Skip to content

Commit

Permalink
[data] add Render instance for Record
Browse files Browse the repository at this point in the history
[data] make `~` an abstract type
  • Loading branch information
road21 committed Jan 19, 2025
1 parent e1a2a97 commit 46d3079
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 67 deletions.
28 changes: 25 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 as ts]: CanEqual[Record[Fields], Record[Fields]] =
discard(TypeIntersection.summonAll[Fields, HasCanEqual])
CanEqual.derived
end given
Expand All @@ -267,6 +272,23 @@ 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.map((field, value) => (field, value, insts.get(field.name))).collect {
case (field, value, Some(r: Render[x])) => field.name + " ~ " + r.asText(value.asInstanceOf[x])
}.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
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""")
}

"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

0 comments on commit 46d3079

Please sign in to comment.