arainko / ducktape

Automatic and customizable compile time transformations between similar case classes and sealed traits/enums, essentially a thing that glues your code. Scala 3 only. Or is it duct 🤔
https://arainko.github.io/ducktape/
Other
402 stars 8 forks source link

Opaque type treated as underlying type in some scenarios #197

Open kubukoz opened 3 weeks ago

kubukoz commented 3 weeks ago

Hi! I was trying to use the library for a work project but found an apparent shortcoming that makes it a little bit tricky.

Consider this example:

//> using dep io.github.arainko::ducktape::0.2.4

import io.github.arainko.ducktape.*

case class From(name: String)

object scoped {

  object types {
    opaque type Ident = String
  }

  export types.Ident

  case class To(name: String, id: Ident)

}

object demo {

  def f(
    f: From,
    theId: scoped.Ident,
  ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))

}

Everything is alright here. Now, move opaque type Ident one level up so that To actually sees that it's a string:

- object types {
-  opaque type Ident = String
-}

- export types.Ident
+ opaque type Ident = String

Now, f fails to compile:

[error] ./main.scala:20:46
[error] Configuration is not valid since the provided type (scoped.Ident) is not a subtype of java.lang.String @ To.id
[error]   ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
[error]                                              ^^^^^^^^^^^^^^^^^^^^^^^^
[error] ./main.scala:20:18
[error] No field 'id' found in From @ To.id

Originally, my code looked like the broken version, I can probably work around this by moving / wrapping the opaque types, but this seems like something that the library could improve upon. :)

arainko commented 3 weeks ago

Hey! Thanks for the report - yeah I'd fully expect it to work... Maybe I went a .dealias too far somewhere down the line, I'll investigate ASAP

arainko commented 3 weeks ago

So, I'm not able to repro this on Scala 3.3.3 (running right from the ducktape repo), i.e. this:

case class From(name: String)

object scoped {

  opaque type Ident = String

  case class To(name: String, id: Ident)

  def f(
    f: From,
    theId: scoped.Ident
  ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}

object demo {

  def f(
    f: From,
    theId: scoped.Ident
  ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))

}

@main def main = {
  println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
  println(demo.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
}

compiles and works exactly as I'd expect:

[info] running io.github.arainko.ducktape.main 
To(a,ident)
To(a,ident)
[success] Total time: 0 s, completed Aug 22, 2024, 9:02:12 PM

I'll try running from a project that pulls the dep in and across different Scalas, maybe that's a factor for some reason...

arainko commented 3 weeks ago

Hah, pulling the dep in from a separate project reproduces it, weird

arainko commented 3 weeks ago

full repro:

//> using scala 3.3.3
//> using repositories sonatype-s01:snapshots
//> using dep io.github.arainko::ducktape::0.2.4-16-8bf2679-20240822T175302Z-SNAPSHOT

import io.github.arainko.ducktape.*

case class From(name: String)

object scoped {

  opaque type Ident = String

  case class To(name: String, id: Ident)

  // def f(
  //   f: From,
  //   theId: scoped.Ident
  // ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}

object demo {
  def f(
    f: From,
    theId: scoped.Ident
  ): scoped.To = 
    f
      .into[scoped.To]
      .transform(Field.const(_.id, theId))

}

@main def main = {
  // println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
  println(demo.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
}

Logs:

aleksander@pop-os:~/repos/repro-196$ scala-cli compile . --server=false
-- [E008] Not Found Error: /home/aleksander/repos/repro-196/repro.scala:34:17 --
34 |  println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
   |          ^^^^^^^^
   |          value f is not a member of object scoped
1 error found
Compilation failed
aleksander@pop-os:~/repos/repro-196$ scala-cli compile . --server=false
[INFO] [repro.scala:17:24] Structure: Product(
  tpe = Type.of[From],
  path = From,
  fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
)
[INFO] [repro.scala:17:24] Structure: Product(
  tpe = Type.of[To],
  path = To,
  fields = Map(
    Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
    Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
  )
)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[String], path = From.name)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[String], path = To.name)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[Nothing], path = From)
[INFO] [repro.scala:17:24] Parsed path: To.id
[INFO] [repro.scala:17:24] Original plan: BetweenProducts(
  source = Product(
    tpe = Type.of[From],
    path = From,
    fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
  ),
  dest = Product(
    tpe = Type.of[To],
    path = To,
    fields = Map(
      Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
      Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
    )
  ),
  fieldPlans = Map(
    Entry(
      key = "name",
      value = Upcast(
        source = Ordinary(tpe = Type.of[String], path = From.name),
        dest = Ordinary(tpe = Type.of[String], path = To.name)
      )
    ),
    Entry(
      key = "id",
      value = Error(
        source = Ordinary(tpe = Type.of[Nothing], path = From),
        dest = Lazy(tpe = Type.of[String], path = To.id),
        message = NoFieldFound(fieldName = "id", fieldTpe = Type.of[String], sourceTpe = Type.of[From]),
        suppressed = None
      )
    )
  )
)
[INFO] [repro.scala:17:24] Config: List(
  Static(
    path = To.id,
    side = Dest(),
    config = Const(value = Expr[theId], tpe = Type.of[Ident]),
    span = Span(start = 569, end = 593)
  )
)
[INFO] [repro.scala:17:24] Reconfigured plan: Reconfigured(
  errors = List(
    Error(
      source = Ordinary(tpe = Type.of[Nothing], path = From),
      dest = Lazy(tpe = Type.of[String], path = To.id),
      message = InvalidConfiguration(
        configTpe = Type.of[Ident],
        expectedTpe = Type.of[String],
        side = Dest(),
        span = Span(start = 569, end = 593)
      ),
      suppressed = None
    )
  ),
  result = BetweenProducts(
    source = Product(
      tpe = Type.of[From],
      path = From,
      fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
    ),
    dest = Product(
      tpe = Type.of[To],
      path = To,
      fields = Map(
        Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
        Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
      )
    ),
    fieldPlans = Map(
      Entry(
        key = "name",
        value = Upcast(
          source = Ordinary(tpe = Type.of[String], path = From.name),
          dest = Ordinary(tpe = Type.of[String], path = To.name)
        )
      ),
      Entry(
        key = "id",
        value = Error(
          source = Ordinary(tpe = Type.of[Nothing], path = From),
          dest = Lazy(tpe = Type.of[String], path = To.id),
          message = InvalidConfiguration(
            configTpe = Type.of[Ident],
            expectedTpe = Type.of[String],
            side = Dest(),
            span = Span(start = 569, end = 593)
          ),
          suppressed = None
        )
      )
    )
  )
)
-- Error: /home/aleksander/repos/repro-196/repro.scala:27:4 --------------------
27 |    f
   |    ^
   |    No field 'id' found in From @ To.id
   |----------------------------------------------------------------------------
   |Inline stack trace
   |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
   |This location contains code that was inlined from repro.scala:27
    ----------------------------------------------------------------------------
-- Error: /home/aleksander/repos/repro-196/repro.scala:29:17 -------------------
29 |      .transform(Field.const(_.id, theId))
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^
   |Configuration is not valid since the provided type (scoped.Ident) is not a subtype of java.lang.String @ To.id
   |----------------------------------------------------------------------------
   |Inline stack trace
   |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
   |This location contains code that was inlined from repro.scala:29
    ----------------------------------------------------------------------------
2 errors found
Compilation failed
arainko commented 2 weeks ago

I'm pretty sure that this is some kind of an incremental compilation bug, it randomly disappears and reappears with no actual code changes (i.e. it fails after adding a newline but disappears after a clean;compile)...

arainko commented 2 weeks ago

Reported this under https://github.com/scala/scala3/issues/21430