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

Make uPickle derivation in Scala 3 call apply method instead of new #607

Merged
merged 13 commits into from
Jul 13, 2024

Conversation

lihaoyi
Copy link
Member

@lihaoyi lihaoyi commented Jul 12, 2024

fixes #552

Bumps minimum Scala 3 version to 3.4.2 because 3.3.3 crashes with weird errors

assert(rError.msg.contains("No given instance of type ReadersVersionSpecific_this.Reader[(A.B : A)] was found"))
assert(wError.msg.contains("No given instance of type WritersVersionSpecific_this.Writer[(A.B : A)] was found"))
// assert(rError.msg.contains("No given instance of type ReadersVersionSpecific_this.Reader[(A.B : A)] was found"))
// assert(wError.msg.contains("No given instance of type WritersVersionSpecific_this.Writer[(A.B : A)] was found"))
Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure why this test broke, but this PR fixes happy-path behavior and this test covers failure-mode error message, so making the happy path work takes precedence

Copy link
Contributor

Choose a reason for hiding this comment

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

these can also be restored with 3.3.4-RC1

@@ -17,7 +17,7 @@ import com.github.lolgab.mill.mima._
val scala212 = "2.12.18"
val scala213 = "2.13.11"

val scala3 = "3.3.3"
val scala3 = "3.4.2"
Copy link
Contributor

Choose a reason for hiding this comment

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

any reason for this?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, 3.3.3 fails with this exception

[error]     |Exception occurred while executing macro expansion.
[error]     |java.lang.ClassCastException: class dotty.tools.dotc.ast.Trees$This cannot be cast to class dotty.tools.dotc.ast.Trees$RefTree (dotty.tools.dotc.ast.Trees$This and dotty.tools.dotc.ast.Trees$RefTree are in unnamed module of loader mill.api.ClassLoader$$anon$1 @4f12ea86)
[error]     |	at scala.quoted.runtime.impl.QuotesImpl.scala$quoted$runtime$impl$QuotesImpl$reflect$Ref$$$_$apply$$anonfun$6(QuotesImpl.scala:446)
[error]     |	at scala.quoted.runtime.impl.QuotesImpl$reflect$.scala$quoted$runtime$impl$QuotesImpl$reflect$$$withDefaultPos(QuotesImpl.scala:3002)
[error]     |	at scala.quoted.runtime.impl.QuotesImpl$reflect$Ref$.apply(QuotesImpl.scala:446)
[error]     |	at scala.quoted.runtime.impl.QuotesImpl$reflect$Ref$.apply(QuotesImpl.scala:444)
[error]     |	at upickle.implicits.macros.macros$package$.apply$1(macros.scala:221)
[error]     |	at upickle.implicits.macros.macros$package$.applyConstructorImpl(macros.scala:255)

This is the code in question

    val companion: Symbol = tpe.classSymbol.get.companionModule
    val constructorParamSymss = tpe.typeSymbol.primaryConstructor.paramSymss

    def isPrimaryApplyMethod(syms1: Seq[Seq[Symbol]], syms2: Seq[Seq[Symbol]]) = {
      // try to guess the primary apply method based on the parameter counts
      // not sure why comparing the types doesn't seem to work
      // println(syms1.flatten.zip(syms2.flatten).map{case (s1, s2) => (s1.typeRef.simplified, s2.typeRef.simplified)})
      syms1.map(_.length) == syms2.map(_.length)
    }

    val applyMethods = companion
      .methodMember("apply")
      .filter(s => isPrimaryApplyMethod(s.paramSymss, constructorParamSymss))

    val lhs = Select(Ref(companion), applyMethods.head)

It's the Ref.apply call that is failing. It was beyond my abilities to debug why, but somehow going onto 3.4.2 fixes it

Copy link
Contributor

Choose a reason for hiding this comment

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

ok, seems to be because of scala/scala3#19732, fixed in 3.4

Copy link
Contributor

Choose a reason for hiding this comment

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

it might not be easy to workaround that in 3.3.3 (like reimplementing Ref.apply)

Copy link
Contributor

Choose a reason for hiding this comment

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

oh it looks like the fix is backported to 3.3.4-RC1

def isPrimaryApplyMethod(syms1: Seq[Seq[Symbol]], syms2: Seq[Seq[Symbol]]) = {
// try to guess the primary apply method based on the parameter counts
// not sure why comparing the types doesn't seem to work
// println(syms1.flatten.zip(syms2.flatten).map{case (s1, s2) => (s1.typeRef.simplified, s2.typeRef.simplified)})
Copy link
Member Author

Choose a reason for hiding this comment

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

@bishabosha do you have any idea how I can find the apply method with a signature matching the primary constructor? In Scala 2 I can just call apply in a quasiquote and it somehow resolves to the right now, but in Scala 3 it seems pickier and asks me to select which apply method I want to call ahead of time

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 you would still want to capture the MirroredElemTypes from the mirror - cast each element of the array to the type from MirroredElemTypes, and then call Select.overloaded(ref, "apply", args) - however then that makes it hard to adapt the varargs if needed - I wonder if its not needed

Copy link
Contributor

Choose a reason for hiding this comment

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

or yeah you maybe can manually resolve the right apply method by comparing the types of the mirrored elem types

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you need the mirrored elem types - cast away with the primary constructor signatures

Copy link
Member Author

Choose a reason for hiding this comment

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

How do I compare the primary constructor signatures though? println(syms1.flatten.zip(syms2.flatten).map{case (s1, s2) => (s1.typeRef, s2.typeRef)}) seems to give me singleton TypeRefs or something for each of the parameters, and when I try to =:= them I get false. Also, if the case class i generic, then presumably the T type param for the primary constructor is different from the T type param from the apply method

Copy link
Contributor

Choose a reason for hiding this comment

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

there's the substituteTypes method, where you can substitute references to the constructor type parameters, with the type arguments of the typeclass

Copy link
Contributor

Choose a reason for hiding this comment

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

I added a suggestion that passes the tests

typeApply match{
case None => Select.overloaded(Ref(companion), "apply", Nil, rhs).asExprOf[T]
case Some(args) =>
Select.overloaded(Ref(companion), "apply", args, rhs).asExprOf[T]
Copy link
Contributor

@bishabosha bishabosha Jul 12, 2024

Choose a reason for hiding this comment

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

just thought of this nasty example though (not tested) -

case class CustomApplyGenVararg[T](x: String, ys: T*)

  object CustomApplyGenVararg {
    def apply(x: String, ys: Int*) = new CustomApplyGenVararg[Int](x.toUpperCase, ys.map(math.abs(_)): _*)
  }

so there is no apply that matches the constructor

Copy link
Member Author

Choose a reason for hiding this comment

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

Is there some way to say "call apply, whichever one ends up neing resolved"? That's basically what it does in Scala 2, and it works great including edge cases such as this one

Copy link
Contributor

@bishabosha bishabosha Jul 12, 2024

Choose a reason for hiding this comment

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

that should be what this overloaded does - runs overload resolution with the provided arguments

Copy link
Contributor

@bishabosha bishabosha Jul 12, 2024

Choose a reason for hiding this comment

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

in which case, maybe you can always leave type arguments empty because the value arguments should be correct - tried that and type arguments are needed :/

Copy link
Contributor

Choose a reason for hiding this comment

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

ok, looks like you can "try both" :)

    typeApply match {
      case None => Select.overloaded(Ref(companion), "apply", Nil, rhs, tpe).asExprOf[T]
      case Some(args) =>
        val term =
          try Select.overloaded(Ref(companion), "apply", Nil, rhs, tpe) // try in case there are no type parameters
          catch case scala.util.control.NonFatal(e) => // assume apply method needs type parameters
            Select.overloaded(Ref(companion), "apply", args, rhs, tpe)
        term.asExprOf[T]
    }

however it somehow still resolves to the synthetic "universal" apply method rather than the user defined one

  case class CustomApplyGen2[T](x: String, y: T)

  object CustomApplyGen2 {
    def apply(x: String, y: Int) = new CustomApplyGen2[Int](x.toUpperCase, math.abs(y))

    implicit def nodeRW[T: ReadWriter]: ReadWriter[CustomApplyGen2[T]] = macroRW[CustomApplyGen2[T]]
  }

...
      test("customApplyGen2") {
        val input = """{"x":"a","y":-102}"""
        val expected = CustomApplyGen2("A", +102)
        val result = upickle.default.read[CustomApplyGen2[Int]](input)
        assert(result == expected)
        val written = upickle.default.write(result)
        assert(written == """{"x":"A","y":102}""")
      }
...
X upickle.AdvancedTests.issues.customApplyGen2 3ms
  utest.AssertionError: result == expected
  result: upickle.AdvancedTests.CustomApplyGen2[Int] = CustomApplyGen2(a,-102)
  expected: upickle.AdvancedTests.CustomApplyGen2[Int] = CustomApplyGen2(A,102)
    utest.asserts.Asserts$.assertImpl(Asserts.scala:30)
    upickle.AdvancedTests$.$init$$$anonfun$1$$anonfun$5$$anonfun$7(AdvancedTests.scala:108)

Comment on lines +207 to +209
val companion: Symbol = tpe.classSymbol.get.companionModule
val constructorSym = tpe.typeSymbol.primaryConstructor
val constructorParamSymss = constructorSym.paramSymss

Choose a reason for hiding this comment

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

Are you sure that those always exist? Quotes API has a bad habit of returning Symbol.noSymbol instead of None. It is not obvious from the type signature that those methods can fail.

@bishabosha
Copy link
Contributor

I think I have the right solution for matching the signatures - and rejects when there isn't a single match (seems to work well in the repl)

def apply(typeApply: Option[List[TypeRepr]]) = {
    val tpe = TypeRepr.of[T]
    val companion: Symbol = tpe.classSymbol.get.companionModule
    val constructorSym = tpe.typeSymbol.primaryConstructor
    val constructorParamSymss = constructorSym.paramSymss

    val (tparams0, params0) = constructorParamSymss.flatten.partition(_.isType)
    val constructorTpe = tpe.memberType(constructorSym).widen

    val companionRef = companion.termRef

    val sub: (tpe: TypeRepr, tparams: List[Symbol]) => TypeRepr =
      typeApply match
        case Some(tps) => (tpe, tparams) => tpe.substituteTypes(tparams, tps)
        case None => (tpe, tparams) => tpe

    val apps = for
      app <- companion.methodMember("apply")
      info = companionRef.memberType(app).widen
      (tps, ps) = app.paramSymss.flatten.partition(_.isType)
      if tps.length == tparams0.length
      if ps.length == params0.length
      if tps.corresponds(tparams0)((t0, t1) =>
        sub(info.memberType(t0), tps) =:= sub(constructorTpe.memberType(t1), tparams0)
      )
      if ps.corresponds(params0)((p0, p1) =>
        sub(info.memberType(p0), tps) =:= sub(constructorTpe.memberType(p1), tparams0)
      )
    yield app

    val matching = apps match
      case app :: Nil => app
      case _ => report.errorAndAbort(s"Cannot find a uniquely matching apply method in companion ${companionRef.show}")

    val lhs = Select(Ref(companion), matching)

    val rhs = params0.zipWithIndex.map {
      case (sym0, i) =>
        val lhs = '{$params(${ Expr(i) })}
        sub(constructorTpe.memberType(sym0), tparams0) match {
          case AnnotatedType(AppliedType(base, Seq(arg)), x)
            if x.tpe =:= defn.RepeatedAnnot.typeRef =>
            arg.asType match {
              case '[t] =>
                Typed(
                  lhs.asTerm,
                  TypeTree.of(using AppliedType(defn.RepeatedParamClass.typeRef, List(arg)).asType)
                )
            }
          case tpe =>
            tpe.asType match {
              case '[t] => '{ $lhs.asInstanceOf[t] }.asTerm
            }
        }

    }

    typeApply match{
      case None => lhs.appliedToArgs(rhs).asExprOf[T]
      case Some(args) => lhs.appliedToTypes(args).appliedToArgs(rhs).asExprOf[T]
    }

@lihaoyi
Copy link
Member Author

lihaoyi commented Jul 13, 2024

Thanks @bishabosha ! I refactored the code a bit and updated the PR

@lihaoyi
Copy link
Member Author

lihaoyi commented Jul 13, 2024

I don't think this quite follows the exact behavior of Scala 2. In particular, in Scala 2 the argument lists do not even need to match AFAIK; you can have an extra implicit parameter list the gets filled in by typechecking, or extra parameters with default values. But it will have to do for now, and we can refine the implementation further in future

@lihaoyi lihaoyi merged commit 1683583 into main Jul 13, 2024
8 checks passed
@lihaoyi lihaoyi deleted the upickle-apply branch July 13, 2024 08:01
@DamianReeves
Copy link

Is it possible to add a chore to revert the minimum Scala 3 version back down to Scala 3.3.4 once that becomes available with the backported changes?

It seems if a library depends on upickle 4.x it is forced to update to using Scala 3.4.x and it seems the intention of Scala Center is for library authors to avoid this, though the guidance does address needing new language features.

As upickle is a part of the toolkit and is used in many places in the ecosystem, it seems forcing the 3.4.2 here for the entirety of the upickle 4.x version will either limit users from upgrading or force the library authors using upickle to upgrade and thus produce a ripple effect.

I admit I may be dead wrong in my analysis here as the Scala LTS versioning strategy is still confusing to me. Also it seems we are around 2 years removed from the announcement of LTS, is a new LTS version perhaps on the horizon that means this is no big deal? 🤷🏾

@lihaoyi
Copy link
Member Author

lihaoyi commented Aug 2, 2024

@DamianReeves yes, that's fine. We can add back support for 3.3.4 or 3.3.5 or whatever version has our bug fix backported. I just didn't want to hold up uPickle 4 for the folks on 2.12/2.13/3.4, but once we can compile on 3.3 we can publish a version supporting it

@nafg
Copy link
Contributor

nafg commented Oct 1, 2024

Did 3.3.4 fix the errors?

@nafg
Copy link
Contributor

nafg commented Oct 1, 2024

I guess it did #635

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.

Case class deserialization doesn't go through apply in Scala 3
5 participants