Skip to content
Open
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
20 changes: 18 additions & 2 deletions kyo-data/shared/src/main/scala/kyo/Fields.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ sealed abstract class Fields[A] extends Serializable:
/** Tuple representation of the fields: `"a" ~ Int & "b" ~ String` becomes `("a" ~ Int) *: ("b" ~ String) *: EmptyTuple`. */
type AsTuple <: Tuple

/** Structural representation of the fields: `"a" ~ Int & "b" ~ String` becomes `Fields.Structural & { def a: Int; def b: String }`. */
type Struct <: Fields.Structural

/** Applies a type constructor `F` to each field component and re-intersects the results. For example,
* `Fields["a" ~ Int & "b" ~ String].Map[Option]` yields `Option["a" ~ Int] & Option["b" ~ String]`.
*/
Expand All @@ -44,9 +47,10 @@ end Fields
/** Companion providing derivation, type-level utilities, and evidence types for field operations. */
object Fields:

private[kyo] def createAux[A, T <: Tuple](_fields: => List[Field[?, ?]]): Fields.Aux[A, T] =
private[kyo] def createAux[A, T <: Tuple, S <: Structural](_fields: => List[Field[?, ?]]): Fields.Aux[A, T, S] =
new Fields[A]:
type AsTuple = T
type Struct = S
lazy val fields = _fields

private[kyo] type Join[A <: Tuple] = Tuple.Fold[A, Any, [B, C] =>> B & C]
Expand All @@ -68,9 +72,10 @@ object Fields:
case (n ~ v1) *: rest => (n ~ (v1, LookupValue[n, T2])) & ZipValues[rest, T2]

/** Refinement type alias that exposes the `AsTuple` member. */
type Aux[A, T] =
type Aux[A, T, S] =
Fields[A]:
type AsTuple = T
type Struct = S

/** Macro-derived given that produces a `Fields` instance for any field intersection type or case class. */
transparent inline given derive[A]: Fields[A] =
Expand Down Expand Up @@ -160,4 +165,15 @@ object Fields:
${ internal.FieldsMacros.sameNamesImpl[A, B] }
end SameNames

type Structural = Structural.Impl & Selectable
object Structural:
opaque type Impl = Dict[String, Any]

extension (s: Impl)
def selectDynamic(name: String): Any =
s(name)

private[kyo] def from[A](dict: Dict[String, Any]): Structural & A =
dict.asInstanceOf[Structural & A]
end Structural
end Fields
197 changes: 95 additions & 102 deletions kyo-data/shared/src/main/scala/kyo/Record.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,112 +34,105 @@ import scala.language.implicitConversions
* @tparam F
* Intersection of `Name ~ Value` field types describing the record's schema
*/
final class Record[F](private[kyo] val dict: Dict[String, Any]) extends Dynamic:
type Record[F] = Record.Impl[F]

/** Retrieves a field value by name via dynamic method syntax. The return type is inferred from the field's declared type. Requires
* `Fields.Have` evidence that the field exists in `F`.
*/
def selectDynamic[Name <: String & Singleton](name: Name)(using h: Fields.Have[F, Name]): h.Value =
dict(name).asInstanceOf[h.Value]
trait RecordDictSyntax:
extension [F](record: Record[F])
/** Returns a new record with the specified field's value replaced. The field name and value type must match a field in `F`. */
def update[Name <: String & Singleton, V](name: Name, value: V)(using F <:< (Name ~ V)): Record[F] =
Record.from(record.toDict.update(name, value.asInstanceOf[Any]))

/** Retrieves a field value by name. Unlike `selectDynamic`, this method works with any string literal, including names that are not
* valid Scala identifiers (e.g., `"user-name"`, `"&"`, `""`).
*/
def getField[Name <: String & Singleton, V](name: Name)(using h: Fields.Have[F, Name]): h.Value =
dict(name).asInstanceOf[h.Value]
/** Returns the number of fields stored in this record. */
def size: Int = record.toDict.size
end extension
end RecordDictSyntax

/** Combines this record with another, producing a record whose type is the intersection of both field sets. If both records contain a
* field with the same name, the value from `other` takes precedence at runtime.
*/
def &[A](other: Record[A]): Record[F & A] =
new Record(dict ++ other.dict)
/** Companion object providing record construction, field type definitions, implicit conversions, and compile-time staging. */
object Record extends RecordDictSyntax:
opaque type Impl[F] = Dict[String, Any]

/** Returns a new record with the specified field's value replaced. The field name and value type must match a field in `F`. */
def update[Name <: String & Singleton, V](name: Name, value: V)(using F <:< (Name ~ V)): Record[F] =
new Record(dict.update(name, value.asInstanceOf[Any]))
private[kyo] def from[F](dict: Dict[String, Any]): Record[F] = dict

/** Returns a new record containing only the fields declared in `F`, removing any extra fields that may be present in the underlying
* storage due to widening. Requires a `Fields` instance for `F`.
*/
def compact(using f: Fields[F]): Record[F] =
new Record(dict.filter((k, _) => f.names.contains(k)))
extension [F](record: Impl[F])
/** Retrieves a field value by name. Unlike `selectDynamic`, this method works with any string literal, including names that are not
* valid Scala identifiers (e.g., `"user-name"`, `"&"`, `""`).
*/
def getField[Name <: String & Singleton, V](name: Name)(using h: Fields.Have[F, Name]): h.Value =
(record: Dict[String, Any])(name).asInstanceOf[h.Value]

/** Returns the field names declared in `F` as a list. */
def fields(using f: Fields[F]): List[String] =
f.fields.map(_.name)
/** Returns the record's contents as a `Dict[String, Any]`. */
def toDict: Dict[String, Any] = record

/** Extracts all field values as a typed tuple, ordered by the field declaration in `F`. */
inline def values(using f: Fields[F]): f.Values =
Record.collectValues[f.AsTuple](dict).asInstanceOf[f.Values]
/** Combines this record with another, producing a record whose type is the intersection of both field sets. If both records contain
* a field with the same name, the value from `other` takes precedence at runtime.
*/
def &[A](other: Record[A]): Record[F & A] =
(record: Dict[String, Any]) ++ other

/** Applies a polymorphic function to each field value, wrapping each value type in `G`. Returns a new record where every field
* `Name ~ V` becomes `Name ~ G[V]`.
*/
def map[G[_]](using
f: Fields[F]
)(
fn: [t] => t => G[t]
): Record[f.Map[~.MapValue[G]]] =
new Record(
dict
.filter((k, _) => f.names.contains(k))
.mapValues(v => fn(v))
)

/** Like `map`, but the polymorphic function also receives the `Field` descriptor for each field, providing access to the field name and
* tag.
*/
def mapFields[G[_]](using
f: Fields[F]
)(
fn: [t] => (Field[?, t], t) => G[t]
): Record[f.Map[~.MapValue[G]]] =
val result = DictBuilder.init[String, Any]
f.fields.foreach: field =>
dict.get(field.name) match
case Present(v) =>
discard(result.add(field.name, fn(field.asInstanceOf[Field[?, Any]], v)))
case _ =>
new Record(result.result())
end mapFields

/** Pairs the values of this record with another record by field name. Both records must have the same field names (verified at compile
* time). For each field `Name ~ V1` in this record and `Name ~ V2` in `other`, the result contains `Name ~ (V1, V2)`.
*/
inline def zip[F2](other: Record[F2])(using
f1: Fields[F],
f2: Fields[F2],
ev: Fields.SameNames[F, F2]
): Record[f1.Zipped[f2.AsTuple]] =
val result = DictBuilder.init[String, Any]
f1.fields.foreach: field =>
discard(result.add(field.name, (dict(field.name), other.dict(field.name))))
new Record(result.result())
end zip

/** Returns the number of fields stored in this record. */
def size: Int = dict.size

/** Returns the record's contents as a `Dict[String, Any]`. */
def toDict: Dict[String, Any] = dict

override def equals(that: Any): Boolean =
that match
case other: Record[?] =>
given CanEqual[Any, Any] = CanEqual.derived
dict.is(other.dict)
case _ => false

override def hashCode(): Int =
var h = 0
dict.foreach((k, v) => h = h ^ (k.hashCode * 31 + v.##))
h
end hashCode
/** Returns a new record containing only the fields declared in `F`, removing any extra fields that may be present in the underlying
* storage due to widening. Requires a `Fields` instance for `F`.
*/
def compact(using f: Fields[F]): Record[F] =
record.filter((k, _) => f.names.contains(k))

end Record
/** Returns the field names declared in `F` as a list. */
def fields(using f: Fields[F]): List[String] =
f.fields.map(_.name)

/** Companion object providing record construction, field type definitions, implicit conversions, and compile-time staging. */
object Record:
/** Extracts all field values as a typed tuple, ordered by the field declaration in `F`. */
inline def values(using f: Fields[F]): f.Values =
Record.collectValues[f.AsTuple](record).asInstanceOf[f.Values]

/** Applies a polymorphic function to each field value, wrapping each value type in `G`. Returns a new record where every field
* `Name ~ V` becomes `Name ~ G[V]`.
*/
def map[G[_]](using
f: Fields[F]
)(
fn: [t] => t => G[t]
): Record[f.Map[~.MapValue[G]]] =
Record.from[f.Map[~.MapValue[G]]](
record
.filter((k, _) => f.names.contains(k))
.mapValues(v => fn(v))
)

/** Like `map`, but the polymorphic function also receives the `Field` descriptor for each field, providing access to the field name
* and tag.
*/
def mapFields[G[_]](using
f: Fields[F]
)(
fn: [t] => (Field[?, t], t) => G[t]
): Record[f.Map[~.MapValue[G]]] =
val result = DictBuilder.init[String, Any]
f.fields.foreach: field =>
toDict.get(field.name) match
case Present(v) =>
discard(result.add(field.name, fn(field.asInstanceOf[Field[?, Any]], v)))
case _ =>
Record.from[f.Map[~.MapValue[G]]](result.result())
end mapFields

/** Pairs the values of this record with another record by field name. Both records must have the same field names (verified at
* compile time). For each field `Name ~ V1` in this record and `Name ~ V2` in `other`, the result contains `Name ~ (V1, V2)`.
*/
inline def zip[F2](other: Record[F2])(using
f1: Fields[F],
f2: Fields[F2],
ev: Fields.SameNames[F, F2]
): Record[f1.Zipped[f2.AsTuple]] =
val result = DictBuilder.init[String, Any]
f1.fields.foreach: field =>
discard(result.add(field.name, (toDict(field.name), other.toDict(field.name))))
Record.from[f1.Zipped[f2.AsTuple]](result.result())
end zip
end extension

/** Conversion that allows access to fields by name. */
given [F, T, S](using c: Fields.Aux[F, T, S]): Conversion[Impl[F], S] =
new Conversion[Impl[F], S]:
def apply(r: Impl[F]): S = Fields.Structural.from(r)

/** Phantom type representing a field binding from a singleton string name to a value type. Contravariant in `Value` so that duplicate
* field names with different types are normalized to a union: `"f" ~ Int & "f" ~ String =:= "f" ~ (Int | String)`.
Expand All @@ -158,7 +151,7 @@ object Record:
case _ *: rest => FieldValue[rest, Name]

/** An empty record with type `Record[Any]`, which is the identity element for `&`. */
val empty: Record[Any] = new Record(Dict.empty[String, Any])
val empty: Record[Any] = Dict.empty[String, Any]

/** Implicit conversion that enables structural subtyping for Record. Since `F` is invariant, this conversion allows a `Record[A]` to be
* used where a `Record[B]` is expected whenever `A <: B`. This is safe because the underlying `Dict` storage is read-only, and the `~`
Expand All @@ -170,7 +163,7 @@ object Record:
/** Creates a single-field record from a string literal name and a value. */
extension (self: String)
def ~[Value](value: Value): Record[self.type ~ Value] =
new Record(Dict[String, Any](self -> value))
Dict[String, Any](self -> value)

/** Provides `CanEqual` for records whose field types are all comparable, enabling `==` and `!=`. */
given [F](using Fields.Comparable[F]): CanEqual[Record[F], Record[F]] =
Expand All @@ -183,7 +176,7 @@ object Record:
Render.from: (value: Record[F]) =>
val sb = new StringBuilder
var first = true
value.dict.foreach: (name, v) =>
value.toDict.foreach: (name, v) =>
if renders.contains(name) then
if !first then discard(sb.append(" & "))
discard(sb.append(name).append(" ~ ").append(renders.get(name).asText(v)))
Expand All @@ -204,7 +197,7 @@ object Record:
*/
class StageOps[A, T <: Tuple](dummy: Unit) extends AnyVal:
inline def apply[G[_]](fn: [v] => Field[?, v] => G[v])(using f: Fields[A]): Record[f.Map[~.MapValue[G]]] =
new Record(stageLoop[f.AsTuple, G](fn)).asInstanceOf[Record[f.Map[~.MapValue[G]]]]
stageLoop[f.AsTuple, G](fn).asInstanceOf[Record[f.Map[~.MapValue[G]]]]

/** Adds a type class constraint `TC` that must be available for each field's value type. Produces a `StageWith` that accepts a
* function receiving both the `Field` descriptor and the `TC` instance.
Expand All @@ -218,7 +211,7 @@ object Record:
*/
class StageWith[A, T <: Tuple, TC[_]](dummy: Unit) extends AnyVal:
inline def apply[G[_]](fn: [v] => (Field[?, v], TC[v]) => G[v])(using f: Fields[A]): Record[f.Map[~.MapValue[G]]] =
new Record(stageLoopWith[f.AsTuple, TC, G](fn)).asInstanceOf[Record[f.Map[~.MapValue[G]]]]
stageLoopWith[f.AsTuple, TC, G](fn).asInstanceOf[Record[f.Map[~.MapValue[G]]]]

// Note: stageLoop/stageLoopWith use Dict here but SummonAll uses Map. Dict works in these methods
// but causes the compiler to hang in SummonAll, likely due to how the opaque type interacts with
Expand Down Expand Up @@ -277,6 +270,6 @@ object Record:
dict(constValue[n & String]) *: collectValues[rest](dict)

private[kyo] def init[F](dict: Dict[String, Any]): Record[F] =
new Record(dict)
dict

end Record
40 changes: 30 additions & 10 deletions kyo-data/shared/src/main/scala/kyo/internal/FieldsMacros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ object FieldsMacros:
case h +: t => TypeRepr.of[*:].appliedTo(List(h, tupled(t)))
case _ => TypeRepr.of[EmptyTuple]

def structural(typs: Vector[TypeRepr]): TypeRepr =
if typs.isEmpty then
TypeRepr.of[Fields.Structural]
else
val structType = typs.foldLeft(Map[String, TypeRepr]()) {
case (acc, AppliedType(_, List(ConstantType(StringConstant(name)), valueType))) =>
acc.get(name) match
case Some(x) => acc + (name -> OrType(x, valueType))
case None => acc + (name -> valueType)
case (acc, _) => acc
}.foldLeft(TypeRepr.of[Any]) {
case (acc, (name, valueType)) =>
Refinement(acc, name, ByNameType(valueType))
}
AndType(TypeRepr.of[Fields.Structural], structType)

val components = decompose(TypeRepr.of[A].dealias)

case class ComponentInfo(name: String, nameExpr: Expr[String], tagExpr: Expr[Any], nestedExpr: Expr[List[Field[?, ?]]])
Expand All @@ -63,12 +79,16 @@ object FieldsMacros:
Expr.summon[Tag[v]].getOrElse(
report.errorAndAbort(s"Cannot summon Tag for field '$name': ${valueType.show}")
)
val nestedExpr = valueType.asType match
case '[Record[f]] =>
Expr.summon[Fields[f]] match
case Some(fields) => '{ $fields.fields }
case None => '{ Nil: List[Field[?, ?]] }
case _ => '{ Nil: List[Field[?, ?]] }
val recordRepr = TypeRepr.of[Record]
val nestedExpr = valueType.dealias match
case AppliedType(recordRepr, List(f)) =>
f.asType match
case '[f] =>
Expr.summon[Fields[f]] match
case Some(fields) => '{ $fields.fields }
case None => '{ Nil: List[Field[?, ?]] }
case _ =>
'{ Nil: List[Field[?, ?]] }
Some(ComponentInfo(name, nameExpr, tagExpr, nestedExpr))
case _ => None

Expand All @@ -77,9 +97,9 @@ object FieldsMacros:
'{ Field[String, Any](${ ci.nameExpr }, ${ ci.tagExpr }.asInstanceOf[Tag[Any]], ${ ci.nestedExpr }) }
).toList)

tupled(components).asType match
case '[type x <: Tuple; x] =>
'{ Fields.createAux[A, x]($fieldsList) }
(tupled(components).asType, structural(components).asType) match
case ('[type x <: Tuple; x], '[type s <: Fields.Structural; s]) =>
'{ Fields.createAux[A, x, s]($fieldsList) }
end match
end deriveImpl

Expand Down Expand Up @@ -253,7 +273,7 @@ object FieldsMacros:
'{ () }
)
}
new Record[f](Dict.fromArrayUnsafe(arr.asInstanceOf[Array[String | Any]]))
Dict.fromArrayUnsafe(arr.asInstanceOf[Array[Any]]).asInstanceOf[Record[f]] // Record.from leads to cyclic macro error
}
end match
end fromProductImpl
Expand Down
1 change: 1 addition & 0 deletions kyo-data/shared/src/test/scala/kyo/FieldTest.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kyo

import Record.*
import scala.language.implicitConversions

class FieldTest extends Test:

Expand Down
Loading
Loading