-
-
Notifications
You must be signed in to change notification settings - Fork 28
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
Add SplitByType
s observables
#116
Conversation
src/main/scala-3/com/raquo/airstream/split/SplittableTypeMacros.scala
Outdated
Show resolved
Hide resolved
src/main/scala-3/com/raquo/airstream/split/SplittableTypeMacros.scala
Outdated
Show resolved
Hide resolved
4c2700c
to
3066f39
Compare
@kitlangton @raquo is there anything left to do with this PR? I think it is quite useful |
It's definitely very useful, thanks for your work on this! I am yet to properly review this, but on the high level it looks good. Sorry for such a delay, this was just too much for me to include in v17. I plan to merge this when I start working on the next Laminar release, likely around September-October. Since all this ties into Airstream with extension methods, I assume that you can use this code privately for now. |
src/test/scala/com/raquo/airstream/split/SplittableTypeSpec.scala
Outdated
Show resolved
Hide resolved
Awesome! Thanks @raquo |
e67afbc
to
ee67837
Compare
@raquo https://github.com/felher/laminouter/#how-to-get-rid-of-the-asinstanceof is an example of why this PR can be useful. Can you please take another look? Thanks |
@ngbinh Sorry for such a long delay on this, it's definitely a very useful feature, the blame is entirely on my availability and schedule. I feel bad for letting such a good contribution go unmerged for so long. The reason is, I'm currently working overdrive on an unrelated project, and won't be able to spend a significant time on OSS until late October, maybe even early November. I'm itching very hard to publish this and other new features once I'm able to dedicate the time. Merging this PR will be one of my first priorities when I get back into it. |
src/main/scala-3/com/raquo/airstream/split/SplittableTypeMacros.scala
Outdated
Show resolved
Hide resolved
src/main/scala-3/com/raquo/airstream/split/SplittableTypeMacros.scala
Outdated
Show resolved
Hide resolved
src/test/scala-3/com/raquo/airstream/split/SplittableTypeSpec.scala
Outdated
Show resolved
Hide resolved
src/test/scala-3/com/raquo/airstream/split/SplittableTypeSpec.scala
Outdated
Show resolved
Hide resolved
ee67837
to
9a41579
Compare
handleTypeImpl[Self, I, O, T]('{ matchSplitObservable }, '{ casePf }) | ||
} | ||
|
||
inline def handleType[T]: MatchTypeObservable[Self, I, O, T] = handlePfType[T] { case t: T => t } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason I need to take this "detour" is that, originally this should be handleType[T][O1 >:O](inline handleFn: ((T, Signal[T])) => O1)
. O1>:O
here is very important because it helps refining the output type from the ground "Nothing" up to the output type we want. Sadly Scala 3 right now can only do handleType[T, O1 >:O]
, and this force user to explicitly named the output type of the handleFn
function.
Until SIP-47 is widely adopted, we will have to stick into this detour
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do I understand correctly that calling handleType[Foo[A]]
is equivalent to the user writing handleType { case f: Foo[A @unchecked] => f }
– that is, the Foo
will be checked, but A
will be unchecked? That's fine and that's what I would expect, given type erasure, just wanted to double check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I believe so, the end result of this macro will create a simple match/cases
code, before any transformation happens. So it should follow the normal behavior of scala
val signal = myVar.signal | ||
.splitMatch | ||
.handleCase { | ||
case Bar(Some(str)) => str | ||
case Bar(None) => "null" | ||
} { case (str, strSignal) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just noticed that the public API requires the user to provide a function expecting a single argument – a tuple of (A, Signal[A])
– does it have to be like this, instead of accepting two arguments, A
and Signal[A]
?
The single tuple argument basically requires the end user to provide a partial function using case
, where a total function is expected. If the function accepted two arguments, we could write the same but without the second case
:
.handleCase {
case Bar(Some(str)) => str
case Bar(None) => "null"
} { (str, strSignal) =>
...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can be changed, it's just an old code from the initial implementations that slips through
If it's not too much to ask, could you please add another helper – |
* ``` | ||
*/ | ||
|
||
opaque type MatchValueObservable[Self[+_] <: Observable[_], I, O, V0, V1] = Unit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
V0
and V1
should never be different, but we cannot just do MatchValueObservable[Self[+_] <: Observable[_], I, O, V]
because after type erasure, compiler cannot differentiates MatchValueObservable[Self[+_] <: Observable[_], I, O, V]
from MatchTypeObservable[Self[+_] <: Observable[_], I, O, T]
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TBH the implications of this are over my head, but it sounds like it's just an unfortunate implementation detail that we have to deal with. In that case, fine by me, I certainly don't have better ideas on this.
I think we're all good now, thanks for all the updates!
I am super pumped for these new operators, this will be one of the first things that I will merge for 18.x, proooobably in a few weeks (but apologies in advance if my 18.x gets delayed a bit, honestly, it's possible, but I'll do my best to carve out some time for it asap).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ambiguity happens because of the opaque types. I changed them back to value classes to remove this implementation hack. It should be easier to read and understand now.
handleValueApplyImpl('{ matchValueObservable }, '{ handleFn }) | ||
} | ||
|
||
inline def apply[O1 >: O](inline handleFn: Signal[V1] => O1): MatchSplitObservable[Self, I, O1] = deglate { (_, vSignal) => handleFn(vSignal) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
difference in signature (Signal[V1] => O1
here) means we need another (quite redundant IMHO) dummy class and dummy "detour".
@raquo should we have another variant for |
Also, I see that |
@HollandDM Hmmm yeah, if it's actually possible, it would be nice... trait Bar
case class Foo(int: Int) extends Bar
case obect Baz extends Bar To mirror the normal Var.split API, ideally we would want to provide an API like the following, I guess? val barVar: Var[Bar] = ???
barBar
.matchSplit
.handleCase { case Foo(num) => num } { (num: Int, numVar: Var[Int]) => ... }
.handleValue(Baz) { (bazVar: Var[Baz]) => ... } So then, we would need to e.g. create such a Var[Int] that would write Foo(int) into barVar – but I don't think that possible to automatically create such a Var, just from So then, it looks like we would need to add another parameter, e.g.: barBar
.matchSplit
.handleCase { case Foo(num) => num } { num => Foo(num) } { (num: Int, numVar: Var[Int]) => ... }
.handleValue(Baz) { (bazVar: Var[Baz]) => ... } Which is a bit verbose, but ultimately fine – it would be similar to how we have two params in the |
PR for such a I do plan to resolve #157 eventually, although I'm not sure how it relates to |
For In
For Also, I think we will need a way to let users pass their
This is more of a Laminar only solution, as you can do: children <-- eitherSeq.split(eitherToKey) { (_, _, eitherSignal) =>
child <-- eitherSignal.splitEither(
left = ???,
right = ???
)
} |
@HollandDM So uh this is a lot of stuff at once... sorry if I mixed anything up. This new Let's ignore the How to define split keys in val signalOfFoos: Signal[List[Foo]] = ???
signalOfFoos
.splitMatchSeq(
key = {
case Foo(num) => num
case Foo2(idStr) => idStr
case Bar => ()
},
distinctCompose = ???,
duplicateKeys = ???
)
.handleCase { case Foo(num) => num } { (num: Int, numSignal: Signal[Int]) => ... }
.handleType[Foo2] { (foo2: Foo2, foo2Signal: Signal[Foo2]) => ... }
.handleValue(Bar) { (barSignal: Signal[Bar.type]) => ... ] This introduces some redundancy between the definition of Perhaps the shared key definition could be simplified if the common Foo type had a property like The real key used by And so, another downside of defining the keys separately like this is that we don't get the OTOH I think all of the above can be implemented just in terms of Overall I think I still to prefer this syntax over specifying the key in every case. What do you think, would it fit your use cases? Am I missing some more ergonomic concerns? Regarding the naming, I think |
using a single "global" function make can make the signalOfFoos
.splitMatchSeq(
key = {
case Foo(num) if num > 0 => num
case Foo2(idStr) => idStr
case _ => ()
},
distinctCompose = ???,
duplicateKeys = ???
)
.handleCase { case Foo(num) => num } { (num: Int, numSignal: Signal[Int]) => ... }
.handleType[Foo2] { (foo2: Foo2, foo2Signal: Signal[Foo2]) => ... }
.handleValue(Bar) { (barSignal: Signal[Bar.type]) => ... ] This is a valid syntax, but Perhaps we should just separate the key definition here from the For me personally, beside my custom |
I see that there is room for this, but in practice the keys will depend on the type only, so there should be no value-based conditions like I can imagine very complex cases where you would need to pay attention to get the keys right, but realistically, those would be very rare, and I'm ok to take the tradeoff of putting an ergonomic burden on those rare use cases to get a simpler API for the 99% of typical uses, because we don't have a perfect alternative that offers clean syntax and good ergonomics for the typical use cases, especially when we consider the eventual support for splitting Var-s with this operator.
A shared type for userKey would only be useful in a narrow set of cases, but e.g. for an Either, it could easily end up Int | String, or more realistically it would resolve to Any without explicit type ascriptions. Ideally we would want key types to be specific to each handleCase, but that is not possible without moving key callbacks into individual handleCase calls. I think in practice we can live without providing the key to the handle callback. Usually the key would be something obvious like |
I think Its quite impossible for key to be specific for each signalOfFoos
.splitMatchSeq(
key = Foo => K,
distinctCompose = ???,
duplicateKeys = ???
)
.handleCase { case Foo(num) => num } { (num: Int, numSignal: Signal[Int]) => ... }
.handleType[Foo2] { (foo2: Foo2, foo2Signal: Signal[Foo2]) => ... }
.handleValue(Bar) { (barSignal: Signal[Bar.type]) => ... ] In the above snippet, |
Ok, yes, I think we're on the same page. If I understand correctly – no, the user-provided keys don't need to follow handleCase-s. Any Also, just wanted to mention that since the type K in |
Yeah, I just want to clarify the key point before committing, I see that we agree the |
Sounds good, thank you for all this 🙏 |
@raquo |
@raquo is there anything else left to do for this PR? Thanks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All is good, thanks! Super stoked to have this feature. I'm finally able to slowly start work on the next version, so I'll be merging this soon.
@HollandDM Sorry I was too eager to merge this, but now I see there are some issues yet remaining. Note – I did not expect to need more changes, so I've squash-merged the PR, as I usually do to clean up git history, but because of that you'll need to force rebase on Airstream's master to reconnect to that history. Sorry for the inconvenience :| Using too many cases at once slows down the compiler, eventually resulting in an OOO crashTo reproduce, add this test to SplitMatchOneSpec: it("xxx") {
val myVar = Var[Foo](Bar(Some("initial")))
val signal = myVar.signal
.splitMatchOne
.handleCase { case Bar(Some(str)) => str } { (str, strSignal) => () }
.handleCase { case Bar(Some(str)) => str } { (str, strSignal) => () }
// !!! TODO: To crash the compiler, insert the same handleCase line 20-40 times here
.handleCase { case Bar(Some(str)) => str } { (str, strSignal) => () }
.handleCase { case Bar(Some(str)) => str } { (str, strSignal) => () }
.toSignal
} For me it usually takes 10-20 seconds for the compiler to crash. Depends on CPU and heap I guess. If it doesn't crash for you, you may need to add more lines. It will still be slow though, and it may give different warnings:
I ran into this issue when trying to convert these waypoint cases in laminar-demo app. The new Airstream tests compile with Scala 3.3.3, but not with 3.3.4 or higher.
|
As discussed in #103, this is the implementation of
SplitByType
macro for observable.The macro will expand into
match/case
definition, so it can takes advantage of exhaustive and reachable checking of the compiler.