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

[data] add Render instance for Record (#1008) #1019

Merged
merged 2 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions kyo-data/shared/src/main/scala/kyo/Record.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package kyo

import Record.*
import kyo.internal.Inliner
import kyo.internal.TypeIntersection
import scala.annotation.implicitNotFound
import scala.compiletime.constValue
import scala.compiletime.erasedValue
import scala.compiletime.summonInline
import scala.deriving.Mirror
import scala.language.dynamics
Expand Down Expand Up @@ -73,7 +75,7 @@ import scala.util.NotGiven
* - Tag derivation for Records is not currently supported
* - CanEqual and Render instances are not provided for Records
*/
class Record[+Fields](val toMap: Map[Field[?, ?], Any]) extends AnyVal with Dynamic:
final class Record[+Fields] private (val toMap: Map[Field[?, ?], Any]) extends AnyVal with Dynamic:

/** Retrieves a value from the Record by field name.
*
Expand Down Expand Up @@ -115,12 +117,15 @@ class Record[+Fields](val toMap: Map[Field[?, ?], Any]) extends AnyVal with Dyna
/** Returns the number of fields in this Record.
*/
def size: Int = toMap.size

end Record

export Record.`~`

object Record:
/** Creates an empty Record
*/
val empty: Record[Any] = Record[Any](Map())

given [Fields]: Flat[Record[Fields]] = Flat.unsafe.bypass

final infix class ~[Name <: String, Value] private ()
Expand Down Expand Up @@ -257,7 +262,7 @@ object Record:
Field match
case name ~ value => CanEqual[value, value]

transparent inline given [Fields]: CanEqual[Record[Fields], Record[Fields]] =
inline given [Fields: TypeIntersection]: CanEqual[Record[Fields], Record[Fields]] =
discard(TypeIntersection.summonAll[Fields, HasCanEqual])
CanEqual.derived
end given
Expand All @@ -267,6 +272,27 @@ object Record:
"Cannot derive Tag for Record type. This commonly occurs when trying to nest Records, " +
"which is not currently supported by the Tag implementation."
)

private object RenderInliner extends Inliner[(String, Render[?])]:
inline def apply[T]: (String, Render[?]) =
inline erasedValue[T] match
case _: (n ~ v) =>
val ev = summonInline[n <:< String]
val inst = summonInline[Render[v]]
ev(constValue[n]) -> inst
end RenderInliner

inline given [Fields: TypeIntersection]: Render[Record[Fields]] =
val insts = TypeIntersection.inlineAll[Fields, (String, Render[?])](RenderInliner).toMap
Render.from: (value: Record[Fields]) =>
value.toMap.foldLeft(Vector[String]()) { case (acc, (field, value)) =>
insts.get(field.name) match
case Some(r: Render[x]) =>
acc :+ (field.name + " ~ " + r.asText(value.asInstanceOf[x]))
case None => acc
end match
}.mkString(" & ")
end given
end Record

object AsFieldsInternal:
Expand Down
61 changes: 27 additions & 34 deletions kyo-data/shared/src/main/scala/kyo/Render.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,63 +16,56 @@ sealed trait LowPriorityRenders:
object Render extends LowPriorityRenders:
inline def apply[A](using r: Render[A]): Render[A] = r

def from[A](impl: A => Text): Render[A] =
new Render[A]:
def asText(value: A): Text = impl(value)

def asText[A](value: A)(using r: Render[A]): Text = r.asText(value)

import scala.compiletime.*

@nowarn("msg=anonymous")
private inline def sumRender[A, M <: scala.deriving.Mirror.ProductOf[A]](label: String, mir: M): Render[A] =
val shows = summonAll[Tuple.Map[mir.MirroredElemTypes, Render]]
new Render[A]:
def asText(value: A): String =
val builder = java.lang.StringBuilder()
builder.append(label)
builder.append("(")
val valIter = value.asInstanceOf[Product].productIterator
val showIter: Iterator[Render[Any]] = shows.productIterator.asInstanceOf
if valIter.hasNext then
builder.append(showIter.next().asText(valIter.next()))
()
while valIter.hasNext do
builder.append(",")
builder.append(showIter.next().asText(valIter.next()))
()
end while
builder.append(")")
builder.toString()
end asText
end new
Render.from: (value: A) =>
val builder = java.lang.StringBuilder()
builder.append(label)
builder.append("(")
val valIter = value.asInstanceOf[Product].productIterator
val showIter: Iterator[Render[Any]] = shows.productIterator.asInstanceOf
if valIter.hasNext then
builder.append(showIter.next().asText(valIter.next()))
()
while valIter.hasNext do
builder.append(",")
builder.append(showIter.next().asText(valIter.next()))
()
end while
builder.append(")")
builder.toString()
end sumRender

@nowarn("msg=anonymous")
inline given [A](using mir: scala.deriving.Mirror.Of[A]): Render[A] = inline mir match
case sumMir: scala.deriving.Mirror.SumOf[?] =>
val shows = summonAll[Tuple.Map[sumMir.MirroredElemTypes, Render]]
new Render[A]:
def asText(value: A): Text =
val caseIndex = sumMir.ordinal(value)
val showInstance: Render[Any] = shows.productElement(caseIndex).asInstanceOf
showInstance.asText(value)
end asText
end new
Render.from: (value: A) =>
val caseIndex = sumMir.ordinal(value)
val showInstance: Render[Any] = shows.productElement(caseIndex).asInstanceOf
showInstance.asText(value)
case singMir: scala.deriving.Mirror.Singleton =>
val label: String = constValue[singMir.MirroredLabel]
new Render[A]:
def asText(value: A): Text = label
Render.from(_ => label)
case prodMir: scala.deriving.Mirror.ProductOf[?] => inline erasedValue[A] match
case _: Tuple =>
inline erasedValue[prodMir.MirroredElemTypes] match
case _: EmptyTuple =>
new Render[A]:
def asText(value: A): Text = "()"
Render.from(_ => "()")
case _ =>
sumRender[A, prodMir.type]("", prodMir)
case _ =>
val label: String = constValue[prodMir.MirroredLabel]
inline erasedValue[prodMir.MirroredElemTypes] match
case _: EmptyTuple =>
new Render[A]:
def asText(value: A): Text = label + "()"
Render.from(_ => label + "()")
case _ =>
sumRender[A, prodMir.type](label, prodMir)
end match
Expand Down
20 changes: 20 additions & 0 deletions kyo-data/shared/src/main/scala/kyo/internal/Inliner.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kyo.internal

import scala.compiletime.erasedValue

trait Inliner[A]:
inline def apply[T]: A

object Inliner:
inline def inlineAllLoop[A, T <: Tuple](f: Inliner[A]): List[A] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h1 *: h2 *: h3 *: h4 *: h5 *: h6 *: h7 *: h8 *: h9 *: h10 *: h11 *: h12 *: h13 *: h14 *: h15 *: h16 *:
ts) =>
f[h1] :: f[h2] :: f[h3] :: f[h4] :: f[h5] :: f[h6] :: f[h7] :: f[h8]
:: f[h9] :: f[h10] :: f[h11] :: f[h12] :: f[h13] :: f[h14] :: f[h15] :: f[h16]
:: inlineAllLoop[A, ts](f)
case _: (h1 *: h2 *: h3 *: h4 *: ts) =>
f[h1] :: f[h2] :: f[h3] :: f[h4] :: inlineAllLoop[A, ts](f)
case _: (h *: ts) => f[h] :: inlineAllLoop[A, ts](f)
end Inliner
44 changes: 18 additions & 26 deletions kyo-data/shared/src/main/scala/kyo/internal/TypeIntersection.scala
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,24 @@ object TypeIntersection:
* @return
* a List of type class instances
*/
transparent inline def summonAll[A: TypeIntersection as ts, F[_]]: List[F[Any]] =
summonAllLoop[ts.AsTuple, F]
transparent inline def summonAll[A: TypeIntersection, F[_]]: List[F[Any]] =
inlineAll[A, F[Any]](new SummonInliner[F])

class SummonInliner[F[_]] extends Inliner[F[Any]]:
inline def apply[T]: F[Any] =
summonInline[F[T]].asInstanceOf[F[Any]]

/** Runs Inliner logic for each component type in A.
*
* @tparam A
* the intersection type to decompose
* @tparam R
* the result type of inline logic
* @return
* a List of type class instances
*/
inline def inlineAll[A: TypeIntersection as ts, R](inliner: Inliner[R]): List[R] =
Inliner.inlineAllLoop[R, ts.AsTuple](inliner)

/** Type alias for TypeIntersection with a specific tuple type.
*
Expand Down Expand Up @@ -113,28 +129,4 @@ object TypeIntersection:
}
end match
end deriveImpl

private transparent inline def summonAllLoop[T <: Tuple, F[_]]: List[F[Any]] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h1 *: h2 *: h3 *: h4 *: h5 *: h6 *: h7 *: h8 *: h9 *: h10 *: h11 *: h12 *: h13 *: h14 *: h15 *: h16 *: tail) =>
summonInline[F[h1]].asInstanceOf[F[Any]] ::
summonInline[F[h2]].asInstanceOf[F[Any]] ::
summonInline[F[h3]].asInstanceOf[F[Any]] ::
summonInline[F[h4]].asInstanceOf[F[Any]] ::
summonInline[F[h5]].asInstanceOf[F[Any]] ::
summonInline[F[h6]].asInstanceOf[F[Any]] ::
summonInline[F[h7]].asInstanceOf[F[Any]] ::
summonInline[F[h8]].asInstanceOf[F[Any]] ::
summonInline[F[h9]].asInstanceOf[F[Any]] ::
summonInline[F[h10]].asInstanceOf[F[Any]] ::
summonInline[F[h11]].asInstanceOf[F[Any]] ::
summonInline[F[h12]].asInstanceOf[F[Any]] ::
summonInline[F[h13]].asInstanceOf[F[Any]] ::
summonInline[F[h14]].asInstanceOf[F[Any]] ::
summonInline[F[h15]].asInstanceOf[F[Any]] ::
summonInline[F[h16]].asInstanceOf[F[Any]] ::
summonAllLoop[tail, F]
case _: (t *: ts) =>
summonInline[F[t]].asInstanceOf[F[Any]] :: summonAllLoop[ts, F]
end TypeIntersection
37 changes: 33 additions & 4 deletions kyo-data/shared/src/test/scala/kyo/RecordTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,12 @@ class RecordTest extends Test:
""")
}

"not compile when fields lack CanEqual" in {
"not compile when fields lack CanEqual" in pendingUntilFixed { // looks like scala3 bug
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 don't know why this test works in main

When I was fighting with it, I minimized my case to

import scala.compiletime.*
import scala.language.strictEquality

class Lol
object Lol:
  inline given CanEqual[Lol, Lol] = error("lol")

val x = new Lol
val y = new Lol

// summon[CanEqual[Lol, Lol]] // doesn't compile
x == y // compile, and this is strange

https://scastie.scala-lang.org/road21/B8hWnSQNTriBcOYndUduRg

For me this looks like a bug in scala3 compiler, what do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah, that's odd. I can follow up on this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

case class NoEqual(x: Int)

val record1: Record["test" ~ NoEqual] = "test" ~ NoEqual(1)
val record2 = "test" ~ NoEqual(1)

assertDoesNotCompile("""
assert(record1 == record2)
""")
Expand Down Expand Up @@ -513,10 +515,37 @@ class RecordTest extends Test:
}

"Render" - {
"simple record" in pendingUntilFixed {
"simple record" in {
val record = "name" ~ "John" & "age" ~ 30
assert(Render.asText(record).show == """name ~ "John" & age ~ 30""")
()
assert(Render.asText(record).show == """name ~ John & age ~ 30""")
road21 marked this conversation as resolved.
Show resolved Hide resolved
}

"long simple record" in {
val record = "name" ~ "Bob" & "age" ~ 25 & "city" ~ "London" & "active" ~ true
assert(Render.asText(record).show == """name ~ Bob & age ~ 25 & city ~ London & active ~ true""")
}

"empty record" in {
val record = Record.empty
assert(Render.asText(record).show == "")
}

"render with upper type instance" in {
val record = "name" ~ "Bob" & "age" ~ 25 & "city" ~ "London" & "active" ~ true
val render = Render[Record["name" ~ String & "city" ~ String]]
assert(render.asString(record) == """name ~ Bob & city ~ London""")
}

"respects custom render instances" in {
case class Name(u: String)
given Render[Name] with
def asText(name: Name): Text =
val (prefix, suffix) = name.u.splitAt(3)
prefix ++ suffix.map(_ => '*')
end given

val record = "first" ~ Name("John") & "last" ~ Name("Johnson")
assert(Render.asText(record).show == """first ~ Joh* & last ~ Joh****""")
}
}

Expand Down
Loading