diff --git a/kyo-data/shared/src/main/scala/kyo/ConstValue.scala b/kyo-data/shared/src/main/scala/kyo/ConstValue.scala new file mode 100644 index 000000000..4972a444f --- /dev/null +++ b/kyo-data/shared/src/main/scala/kyo/ConstValue.scala @@ -0,0 +1,18 @@ +package kyo + +import scala.compiletime.constValue as scConstValue + +/** An opaque type that materializes a compile-time constant value as a runtime value via an implicit given. + * + * `ConstValue[A]` is a subtype of `A`, backed by `scala.compiletime.constValue`. When `A` is a singleton literal type (e.g., `"name"`, + * `42`, `true`), summoning a `ConstValue[A]` produces the corresponding runtime value. This is useful for passing singleton type + * information to runtime code without requiring an explicit `inline` context at every call site. + * + * @tparam A + * A singleton literal type whose compile-time value is materialized at runtime + */ +opaque type ConstValue[A] <: A = A + +object ConstValue: + /** Materializes the compile-time constant for singleton type `A` as a runtime value. */ + inline given [A]: ConstValue[A] = scConstValue[A] diff --git a/kyo-data/shared/src/main/scala/kyo/Field.scala b/kyo-data/shared/src/main/scala/kyo/Field.scala new file mode 100644 index 000000000..010b00fca --- /dev/null +++ b/kyo-data/shared/src/main/scala/kyo/Field.scala @@ -0,0 +1,34 @@ +package kyo + +import Record.~ + +/** A reified field descriptor carrying the field's singleton string name, its value type's `Tag`, and optional nested field descriptors + * (populated when the value type is itself a `Record`). + * + * Field instances are typically obtained from `Fields.fields` or constructed via the companion's `apply` method. They serve as runtime + * metadata for operations like `mapFields`, `stage`, and serialization, and also provide typed `get`/`set` accessors on records. + * + * @tparam Name + * The singleton string type of the field name + * @tparam Value + * The field's value type + */ +case class Field[Name <: String, Value]( + name: Name, + tag: Tag[Value], + nested: List[Field[?, ?]] = Nil +): + /** Extracts this field's value from a record. Requires evidence that `F` contains `Name ~ Value`. */ + def get[F](record: Record[F])(using F <:< (Name ~ Value)): Value = + record.toDict(name).asInstanceOf[Value] + + /** Returns a new record with this field's value replaced. Requires evidence that `F` contains `Name ~ Value`. */ + def set[F](record: Record[F], value: Value)(using F <:< (Name ~ Value)): Record[F] = + Record.init(record.toDict.update(name, value.asInstanceOf[Any])) +end Field + +object Field: + /** Constructs a `Field` by summoning the singleton name value and `Tag` from implicit scope. */ + def apply[Name <: String & Singleton, Value](using name: ConstValue[Name], tag: Tag[Value]): Field[Name, Value] = + Field(name, tag) +end Field diff --git a/kyo-data/shared/src/main/scala/kyo/Fields.scala b/kyo-data/shared/src/main/scala/kyo/Fields.scala new file mode 100644 index 000000000..1684e9fb4 --- /dev/null +++ b/kyo-data/shared/src/main/scala/kyo/Fields.scala @@ -0,0 +1,163 @@ +package kyo + +import kyo.Record.* +import scala.compiletime.* + +/** Reifies the structure of an intersection of `Name ~ Value` field types into runtime metadata and type-level operations. + * + * Given a field type like `"name" ~ String & "age" ~ Int`, a `Fields` instance decomposes it into a tuple of individual field components + * (`("name" ~ String) *: ("age" ~ Int) *: EmptyTuple`) and provides both runtime access (field names, `Field` descriptors) and type-level + * transformations (mapping, value extraction, zipping). Also supports case class types by deriving the equivalent field intersection from + * the product's element labels and types. + * + * Instances are derived transparently via a macro (`Fields.derive`), and are summoned implicitly by `Record` operations like `map`, + * `compact`, `values`, and `zip`. + * + * @tparam A + * The field intersection type (e.g., `"name" ~ String & "age" ~ Int`) or a case class type + */ +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 + + /** 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]`. + */ + type Map[F[_]] = Fields.Join[Tuple.Map[AsTuple, F]] + + /** Extracts the value types from each field component into a plain tuple: `Fields["a" ~ Int & "b" ~ String].Values` = `(Int, String)`. + */ + type Values = Fields.ExtractValues[AsTuple] + + /** Zips this field tuple with another by name, pairing their value types into tuples. */ + type Zipped[T2 <: Tuple] = Fields.ZipValues[AsTuple, T2] + + /** Runtime `Field` descriptors (name, tag, nested), lazily materialized. */ + lazy val fields: List[Field[?, ?]] + + /** The set of field names, derived from `fields`. */ + def names: Set[String] = fields.iterator.map(_.name).toSet + +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] = + new Fields[A]: + type AsTuple = T + lazy val fields = _fields + + private[kyo] type Join[A <: Tuple] = Tuple.Fold[A, Any, [B, C] =>> B & C] + + /** Match type that extracts value types from a tuple of field components into a plain tuple. */ + type ExtractValues[T <: Tuple] <: Tuple = T match + case EmptyTuple => EmptyTuple + case (n ~ v) *: rest => v *: ExtractValues[rest] + + /** Match type that looks up the value type for field name `N` in a tuple of field components. */ + type LookupValue[N <: String, T <: Tuple] = T match + case (N ~ v) *: _ => v + case _ *: rest => LookupValue[N, rest] + + /** Match type that zips two field tuples by name, pairing their value types into `(V1, V2)` tuples. */ + type ZipValues[T1 <: Tuple, T2 <: Tuple] = T1 match + case EmptyTuple => Any + case (n ~ v1) *: EmptyTuple => n ~ (v1, LookupValue[n, T2]) + case (n ~ v1) *: rest => (n ~ (v1, LookupValue[n, T2])) & ZipValues[rest, T2] + + /** Refinement type alias that exposes the `AsTuple` member. */ + type Aux[A, T] = + Fields[A]: + type AsTuple = T + + /** Macro-derived given that produces a `Fields` instance for any field intersection type or case class. */ + transparent inline given derive[A]: Fields[A] = + ${ internal.FieldsMacros.deriveImpl[A] } + + /** Returns the `Field` descriptors for type `A`. Convenience accessor for `summon[Fields[A]].fields`. */ + def fields[A](using f: Fields[A]): List[Field[?, ?]] = f.fields + + /** Returns the field names for type `A`. Convenience accessor for `summon[Fields[A]].names`. */ + def names[A](using f: Fields[A]): Set[String] = f.names + + /** An opaque map from field name to a type class instance `F[Any]`, summoned inline for each field's value type. Used by operations + * like `Render` that need a type class instance per field. + */ + opaque type SummonAll[A, F[_]] = Map[String, F[Any]] + + extension [A, F[_]](sa: SummonAll[A, F]) + /** Retrieves the type class instance for the given field name. */ + def get(name: String): F[Any] = sa(name) + + /** Returns true if an instance exists for the given field name. */ + def contains(name: String): Boolean = sa.contains(name) + + /** Iterates over all (name, instance) pairs. */ + def foreach(fn: (String, F[Any]) => Unit): Unit = sa.foreach((k, v) => fn(k, v)) + end extension + + object SummonAll: + /** Inline given that summons `F[V]` for each field `Name ~ V` in `A` and collects them into a map keyed by field name. Fails at + * compile time if any field's value type lacks an `F` instance. + */ + inline given [A, F[_]](using f: Fields[A]): SummonAll[A, F] = + summonLoop[f.AsTuple, F] + + // Note: uses Map instead of Dict because Dict (an opaque type) causes the compiler + // to hang when used inside inline recursive methods, likely due to cascading inline expansion. + private inline def summonLoop[T <: Tuple, F[_]]: Map[String, F[Any]] = + inline erasedValue[T] match + case _: EmptyTuple => Map.empty + case _: ((n1 ~ v1) *: (n2 ~ v2) *: (n3 ~ v3) *: (n4 ~ v4) *: rest) => + summonLoop[rest, F] + .updated(constValue[n1 & String], summonInline[F[v1]].asInstanceOf[F[Any]]) + .updated(constValue[n2 & String], summonInline[F[v2]].asInstanceOf[F[Any]]) + .updated(constValue[n3 & String], summonInline[F[v3]].asInstanceOf[F[Any]]) + .updated(constValue[n4 & String], summonInline[F[v4]].asInstanceOf[F[Any]]) + case _: ((n ~ v) *: rest) => + summonLoop[rest, F].updated(constValue[n & String], summonInline[F[v]].asInstanceOf[F[Any]]) + end SummonAll + + /** Evidence that field type `F` contains a field named `Name`. The dependent `Value` member resolves to the field's value type. Used by + * `Record.selectDynamic` and `Record.getField` to verify field access at compile time and infer the return type. + */ + sealed abstract class Have[F, Name <: String]: + type Value + + object Have: + private[kyo] def unsafe[F, Name <: String, V]: Have[F, Name] { type Value = V } = + new Have[F, Name]: + type Value = V + + /** Macro-derived given that resolves the value type for `Name` in `F`. Fails at compile time if the field does not exist. */ + transparent inline given [F, Name <: String]: Have[F, Name] = + ${ internal.FieldsMacros.haveImpl[F, Name] } + end Have + + /** Opaque evidence that all value types in field type `A` have `CanEqual` instances, enabling `==` comparisons on `Record[A]`. */ + opaque type Comparable[A] = Unit + + object Comparable: + private[kyo] def unsafe[A]: Comparable[A] = () + + /** Macro-derived given that verifies all field value types in `A` have `CanEqual`. Fails at compile time if any field type lacks + * `CanEqual`. + */ + transparent inline given derive[A]: Comparable[A] = + ${ internal.FieldsMacros.comparableImpl[A] } + end Comparable + + /** Opaque evidence that field types `A` and `B` have the same set of field names, enabling type-safe `zip`. */ + opaque type SameNames[A, B] = Unit + + object SameNames: + private[kyo] def unsafe[A, B]: SameNames[A, B] = () + + /** Macro-derived given that verifies `A` and `B` have identical field names. Fails at compile time if they differ. */ + transparent inline given derive[A, B]: SameNames[A, B] = + ${ internal.FieldsMacros.sameNamesImpl[A, B] } + end SameNames + +end Fields diff --git a/kyo-data/shared/src/main/scala/kyo/Record.scala b/kyo-data/shared/src/main/scala/kyo/Record.scala index 1dd779c63..c4acfe15d 100644 --- a/kyo-data/shared/src/main/scala/kyo/Record.scala +++ b/kyo-data/shared/src/main/scala/kyo/Record.scala @@ -1,449 +1,282 @@ package kyo -import Record.* -import kyo.internal.ForSome2 -import kyo.internal.Inliner -import kyo.internal.TypeIntersection -import scala.annotation.implicitNotFound -import scala.compiletime.constValue -import scala.compiletime.erasedValue -import scala.compiletime.error -import scala.compiletime.summonInline -import scala.deriving.Mirror +export Record.`~` +import kyo.Record.* import scala.language.dynamics import scala.language.implicitConversions -/** A type-safe, immutable record structure that maps field names to values. Records solve the common need to work with flexible key-value - * structures while maintaining type safety at compile time. Unlike traditional maps or case classes, Records allow dynamic field - * combinations with static type checking, making them ideal for configuration, data transformation, and API integrations where the shape - * of data needs to be flexible but still type-safe. - * - * =Creation= - * Records can be created through direct field construction using the `~` operator and combined with `&`: - * {{{ - * val record: Record["name" ~ String & "age" ~ Int] = "name" ~ "Alice" & "age" ~ 30 - * }}} - * - * For existing data types, Records can be automatically derived from case classes and tuples: - * {{{ - * case class Person(name: String, age: Int) - * val record: Record["name" ~ String & "age" ~ Int] = Record.fromProduct(Person("Alice", 30)) - * }}} +/** A type-safe, immutable record that maps string field names to values using intersection types. * - * =Field Access= - * Fields are accessed with compile-time verification of both existence and type: - * {{{ - * record.name // Returns "Alice" as String - * record.age // Returns 30 as Int - * record.nonexistent // Won't compile - * (record.name: Int) // Won't compile - * }}} - * - * =Field Subsetting= - * Records are covariant in their fields, which means a record with more fields can be used anywhere a record with fewer fields is - * expected: - * {{{ - * // A record with name, age, and city - * val full: Record["name" ~ String & "age" ~ Int & "city" ~ String] = - * "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" + * Record encodes its schema as an intersection of `Name ~ Value` pairs in the type parameter `F`. For example, + * `Record["name" ~ String & "age" ~ Int]` describes a record with a `name` field of type `String` and an `age` field of type `Int`. Fields + * are created with the `~` extension on `String` and combined with `&`. Case classes can also be converted via `fromProduct`. * - * // Can be used as a record with just name and age - * val nameAge: Record["name" ~ String & "age" ~ Int] = full + * =Subtyping via Implicit Conversion= + * The type parameter `F` is '''invariant''', but an implicit `widen` conversion in the companion object allows a `Record[A]` to be used + * wherever a `Record[B]` is expected, provided `A <: B`. Since `"name" ~ String & "age" ~ Int <: "name" ~ String` by Scala's intersection + * subtyping rules, a record with more fields can be assigned where fewer are expected. This gives the effect of structural subtyping while + * keeping the type parameter invariant, which avoids the unsoundness issues that a covariant parameter would introduce with `~`'s + * contravariant `Value` position. After widening, the underlying data still contains all original fields; use `compact` to strip fields + * not present in the declared type. * - * // Or just name - * val nameOnly: Record["name" ~ String] = full + * =Duplicate Fields= + * The same field name may appear with different types. Scala normalizes `"f" ~ Int & "f" ~ String` to `"f" ~ (Int | String)` because `~` + * is contravariant in `Value`, so duplicates merge into a union at the type level. * - * // While the above assignments work, the underlying record still contains all fields. - * // Use compact to create a new record containing only the specified fields: - * val nameOnlyCompact: Record["name" ~ String] = nameOnly.compact // Contains only "name" - * }}} + * =Field Access= + * Fields are accessed via `selectDynamic` with compile-time verification through `Fields.Have`. The return type is fully inferred, so no + * type ascription is needed. For field names that are not valid Scala identifiers (e.g., `"user-name"`, `"&"`), use `getField` instead. * - * This allows Records to be passed to functions that only need a subset of their fields, making them more flexible while maintaining type - * safety. The `compact` method creates a new record that internally contains only the fields in its type signature, removing any - * additional fields that may have been present in the original record. + * =Equality= + * A `CanEqual` given enables `==` and `!=` when `Fields.Comparable` is available (i.e., all value types have `CanEqual`). Field order does + * not affect equality. Without `Comparable` evidence, `==` will not compile, preventing accidental comparisons on non-comparable types. * - * =Duplicate Field Support= - * Records support duplicate field names with different types, enabling flexible data modeling: - * {{{ - * val record = "value" ~ "string" & "value" ~ 42 - * (record.value: String) // Returns "string" - * (record.value: Int) // Returns 42 - * }}} + * @tparam F + * Intersection of `Name ~ Value` field types describing the record's schema */ -final class Record[+Fields] private (val toMap: Map[Field[?, ?], Any]) extends AnyVal with Dynamic: +final class Record[F](private[kyo] val dict: Dict[String, Any]) extends Dynamic: - /** Retrieves a value from the Record by field name. - * - * @param name - * The field name to look up + /** 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, Value](name: Name)(using - @implicitNotFound(""" - Invalid field access: ${Name} - - Record[${Fields}] - - Possible causes: - 1. The field does not exist in this Record - 2. The field exists but has a different type than expected - """) - ev: Fields <:< Name ~ Value, - tag: Tag[Value] - ): Value = - toMap(Field(name, tag)).asInstanceOf[Value] - - /** Retrieves a value from the Record by field name for any field name (even it's not a valid identifier). - * - * @param name - * The field name to look up + def selectDynamic[Name <: String & Singleton](name: Name)(using h: Fields.Have[F, Name]): h.Value = + dict(name).asInstanceOf[h.Value] + + /** 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, Value](using - @implicitNotFound(""" - Invalid field access: ${Name} - - Record[${Fields}] - - Possible causes: - 1. The field does not exist in this Record - 2. The field exists but has a different type than expected - """) - ev: Fields <:< Name ~ Value, - tag: Tag[Value], - name: ValueOf[Name] - ): Value = toMap(Field(name.value, tag)).asInstanceOf[Value] - - /** Combines this Record with another Record. - * - * @param other - * The Record to combine with - * @return - * A new Record containing all fields from both Records + def getField[Name <: String & Singleton, V](name: Name)(using h: Fields.Have[F, Name]): h.Value = + dict(name).asInstanceOf[h.Value] + + /** 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[Fields & A] = - Record(toMap ++ other.toMap) -end Record + def &[A](other: Record[A]): Record[F & A] = + new Record(dict ++ other.dict) -export Record.`~` + /** 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])) -object Record: - /** Creates an empty Record + /** 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`. */ - val empty: Record[Any] = Record[Any](Map.empty) + def compact(using f: Fields[F]): Record[F] = + new Record(dict.filter((k, _) => f.names.contains(k))) - private[kyo] def unsafeFrom[Fields](map: Map[Field[?, ?], Any]): Record[Fields] = Record(map) + /** Returns the field names declared in `F` as a list. */ + def fields(using f: Fields[F]): List[String] = + f.fields.map(_.name) - inline def stage[Fields]: StageOps[Fields] = new StageOps[Fields](()) + /** 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] - class StageOps[Fields](dummy: Unit) extends AnyVal: - /** Applies `StageAs` logic to each field. Called on a record type `n1 ~ v1 & ... & nk ~ vk`, returns a new record of type - * `n1 ~ F[n1, v1] & ... & nk ~ F[nk, vk]`. - */ - inline def apply[F[_, _]](as: Record.StageAs[F])(using - initFields: AsFields[Fields], - ev: TypeIntersection[Fields], - targetFields: AsFields[ev.Map[~.Map[F]]] - ): Record[ev.Map[~.Map[F]]] = - Record.unsafeFrom(TypeIntersection.inlineAll[Fields](as).view.map { - case (f, g) => (AsFieldAny.toField(f), g.unwrap) - }.toMap) - end StageOps - - final infix class ~[Name <: String, Value] private () extends Serializable + /** 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 - object `~`: - given [Name <: String, Value](using CanEqual[Value, Value]): CanEqual[Name ~ Value, Name ~ Value] = - CanEqual.derived +end Record - type Map[F[_, _]] = [x] =>> x match - case n ~ v => n ~ F[n, v] +/** Companion object providing record construction, field type definitions, implicit conversions, and compile-time staging. */ +object Record: - type MapValue[F[_]] = Map[[n, v] =>> F[v]] + /** 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)`. + */ + final infix class ~[Name <: String, -Value] private () extends Serializable + object `~`: + /** Type-level function that wraps the value component of a `Name ~ Value` pair in `G`. */ + type MapValue[G[_]] = [x] =>> x match + case n ~ v => n ~ G[v] end `~` - type FieldsOf[Names <: Tuple, Values <: Tuple] = (Names, Values) match - case (EmptyTuple, EmptyTuple) => Any - case (n *: EmptyTuple, v *: EmptyTuple) => n ~ v - case (n *: ns, v *: vs) => (n ~ v) & FieldsOf[ns, vs] - - type FieldValues[T <: Tuple] <: Tuple = T match - case EmptyTuple => EmptyTuple - case Record.`~`[n, v] *: rest => v *: FieldValues[rest] + /** Match type that looks up the value type for `Name` in a tuple of `Name ~ Value` pairs. */ + type FieldValue[T <: Tuple, Name <: String] = T match + case (Name ~ v) *: _ => v + case _ *: rest => FieldValue[rest, Name] - type ZipLookup[N <: String, T <: Tuple] = T match - case (N ~ v) *: _ => v - case _ *: rest => ZipLookup[N, rest] + /** An empty record with type `Record[Any]`, which is the identity element for `&`. */ + val empty: Record[Any] = new Record(Dict.empty[String, Any]) - type ZipFields[T1 <: Tuple, T2 <: Tuple] = T1 match - case EmptyTuple => Any - case (n ~ v1) *: EmptyTuple => n ~ (v1, ZipLookup[n, T2]) - case (n ~ v1) *: rest => (n ~ (v1, ZipLookup[n, T2])) & ZipFields[rest, T2] - - /** Creates a Record from a product type (case class or tuple). - */ - def fromProduct[A](value: A)(using ar: AsRecord[A]): Record[ar.Fields] = ar.asRecord(value) - - /** A field in a Record, containing a name and associated type information. - * - * @param name - * The name of the field - * @param tag - * Type evidence for the field's value type + /** 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 `~` + * type's contravariance ensures that field type relationships are correctly preserved through intersection subtyping. */ - case class Field[Name <: String, Value](name: Name, tag: Tag[Value]) - - extension [Fields](self: Record[Fields]) - - def size: Int = self.toMap.size - - inline def fields(using ti: TypeIntersection[Fields]): List[String] = - collectFieldNames[ti.AsTuple] - - inline def values(using ti: TypeIntersection[Fields]): FieldValues[ti.AsTuple] = - collectValues[ti.AsTuple](self.toMap).asInstanceOf[FieldValues[ti.AsTuple]] - - def update[Name <: String & Singleton, Value](name: Name, value: Value)(using - @implicitNotFound(""" - Invalid field update: ${Name} - - Record[${Fields}] - - Possible causes: - 1. The field does not exist in this Record - 2. The field exists but has a different type than expected - """) - ev: Fields <:< Name ~ Value, - tag: Tag[Value] - ): Record[Fields] = - Record.unsafeFrom(self.toMap.updated(Field(name, tag), value)) - - inline def map[F[_]](using - ti: TypeIntersection[Fields] - )( - f: [t] => t => F[t] - ): Record[ti.Map[~.MapValue[F]]] = - mapFields([t] => (_: Field[?, t], v: t) => f[t](v)) - - inline def mapFields[F[_]](using - ti: TypeIntersection[Fields] - )( - f: [t] => (Field[?, t], t) => F[t] - ): Record[ti.Map[~.MapValue[F]]] = - Record.unsafeFrom[ti.Map[~.MapValue[F]]]( - mapFieldsImpl[ti.AsTuple, F](self.toMap, f) - ) - - inline def zip[Fields2](other: Record[Fields2])(using - ti1: TypeIntersection[Fields], - ti2: TypeIntersection[Fields2] - ): Record[ZipFields[ti1.AsTuple, ti2.AsTuple]] = - Record.unsafeFrom[ZipFields[ti1.AsTuple, ti2.AsTuple]]( - zipImpl[ti1.AsTuple, ti2.AsTuple](self.toMap, other.toMap) - ) - - def compact(using AsFields[Fields]): Record[Fields] = - Record(self.toMap.view.filterKeys(AsFields[Fields].contains(_)).toMap) - end extension + implicit def widen[A <: B, B](r: Record[A]): Record[B] = + r.asInstanceOf[Record[B]] + /** Creates a single-field record from a string literal name and a value. */ extension (self: String) - /** Creates a single-field Record with the string as the field name. - * - * @param value - * The value to associate with the field - */ - def ~[Value](value: Value)(using tag: Tag[Value]): Record[self.type ~ Value] = - Record(Map.empty.updated(Field(self, tag), value)) - end extension - - /** Type class for converting types to Records. - * - * This type class enables automatic derivation of Records from product types (case classes and tuples). It maintains type information - * about the fields and their values during conversion. - * - * @tparam A - * The type to convert to a Record - */ - trait AsRecord[A]: - /** The field structure of the converted Record */ - type Fields - - /** Converts a value to a Record */ - def asRecord(value: A): Record[Fields] - end AsRecord - - object AsRecord: - - type RMirror[A, Names <: Tuple, Values <: Tuple] = Mirror.ProductOf[A] { - type MirroredElemLabels = Names - type MirroredElemTypes = Values - } - - trait RecordContents[Names <: Tuple, Values <: Tuple]: - def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any] - - object RecordContents: - given empty: RecordContents[EmptyTuple, EmptyTuple] with - def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any] = map - - given nonEmpty[NH <: (String & Singleton), NT <: Tuple, VH, VT <: Tuple]( - using - tag: Tag[VH], - vo: ValueOf[NH], - next: RecordContents[NT, VT] - ): RecordContents[NH *: NT, VH *: VT] with - def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any] = - next.addTo(product, idx + 1, map.updated(Field[NH, VH](vo.value, tag), product.productElement(idx))) - end nonEmpty - end RecordContents - - given [A <: Product, Names <: Tuple, Values <: Tuple](using - mir: RMirror[A, Names, Values], - rc: RecordContents[Names, Values] - ): AsRecord[A] with - type Fields = Record.FieldsOf[Names, Values] - - def asRecord(value: A): Record[Fields] = - Record(rc.addTo(value, 0, Map.empty)).asInstanceOf[Record[Fields]] - end asRecord - end given - end AsRecord - - /** Represents a field in a Record type at the type level. - * - * AsField is used to convert Record field types into concrete Field instances, maintaining type safety for field names and their - * associated values. - * - * @tparam A - * The field type, typically in the form of `"fieldName" ~ ValueType` - */ - type AsField[Name <: String, Value] = AsField.Type[Name, Value] - object AsField: - opaque type Type[Name <: String, Value] = Field[Name, Value] - - inline given [N <: String, V](using tag: Tag[V]): AsField[N, V] = - Field(constValue[N], tag) - - private[kyo] def fromField[Name <: String, Value](field: Field[Name, Value]): AsField[Name, Value] = field - private[kyo] def toField[Name <: String, Value](field: AsField[Name, Value]): Field[Name, Value] = field - end AsField - - private[kyo] type AsFieldAny[n, v] = AsField[n & String, v] - private[kyo] object AsFieldAny: - def toField(as: ForSome2[AsFieldAny]): Field[?, ?] = AsField.toField(as.unwrap) - - /** Type class for working with sets of Record fields. - * - * AsFields provides type-safe field set operations and is used primarily for the `compact` operation on Records. - * - * @tparam A - * The combined type of all fields in the set - */ - type AsFields[+A] = AsFields.Type[A] + def ~[Value](value: Value): Record[self.type ~ Value] = + new Record(Dict[String, Any](self -> value)) - object AsFields: - opaque type Type[+A] <: Set[Field[?, ?]] = Set[Field[?, ?]] + /** Provides `CanEqual` for records whose field types are all comparable, enabling `==` and `!=`. */ + given [F](using Fields.Comparable[F]): CanEqual[Record[F], Record[F]] = + CanEqual.derived - def apply[A](using af: AsFields[A]): Set[Field[?, ?]] = af + /** Provides a `Render` instance for records, rendering each field as `"name ~ value"` joined by `" & "`. Requires `Render` instances + * for all field value types. + */ + given render[F](using f: Fields[F], renders: Fields.SummonAll[F, Render]): Render[Record[F]] = + Render.from: (value: Record[F]) => + val sb = new StringBuilder + var first = true + value.dict.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))) + first = false + sb.toString + + import scala.compiletime.* + + /** Begins compile-time staging for a field type `A`. Staging iterates over the fields at compile time (via inline expansion) and + * applies a polymorphic function to each, producing a new record. This is useful for deriving metadata, default values, or type class + * instances per field. Call the returned `StageOps` directly to stage without a type class, or chain `.using[TC]` to require a type + * class instance for each field's value type. + */ + inline def stage[A](using f: Fields[A]): StageOps[A, f.AsTuple] = new StageOps(()) - inline given [Fields](using ev: TypeIntersection[Fields]): AsFields[Fields] = - AsFieldsInternal.summonAsField - end AsFields + /** Intermediate builder for staging without a type class constraint. Apply a polymorphic function `Field[?, v] => G[v]` to produce a + * record where each field `Name ~ V` becomes `Name ~ G[V]`. + */ + 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]]]] - given [Fields, T](using TypeIntersection.Aux[Fields, T], CanEqual[T, T]): CanEqual[Record[Fields], Record[Fields]] = - CanEqual.derived + /** 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. + */ + inline def using[TC[_]]: StageWith[A, T, TC] = new StageWith(()) + end StageOps - private object RenderInliner extends Inliner[(String, Render[?])]: - inline def apply[T]: (String, Render[?]) = - inline erasedValue[T] match - case _: (n ~ v) => - constValue[n & String] -> summonInline[Render[v]] - end RenderInliner - - inline given [Fields: TypeIntersection]: Render[Record[Fields]] = - val insts = TypeIntersection.inlineAll[Fields](RenderInliner).toMap - Render.from: (value: Record[Fields]) => - value.toMap.iterator.collect { - case (field, v) if insts.contains(field.name) => - val r = insts(field.name).asInstanceOf[Render[Any]] - field.name + " ~ " + r.asText(v) - }.mkString(" & ") - end given - - trait StageAs[F[_, _]] extends Inliner[(ForSome2[AsFieldAny], ForSome2[F])]: - inline def stage[Name <: String, Value](field: Field[Name, Value]): F[Name, Value] - - override inline def apply[T]: (ForSome2[AsFieldAny], ForSome2[F]) = - inline erasedValue[T] match - case _: (n ~ v) => - val name = constValue[n] - val prevTag = summonInline[Tag[v]] - val nextTag = summonInline[Tag[F[n, v]]] - - ( - ForSome2.of[AsFieldAny](AsField.fromField(Field(name, nextTag))), - ForSome2(stage[n, v](Field(name, prevTag))) - ) - end StageAs - - private inline def collectFieldNames[T <: Tuple]: List[String] = + /** Intermediate builder for staging with a type class constraint `TC`. Apply a polymorphic function `(Field[?, v], TC[v]) => G[v]` to + * produce a record where each field `Name ~ V` becomes `Name ~ G[V]`. Fails at compile time if any field's value type lacks a `TC` + * instance. + */ + 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]]]] + + // 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 + // inline expansion in certain patterns. + private[kyo] inline def stageLoop[T <: Tuple, G[_]](fn: [v] => Field[?, v] => G[v]): Dict[String, Any] = inline erasedValue[T] match - case _: EmptyTuple => Nil + case _: EmptyTuple => Dict.empty[String, Any] + case _: ((n1 ~ v1) *: (n2 ~ v2) *: (n3 ~ v3) *: (n4 ~ v4) *: rest) => + val name1 = constValue[n1 & String] + val name2 = constValue[n2 & String] + val name3 = constValue[n3 & String] + val name4 = constValue[n4 & String] + stageLoop[rest, G](fn) + ++ Dict[String, Any](name1 -> fn[v1](Field(name1, summonInline[Tag[v1]]))) + ++ Dict[String, Any](name2 -> fn[v2](Field(name2, summonInline[Tag[v2]]))) + ++ Dict[String, Any](name3 -> fn[v3](Field(name3, summonInline[Tag[v3]]))) + ++ Dict[String, Any](name4 -> fn[v4](Field(name4, summonInline[Tag[v4]]))) case _: ((n ~ v) *: rest) => - constValue[n & String] :: collectFieldNames[rest] - case _: (_ *: rest) => - collectFieldNames[rest] + val name = constValue[n & String] + val value = fn[v](Field(name, summonInline[Tag[v]])) + stageLoop[rest, G](fn) ++ Dict[String, Any](name -> value) - private inline def collectValues[T <: Tuple]( - map: Map[Field[?, ?], Any] - ): Tuple = + private[kyo] inline def stageLoopWith[T <: Tuple, TC[_], G[_]](fn: [v] => (Field[?, v], TC[v]) => G[v]): Dict[String, Any] = inline erasedValue[T] match - case _: EmptyTuple => EmptyTuple + case _: EmptyTuple => Dict.empty[String, Any] + case _: ((n1 ~ v1) *: (n2 ~ v2) *: (n3 ~ v3) *: (n4 ~ v4) *: rest) => + val name1 = constValue[n1 & String] + val name2 = constValue[n2 & String] + val name3 = constValue[n3 & String] + val name4 = constValue[n4 & String] + stageLoopWith[rest, TC, G](fn) + ++ Dict[String, Any](name1 -> fn[v1](Field(name1, summonInline[Tag[v1]]), summonInline[TC[v1]])) + ++ Dict[String, Any](name2 -> fn[v2](Field(name2, summonInline[Tag[v2]]), summonInline[TC[v2]])) + ++ Dict[String, Any](name3 -> fn[v3](Field(name3, summonInline[Tag[v3]]), summonInline[TC[v3]])) + ++ Dict[String, Any](name4 -> fn[v4](Field(name4, summonInline[Tag[v4]]), summonInline[TC[v4]])) case _: ((n ~ v) *: rest) => - val name = constValue[n & String] - val tag = summonInline[Tag[v]] - map(Field(name, tag)) *: collectValues[rest](map) - - private inline def mapFieldsImpl[T <: Tuple, F[_]]( - map: Map[Field[?, ?], Any], - f: [t] => (Field[?, t], t) => F[t] - ): Map[Field[?, ?], Any] = + val name = constValue[n & String] + val value = fn[v](Field(name, summonInline[Tag[v]]), summonInline[TC[v]]) + stageLoopWith[rest, TC, G](fn) ++ Dict[String, Any](name -> value) + + /** Creates a record from a case class or other `Product` type. Each product element becomes a field whose name matches the element + * label. The return type is a `Record` with the appropriate field intersection, inferred transparently by the macro. Fails at compile + * time if `A` is not a `Product`. + */ + transparent inline def fromProduct[A <: Product](value: A): Any = + ${ internal.FieldsMacros.fromProductImpl[A]('value) } + + private[kyo] inline def collectValues[T <: Tuple](dict: Dict[String, Any]): Tuple = inline erasedValue[T] match - case _: EmptyTuple => Map.empty + case _: EmptyTuple => EmptyTuple + case _: ((n1 ~ v1) *: (n2 ~ v2) *: (n3 ~ v3) *: (n4 ~ v4) *: rest) => + dict(constValue[n1 & String]) *: dict(constValue[n2 & String]) *: + dict(constValue[n3 & String]) *: dict(constValue[n4 & String]) *: + collectValues[rest](dict) case _: ((n ~ v) *: rest) => - val name = constValue[n & String] - val prevTag = summonInline[Tag[v]] - val nextTag = summonInline[Tag[F[v]]] - val field = Field(name, prevTag) - val value = map(field).asInstanceOf[v] - val result = f[v](field, value) - mapFieldsImpl[rest, F](map, f).updated(Field(name, nextTag), result) - - private inline def zipImpl[T1 <: Tuple, T2 <: Tuple]( - map1: Map[Field[?, ?], Any], - map2: Map[Field[?, ?], Any] - ): Map[Field[?, ?], Any] = - inline erasedValue[T1] match - case _: EmptyTuple => Map.empty - case _: ((n ~ v1) *: rest) => - val name = constValue[n & String] - val tag1 = summonInline[Tag[v1]] - val tag2 = summonInline[Tag[ZipLookup[n, T2]]] - val pairTag = summonInline[Tag[(v1, ZipLookup[n, T2])]] - val value1 = map1(Field(name, tag1)) - val value2 = map2(Field(name, tag2)) - zipImpl[rest, T2](map1, map2).updated(Field(name, pairTag), (value1, value2)) -end Record + dict(constValue[n & String]) *: collectValues[rest](dict) -object AsFieldsInternal: - private object AsFieldInliner extends Inliner[ForSome2[AsFieldAny]]: - inline def apply[T]: ForSome2[AsFieldAny] = - inline erasedValue[T] match - case _: (n ~ v) => - ForSome2.of[AsFieldAny](summonInline[AsField[n, v]]) - case _ => error("Given type doesn't match to expected field shape: Name ~ Value") - end AsFieldInliner - - inline def summonAsField[Fields](using ev: TypeIntersection[Fields]): Set[Field[?, ?]] = - TypeIntersection.inlineAll[Fields](AsFieldInliner).map(Record.AsFieldAny.toField).toSet - end summonAsField -end AsFieldsInternal + private[kyo] def init[F](dict: Dict[String, Any]): Record[F] = + new Record(dict) + +end Record diff --git a/kyo-data/shared/src/main/scala/kyo/internal/FieldsMacros.scala b/kyo-data/shared/src/main/scala/kyo/internal/FieldsMacros.scala new file mode 100644 index 000000000..59a6b3b65 --- /dev/null +++ b/kyo-data/shared/src/main/scala/kyo/internal/FieldsMacros.scala @@ -0,0 +1,261 @@ +package kyo.internal + +import kyo.* +import kyo.Record.* +import scala.quoted.* + +object FieldsMacros: + + /** If `tpe` is a case class, return its fields as `("name" ~ ValueType)` TypeReprs. */ + private def caseClassFields(using Quotes)(tpe: quotes.reflect.TypeRepr): Option[Vector[quotes.reflect.TypeRepr]] = + import quotes.reflect.* + val sym = tpe.typeSymbol + if sym.isClassDef && sym.flags.is(Flags.Case) then + val tildeType = TypeRepr.of[Record.~] + val fields = sym.caseFields.map: field => + val fieldName = field.name + val fieldType = tpe.memberType(field) + val nameType = ConstantType(StringConstant(fieldName)) + tildeType.appliedTo(List(nameType, fieldType)) + Some(fields.toVector) + else + None + end if + end caseClassFields + + def deriveImpl[A: Type](using Quotes): Expr[Fields[A]] = + import quotes.reflect.* + + def decompose(tpe: TypeRepr): Vector[TypeRepr] = + tpe.dealias match + case AndType(l, r) => decompose(l) ++ decompose(r) + case _ => + if tpe =:= TypeRepr.of[Any] then Vector() + else + caseClassFields(tpe).getOrElse: + try + tpe.typeSymbol.tree match + case typeDef: TypeDef => + typeDef.rhs match + case bounds: TypeBoundsTree => + val hi = bounds.hi.tpe + if !(hi =:= TypeRepr.of[Any]) then decompose(hi) + else Vector(tpe) + case _ => Vector(tpe) + case _ => Vector(tpe) + catch case _: Exception => Vector(tpe) + + def tupled(typs: Vector[TypeRepr]): TypeRepr = + typs match + case h +: t => TypeRepr.of[*:].appliedTo(List(h, tupled(t))) + case _ => TypeRepr.of[EmptyTuple] + + val components = decompose(TypeRepr.of[A].dealias) + + case class ComponentInfo(name: String, nameExpr: Expr[String], tagExpr: Expr[Any], nestedExpr: Expr[List[Field[?, ?]]]) + + def extractComponent(tpe: TypeRepr): Option[ComponentInfo] = + tpe match + case AppliedType(_, List(ConstantType(StringConstant(name)), valueType)) => + val nameExpr = Expr(name) + val tagExpr = valueType.asType match + case '[v] => + 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[?, ?]] } + Some(ComponentInfo(name, nameExpr, tagExpr, nestedExpr)) + case _ => None + + val infos = components.flatMap(extractComponent) + val fieldsList = Expr.ofList(infos.map(ci => + '{ 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) } + end match + end deriveImpl + + def haveImpl[F: Type, Name <: String: Type](using Quotes): Expr[Fields.Have[F, Name]] = + import quotes.reflect.* + + val nameStr = TypeRepr.of[Name] match + case ConstantType(StringConstant(s)) => s + case _ => report.errorAndAbort(s"Field name must be a literal string type, got: ${TypeRepr.of[Name].show}") + + def findValueType(tpe: TypeRepr): Option[TypeRepr] = + tpe.dealias match + case AndType(l, r) => + findValueType(l).orElse(findValueType(r)) + case AppliedType(_, List(ConstantType(StringConstant(n)), valueType)) if n == nameStr => + Some(valueType) + case _ => + if tpe =:= TypeRepr.of[Any] then None + else + // Check case class fields + val sym = tpe.typeSymbol + if sym.isClassDef && sym.flags.is(Flags.Case) then + sym.caseFields.find(_.name == nameStr).map(f => tpe.memberType(f)) + else + try + tpe.typeSymbol.tree match + case typeDef: TypeDef => + typeDef.rhs match + case bounds: TypeBoundsTree => + val hi = bounds.hi.tpe + if !(hi =:= TypeRepr.of[Any]) then findValueType(hi) + else None + case _ => None + case _ => None + catch case _: Exception => None + end if + + findValueType(TypeRepr.of[F]) match + case Some(valueType) => + valueType.asType match + case '[v] => + '{ Fields.Have.unsafe[F, Name, v] } + case None => + report.errorAndAbort( + s"Field '$nameStr' not found in ${TypeRepr.of[F].show}" + ) + end match + end haveImpl + + def comparableImpl[A: Type](using Quotes): Expr[Fields.Comparable[A]] = + import quotes.reflect.* + + def decompose(tpe: TypeRepr): Vector[(String, TypeRepr)] = + tpe.dealias match + case AndType(l, r) => decompose(l) ++ decompose(r) + case AppliedType(_, List(ConstantType(StringConstant(name)), valueType)) => + Vector((name, valueType)) + case _ => + if tpe =:= TypeRepr.of[Any] then Vector() + else + val sym = tpe.typeSymbol + if sym.isClassDef && sym.flags.is(Flags.Case) then + sym.caseFields.map(f => (f.name, tpe.memberType(f))).toVector + else + try + tpe.typeSymbol.tree match + case typeDef: TypeDef => + typeDef.rhs match + case bounds: TypeBoundsTree => + val hi = bounds.hi.tpe + if !(hi =:= TypeRepr.of[Any]) then decompose(hi) + else Vector() + case _ => Vector() + case _ => Vector() + catch case _: Exception => Vector() + end if + + for (name, valueType) <- decompose(TypeRepr.of[A]) do + valueType.asType match + case '[v] => + Expr.summon[CanEqual[v, v]] match + case None => + report.errorAndAbort( + s"Cannot compare records: field '$name' of type ${valueType.show} has no CanEqual instance" + ) + case _ => () + end for + + '{ Fields.Comparable.unsafe[A] } + end comparableImpl + + def sameNamesImpl[A: Type, B: Type](using Quotes): Expr[Fields.SameNames[A, B]] = + import quotes.reflect.* + + def fieldNames(tpe: TypeRepr): Set[String] = + tpe.dealias match + case AndType(l, r) => fieldNames(l) ++ fieldNames(r) + case AppliedType(_, List(ConstantType(StringConstant(name)), _)) => + Set(name) + case _ => + if tpe =:= TypeRepr.of[Any] then Set.empty + else + val sym = tpe.typeSymbol + if sym.isClassDef && sym.flags.is(Flags.Case) then + sym.caseFields.map(_.name).toSet + else + try + tpe.typeSymbol.tree match + case typeDef: TypeDef => + typeDef.rhs match + case bounds: TypeBoundsTree => + val hi = bounds.hi.tpe + if !(hi =:= TypeRepr.of[Any]) then fieldNames(hi) + else Set.empty + case _ => Set.empty + case _ => Set.empty + catch case _: Exception => Set.empty + end if + + val namesA = fieldNames(TypeRepr.of[A]) + val namesB = fieldNames(TypeRepr.of[B]) + + if namesA != namesB then + val onlyA = (namesA -- namesB).toList.sorted + val onlyB = (namesB -- namesA).toList.sorted + val parts = List( + if onlyA.nonEmpty then Some(s"fields only in left: ${onlyA.mkString(", ")}") else None, + if onlyB.nonEmpty then Some(s"fields only in right: ${onlyB.mkString(", ")}") else None + ).flatten.mkString("; ") + report.errorAndAbort(s"Cannot zip records with different fields: $parts") + end if + + '{ Fields.SameNames.unsafe[A, B] } + end sameNamesImpl + + def fromProductImpl[A <: Product: Type](value: Expr[A])(using Quotes): Expr[Any] = + import quotes.reflect.* + + val tpe = TypeRepr.of[A].dealias + val sym = tpe.typeSymbol + + if !sym.isClassDef || !sym.flags.is(Flags.Case) then + report.errorAndAbort(s"fromProduct requires a case class, got: ${tpe.show}") + + val fields = sym.caseFields + val n = fields.size + val tildeType = TypeRepr.of[Record.~] + + val fieldsType = + if fields.isEmpty then TypeRepr.of[Any] + else + fields.map { f => + tildeType.appliedTo(List(ConstantType(StringConstant(f.name)), tpe.memberType(f))) + }.reduce(AndType(_, _)) + + // Build keys-first array: [k0, k1, ..., v0, v1, ...] + // Uses direct field access (value.name) instead of productElement to avoid boxing + val arrayExprs: List[Expr[Any]] = + fields.map(f => Expr(f.name)) ++ + fields.map(f => Select.unique(value.asTerm, f.name).asExprOf[Any]) + + fieldsType.asType match + case '[f] => + '{ + val arr = new Array[Any](${ Expr(n * 2) }) + ${ + Expr.block( + arrayExprs.zipWithIndex.map: (expr, i) => + '{ arr(${ Expr(i) }) = $expr }.asTerm + .toList.map(_.asExprOf[Unit]), + '{ () } + ) + } + new Record[f](Dict.fromArrayUnsafe(arr.asInstanceOf[Array[String | Any]])) + } + end match + end fromProductImpl + +end FieldsMacros diff --git a/kyo-data/shared/src/main/scala/kyo/internal/ForSome.scala b/kyo-data/shared/src/main/scala/kyo/internal/ForSome.scala deleted file mode 100644 index e5dda5894..000000000 --- a/kyo-data/shared/src/main/scala/kyo/internal/ForSome.scala +++ /dev/null @@ -1,41 +0,0 @@ -package kyo.internal - -/** Existentinal types encoding for type constructors `F[_]`. `ForSome[F]` is semantically equiavalent to `F[?]` - */ -type ForSome[F[_]] = ForSome.Type[F] -object ForSome: - class Unwrap[F[_], A](val unwrap: F[A]) extends AnyVal - - type Type[F[_]] = Unwrap[F, ?] - - /** Converts value of type `F[A]` to existential form - */ - inline def apply[F[_], A](v: F[A]): Type[F] = Unwrap(v) - - inline def of[F[_]]: OfOps[F] = new OfOps[F](()) - class OfOps[F[_]](dummy: Unit) extends AnyVal: - /** Converts value of type `F[A]` to existential form - */ - inline def apply[A](f: F[A]): Type[F] = ForSome(f) - end OfOps -end ForSome - -/** Existentinal types encoding for type constructors `F[_, _]`. `ForSome2[F]` is semantically equiavalent to `F[?, ?]` - */ -type ForSome2[F[_, _]] = ForSome2.Type[F] -object ForSome2: - class Unwrap[F[_, _], A1, A2](val unwrap: F[A1, A2]) extends AnyVal - - type Type[F[_, _]] = Unwrap[F, ?, ?] - - /** Converts value of type `F[A1, A2]` to existential form - */ - inline def apply[F[_, _], A1, A2](v: F[A1, A2]): Type[F] = Unwrap(v) - - inline def of[F[_, _]]: OfOps[F] = new OfOps[F](()) - class OfOps[F[_, _]](dummy: Unit) extends AnyVal: - /** Converts value of type `F[A1, A2]` to existential form - */ - inline def apply[A1, A2](f: F[A1, A2]): Type[F] = ForSome2(f) - end OfOps -end ForSome2 diff --git a/kyo-data/shared/src/main/scala/kyo/internal/Inliner.scala b/kyo-data/shared/src/main/scala/kyo/internal/Inliner.scala deleted file mode 100644 index 31db5c35e..000000000 --- a/kyo-data/shared/src/main/scala/kyo/internal/Inliner.scala +++ /dev/null @@ -1,20 +0,0 @@ -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 diff --git a/kyo-data/shared/src/main/scala/kyo/internal/TypeIntersection.scala b/kyo-data/shared/src/main/scala/kyo/internal/TypeIntersection.scala deleted file mode 100644 index feb63ac89..000000000 --- a/kyo-data/shared/src/main/scala/kyo/internal/TypeIntersection.scala +++ /dev/null @@ -1,132 +0,0 @@ -package kyo.internal - -import TypeIntersection.* -import scala.compiletime.summonInline -import scala.quoted.* - -/** A type-level utility for decomposing intersection types into their constituent parts. - * - * TypeIntersection addresses the challenge of working with intersection types in a type-safe manner, particularly when implementing - * type-level operations like equality checking and type class derivation. It provides a way to: - * - * - Break down complex intersection types into simpler components - * - Apply type constructors uniformly across all components - * - Collect type class instances for all component types - */ -sealed abstract class TypeIntersection[A] extends Serializable: - - /** The tuple representation of the decomposed types. - * - * For an intersection type A & B & C, this would be A *: B *: C *: EmptyTuple. The order of types in the tuple matches the order they - * appear in the intersection. - */ - type AsTuple <: Tuple - - /** Applies a type constructor F to each component type in the set. - * - * @tparam F - * the type constructor to apply - * @return - * an intersection type of F applied to each component - */ - type Map[F[_]] = Join[Tuple.Map[AsTuple, F]] - -end TypeIntersection - -object TypeIntersection: - - // Cached instance used for optimization. - private val cached: TypeIntersection[Any] = - new TypeIntersection[Any]: - type AsTuple = EmptyTuple - - private type Join[A <: Tuple] = Tuple.Fold[A, Any, [B, C] =>> B & C] - - /** Returns the TypeIntersection instance for type A. - * - * @tparam A - * the type to get the TypeIntersection for - * @param ts - * the implicit TypeIntersection instance - * @return - * the TypeIntersection instance - */ - transparent inline def apply[A](using inline ts: TypeIntersection[A]): TypeIntersection[A] = ts - - /** Summons all instances of type class F for each component type in A. - * - * @tparam A - * the intersection type to decompose - * @tparam F - * the type class to summon instances for - * @return - * a List of type class instances - */ - transparent inline def summonAll[A: TypeIntersection, F[_]]: List[ForSome[F]] = - inlineAll[A](new SummonInliner[F]) - - class SummonInliner[F[_]] extends Inliner[ForSome[F]]: - inline def apply[T]: ForSome[F] = - ForSome(summonInline[F[T]]) - - inline def inlineAll[A]: InlineAllOps[A] = new InlineAllOps[A](()) - - class InlineAllOps[A](dummy: Unit): - /** 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 apply[R](inliner: Inliner[R])(using ts: TypeIntersection[A]): List[R] = - Inliner.inlineAllLoop[R, ts.AsTuple](inliner) - end InlineAllOps - - /** Type alias for TypeIntersection with a specific tuple type. - * - * @tparam A - * the intersection type - * @tparam T - * the tuple type representing the decomposed types - */ - type Aux[A, T] = - TypeIntersection[A]: - type AsTuple = T - - /** Derives a TypeIntersection instance for type A. - * - * @tparam A - * the type to create a TypeIntersection for - * @return - * a TypeIntersection instance for A - */ - transparent inline given derive[A]: TypeIntersection[A] = - ${ deriveImpl[A] } - - private def deriveImpl[A: Type](using Quotes): Expr[TypeIntersection[A]] = - import quotes.reflect.* - - def decompose(tpe: TypeRepr): Vector[TypeRepr] = - tpe match - case AndType(l, r) => - decompose(l) ++ decompose(r) - case _ => - if tpe =:= TypeRepr.of[Any] then Vector() - else Vector(tpe) - - def tupled(typs: Vector[TypeRepr]): TypeRepr = - typs match - case h +: t => TypeRepr.of[*:].appliedTo(List(h, tupled(t))) - case _ => TypeRepr.of[EmptyTuple] - - tupled(decompose(TypeRepr.of[A].dealias)).asType match - case '[type x <: Tuple; x] => - '{ - cached.asInstanceOf[TypeIntersection.Aux[A, x]] - } - end match - end deriveImpl -end TypeIntersection diff --git a/kyo-data/shared/src/test/scala/kyo/ConstValueTest.scala b/kyo-data/shared/src/test/scala/kyo/ConstValueTest.scala new file mode 100644 index 000000000..187739be6 --- /dev/null +++ b/kyo-data/shared/src/test/scala/kyo/ConstValueTest.scala @@ -0,0 +1,30 @@ +package kyo + +class ConstValueTest extends Test: + + "summon string literal" in { + val v = summon[ConstValue["hello"]] + assert(v == "hello") + } + + "summon int literal" in { + val v = summon[ConstValue[42]] + assert(v == 42) + } + + "summon boolean literal" in { + val v = summon[ConstValue[true]] + assert(v == true) + } + + "subtype of literal type" in { + val v: "test" = summon[ConstValue["test"]] + assert(v == "test") + } + + "used as function parameter" in { + def getName[N <: String](using n: ConstValue[N]): String = n + assert(getName["foo"] == "foo") + } + +end ConstValueTest diff --git a/kyo-data/shared/src/test/scala/kyo/FieldTest.scala b/kyo-data/shared/src/test/scala/kyo/FieldTest.scala new file mode 100644 index 000000000..d47bdfa57 --- /dev/null +++ b/kyo-data/shared/src/test/scala/kyo/FieldTest.scala @@ -0,0 +1,33 @@ +package kyo + +import Record.* + +class FieldTest extends Test: + + "apply with ConstValue and Tag" in { + val f = Field["name", String] + assert(f.name == "name") + } + + "get from record" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val f = Field["name", String] + assert(f.get(r) == "Alice") + } + + "set on record" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val f = Field["name", String] + val r2 = f.set(r, "Bob") + assert(r2.name == "Bob") + assert(r2.age == 30) + } + + "nested field descriptor" in { + val nested = List(Field["x", Int], Field["y", Int]) + val f = Field[String, Any]("point", Tag[Any], nested) + assert(f.nested.size == 2) + assert(f.nested.map(_.name).toSet == Set("x", "y")) + } + +end FieldTest diff --git a/kyo-data/shared/src/test/scala/kyo/FieldsTest.scala b/kyo-data/shared/src/test/scala/kyo/FieldsTest.scala new file mode 100644 index 000000000..b12e19070 --- /dev/null +++ b/kyo-data/shared/src/test/scala/kyo/FieldsTest.scala @@ -0,0 +1,151 @@ +package kyo + +import Record.* +import scala.language.implicitConversions + +case class Person(name: String, age: Int) +case class Point(x: Int, y: Int) +case class Wrapper[A](value: A, label: String) + +class FieldsTest extends Test: + + // --- Intersection type tests (existing behavior) --- + + "intersection: Fields.names" in { + val names = Fields.names["name" ~ String & "age" ~ Int] + assert(names == Set("name", "age")) + } + + "intersection: Fields.fields" in { + val fs = Fields.fields["name" ~ String & "age" ~ Int] + assert(fs.size == 2) + assert(fs.map(_.name).toSet == Set("name", "age")) + } + + "intersection: Have resolves value type" in { + val have = summon[Fields.Have["name" ~ String & "age" ~ Int, "name"]] + assert(true) + } + + // --- Case class support --- + + "case class: Fields.names" in { + val names = Fields.names[Person] + assert(names == Set("name", "age")) + } + + "case class: Fields.fields" in { + val fs = Fields.fields[Person] + assert(fs.size == 2) + assert(fs.map(_.name).toSet == Set("name", "age")) + } + + "case class: Have resolves value type" in { + val have = summon[Fields.Have[Person, "name"]] + val _: Fields.Have[Person, "name"] { type Value = String } = have + succeed + } + + "case class: Have resolves all fields" in { + summon[Fields.Have[Person, "name"]] + summon[Fields.Have[Person, "age"]] + succeed + } + + "case class: Have rejects missing field" in { + typeCheckFailure("""summon[Fields.Have[Person, "missing"]]""")("Fields.Have") + } + + "case class: Fields.fields has correct tags" in { + val fs = Fields.fields[Person] + val nameField = fs.find(_.name == "name").get + assert(nameField.tag =:= Tag[String]) + val ageField = fs.find(_.name == "age").get + assert(ageField.tag =:= Tag[Int]) + } + + "case class: generic case class" in { + val names = Fields.names[Wrapper[Int]] + assert(names == Set("value", "label")) + } + + "case class: generic Have resolves parameterized type" in { + val have = summon[Fields.Have[Wrapper[Int], "value"]] + val _: Fields.Have[Wrapper[Int], "value"] { type Value = Int } = have + succeed + } + + "case class: Comparable" in { + val _ = summon[Fields.Comparable[Person]] + succeed + } + + "case class: Point fields" in { + val fs = Fields.fields[Point] + assert(fs.size == 2) + assert(fs.map(_.name).toSet == Set("x", "y")) + } + + "case class: SummonAll" in { + val renders = summon[Fields.SummonAll[Person, Render]] + assert(renders.contains("name")) + assert(renders.contains("age")) + } + + // --- Large intersection types --- + + "large intersection: 22 fields" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int + val names = Fields.names[F] + assert(names.size == 22) + assert(names.contains("f1")) + assert(names.contains("f22")) + } + + "large intersection: 30 fields" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int & + "f26" ~ Int & "f27" ~ Int & "f28" ~ Int & "f29" ~ Int & "f30" ~ Int + val names = Fields.names[F] + assert(names.size == 30) + assert(names.contains("f1")) + assert(names.contains("f30")) + } + + "large intersection: field access" in { + type F = "f1" ~ Int & "f2" ~ String & "f3" ~ Boolean & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int + val r = "f1" ~ 1 & "f2" ~ "hello" & "f3" ~ true & "f4" ~ 4 & "f5" ~ 5 & + "f6" ~ 6 & "f7" ~ 7 & "f8" ~ 8 & "f9" ~ 9 & "f10" ~ 10 & + "f11" ~ 11 & "f12" ~ 12 & "f13" ~ 13 & "f14" ~ 14 & "f15" ~ 15 & + "f16" ~ 16 & "f17" ~ 17 & "f18" ~ 18 & "f19" ~ 19 & "f20" ~ 20 & + "f21" ~ 21 & "f22" ~ 22 & "f23" ~ 23 & "f24" ~ 24 & "f25" ~ 25 + assert(r.f1 == 1) + assert(r.f2 == "hello") + assert(r.f3 == true) + assert(r.f25 == 25) + } + + "large intersection: SummonAll 30 fields" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int & + "f26" ~ Int & "f27" ~ Int & "f28" ~ Int & "f29" ~ Int & "f30" ~ Int + val renders = summon[Fields.SummonAll[F, Render]] + assert(renders.contains("f1")) + assert(renders.contains("f30")) + } + +end FieldsTest diff --git a/kyo-data/shared/src/test/scala/kyo/RecordTest.scala b/kyo-data/shared/src/test/scala/kyo/RecordTest.scala index 8db3d921f..1b2be499d 100644 --- a/kyo-data/shared/src/test/scala/kyo/RecordTest.scala +++ b/kyo-data/shared/src/test/scala/kyo/RecordTest.scala @@ -1,192 +1,76 @@ package kyo -import Record.Field -import kyo.internal.TypeIntersection -import scala.compiletime.summonInline +import Record.* +import scala.language.implicitConversions class RecordTest extends Test: "creation" - { "single field" in { - val record = "name" ~ "Alice" & "name" ~ 10 - assert(record.name == "Alice") + val r = "name" ~ "Alice" + assert(r.name == "Alice") } "multiple fields" in { - val record = "name" ~ "Alice" & "age" ~ 30 - assert(record.name == "Alice") - assert(record.age == 30) - } - - "allows to use any names for fields" in { - val record = - "size" ~ 3 & - "fields" ~ List("x", "y", "z") & - "toMap" ~ Map(1 -> "x", 2 -> "y", 3 -> "z") & - "getField" ~ true & - "&" ~ "and" & - "equals" ~ "==" & - ";" ~ ";" & - "" ~ "empty" - - // "size" and "fields" are shadowed by extension methods, use getField - assert(record.getField["size", Int] == 3) - assert(record.getField["fields", List[String]] == List("x", "y", "z")) - - assert(record.getField["toMap", Map[Int, String]] == Map(1 -> "x", 2 -> "y", 3 -> "z")) - assert(record.getField["getField", Boolean] == true) - assert(record.getField["&", String] == "and") - assert(record.getField["equals", String] == "==") - assert(record.getField[";", String] == ";") - assert(record.getField["", String] == "empty") - } - - "from product types" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + assert(r.name == "Alice") + assert(r.age == 30) + } + + "arbitrary field names (reserved names)" in { + val r = + ("&" ~ "and") & + ("toMap" ~ "map") & + ("equals" ~ "eq") & + ("getField" ~ "gf") & + ("" ~ "empty") + assert(r.getField("&") == "and") + assert(r.getField("toMap") == "map") + assert(r.getField("equals") == "eq") + assert(r.getField("getField") == "gf") + assert(r.getField("") == "empty") + } + + "fromProduct basic" in { case class Person(name: String, age: Int) - val person = Person("Alice", 30) - val record = Record.fromProduct(person) - assert(record.name == "Alice") - assert(record.age == 30) - } - } - - "complex types" - { - "nested case classes" in { - case class Address(street: String, city: String) - case class Person(name: String, address: Address) - - val address = Address("123 Main St", "Springfield") - val person = Person("Alice", address) - val record = Record.fromProduct(person) - - assert(record.name == "Alice") - assert(record.address.street == "123 Main St") - assert(record.address.city == "Springfield") - } - - "option fields" in { - case class User(name: String, email: Option[String]) - val user1 = User("Bob", Some("bob@email.com")) - val user2 = User("Alice", None) - - val record1 = Record.fromProduct(user1) - val record2 = Record.fromProduct(user2) - - assert(record1.email.contains("bob@email.com")) - assert(record2.email.isEmpty) - } - - "collection fields" in { - case class Team(name: String, members: List[String]) - val team = Team("Engineering", List("Alice", "Bob", "Charlie")) - val record = Record.fromProduct(team) - - assert(record.name == "Engineering") - assert(record.members.length == 3) - assert(record.members.contains("Alice")) - } - - "sealed trait hierarchy" in { - sealed trait Animal - case class Dog(name: String, age: Int) extends Animal - case class Cat(name: String, lives: Int) extends Animal - - val dog = Dog("Rex", 5) - val dogRecord = Record.fromProduct(dog) - assert(dogRecord.name == "Rex") - assert(dogRecord.age == 5) - } - - "generic case classes" in { - case class Box[T](value: T) - val box = Box(42) - val record = Record.fromProduct(box) - assert(record.value == 42) - } - - } - - "map" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val mapped = record.map([t] => (value: t) => Option(value)) - assert((mapped.name: Option[String]) == Some("Alice")) - assert((mapped.age: Option[Int]) == Some(30)) - } - - "mapFields" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val mapped = record.mapFields([t] => (field: Record.Field[?, t], value: t) => Option(value)) - assert((mapped.name: Option[String]) == Some("Alice")) - assert((mapped.age: Option[Int]) == Some(30)) - } - - "zip" in { - val r1 = "name" ~ "Alice" & "age" ~ 30 - val r2 = "name" ~ true & "age" ~ 3.14 - val zipped = r1.zip(r2) - assert((zipped.name: (String, Boolean)) == ("Alice", true)) - assert((zipped.age: (Int, Double)) == (30, 3.14)) - } - - "values" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val v = record.values - typeCheck("""val _: (String, Int) = v""") - assert(v == ("Alice", 30)) - } - - "field access" - { - "type-safe access" in { - val record = "name" ~ "Bob" & "age" ~ 25 - val name0 = record.name - val name1: String = name0 - val age0 = record.age - val age1: Int = age0 - assert(record.name == "Bob") - assert(record.age == 25) - } - - "incorrect type access should not compile" in { - typeCheckFailure(""" - val record = "name" ~ "Bob" - val name: Int = record.name - """)("""Invalid field access: ("name" : String)""") - } - - "non-existent field access should not compile" in { - typeCheckFailure(""" - val record = "name" ~ "Bob" - val city = record.city - """)("""Invalid field access: ("city" : String)""") - } - } - - "edge cases" - { - "empty string fields" in { - val record = "name" ~ "" & "value" ~ "" - assert(record.name.isEmpty) - assert(record.value.isEmpty) - } - - "special characters in field names" in { - val record = "user-name" ~ "Alice" & "_internal" ~ 42 - assert(record.`user-name` == "Alice") - assert(record._internal == 42) - } - - "null values" in { - val record = "name" ~ null - assert(record.name == null) - } - - "empty case class" in { - case class Empty() - val record = Record.fromProduct(Empty()) - assert(record.size == 0) - } - - "case class with 22+ fields" in { + val r = Record.fromProduct(Person("Alice", 30)) + val name: String = r.name + val age: Int = r.age + assert(name == "Alice") + assert(age == 30) + } + + "fromProduct generic" in { + case class Box[A](value: A, label: String) + val r = Record.fromProduct(Box(42, "answer")) + val value: Int = r.value + assert(value == 42) + assert(r.label == "answer") + } + + "fromProduct single field" in { + case class Single(only: Boolean) + val r = Record.fromProduct(Single(true)) + val v: Boolean = r.only + assert(v == true) + assert(r.size == 1) + } + + "fromProduct many fields" in { + case class Big(a: Int, b: String, c: Boolean, d: Double, e: Long, f: Char, g: Float) + val r = Record.fromProduct(Big(1, "two", true, 4.0, 5L, '6', 7.0f)) + assert(r.a == 1) + assert(r.b == "two") + assert(r.c == true) + assert(r.d == 4.0) + assert(r.e == 5L) + assert(r.f == '6') + assert(r.g == 7.0f) + assert(r.size == 7) + } + + "fromProduct 22+ fields" in { case class Large( f1: Int, f2: Int, @@ -212,875 +96,527 @@ class RecordTest extends Test: f22: Int, f23: Int ) - val large = Large(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) - val record = Record.fromProduct(large) - assert(record.size == 23) - assert(record.f1 == 1) - assert(record.f23 == 23) - } - } - - "modification" - { - "adding new fields" in { - val record1 = "name" ~ "Charlie" - val record2 = record1 & "age" ~ 25 - assert(record2.name == "Charlie") - assert(record2.age == 25) + val r = Record.fromProduct(Large(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23)) + assert(r.size == 23) + assert(r.f1 == 1) + assert(r.f23 == 23) } - "updating existing fields" in { - val record1 = "name" ~ "Charlie" & "age" ~ 25 - val record2 = record1 & "age" ~ 26 - assert(record2.name == "Charlie") - assert(record2.age == 26) + "fromProduct rejects non-case-class" in { + class NotACase(val x: Int) + typeCheckFailure("""Record.fromProduct(new NotACase(1))""")("Required: Product") } - } - - "combining records" - { - "merge two records" in { - val record1 = "name" ~ "Eve" & "age" ~ 30 - val record2 = "city" ~ "Paris" & "country" ~ "France" - val merged = record1 & record2 - - assert(merged.name == "Eve") - assert(merged.age == 30) - assert(merged.city == "Paris") - assert(merged.country == "France") - } - - "merge with overlapping fields" in { - val record1 = "name" ~ "Eve" & "age" ~ 30 - val record2 = "age" ~ 31 & "city" ~ "Paris" - val merged = record1 & record2 - assert(merged.name == "Eve") - assert(merged.age == 31) - assert(merged.city == "Paris") - } + "fromProduct with Option and Collection fields" in { + case class User(name: String, email: Option[String]) + val r1 = Record.fromProduct(User("Bob", Some("bob@email.com"))) + val r2 = Record.fromProduct(User("Alice", None)) + assert(r1.email.contains("bob@email.com")) + assert(r2.email.isEmpty) - "merge with type conflicts" in { - val record1 = "value" ~ "string" - val record2 = "value" ~ 42 - val merged = record1 & record2 - assert((merged.value: Int) == 42) - assert((merged.value: String) == "string") + case class Team(name: String, members: List[String]) + val r3 = Record.fromProduct(Team("Engineering", List("Alice", "Bob"))) + assert(r3.members.length == 2) + assert(r3.members.contains("Alice")) } } - "type system" - { - "type tag behavior" in { - val record = "num" ~ 42 - val field = record.toMap.keySet.head - assert(field.tag =:= Tag[Int]) - } - - "handle generic types" in { - val record = "list" ~ List(1, 2, 3) - val field = record.toMap.keySet.head - assert(field.tag =:= Tag[List[Int]]) - } + "map" - { - "handle recursive types" in { - case class Node(value: Int, next: Option[Node]) - val node = Node(1, Some(Node(2, None))) - val record = Record.fromProduct(node) - assert(record.value == 1) - assert(record.next.isDefined) - assert(record.next.get.value == 2) + "map values" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val mapped = r.map([t] => (v: t) => Option(v)) + assert(mapped.name == Some("Alice")) + assert(mapped.age == Some(30)) } - "preserve type parameters in nested structures" in { - case class Wrapper[A, B](first: List[A], second: Map[String, B]) - val wrapper = Wrapper(List(1, 2, 3), Map("key" -> true)) - val record = Record.fromProduct(wrapper) - assert(record.first.head == 1) - assert(record.second.apply("key") == true) + "mapFields receives field metadata" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + var fieldNames = List.empty[String] + val mapped = r.mapFields([t] => + (field: Field[?, t], v: t) => + fieldNames = field.name :: fieldNames + Option(v)) + assert(mapped.name == Some("Alice")) + assert(mapped.age == Some(30)) + assert(fieldNames.toSet == Set("name", "age")) } } - "map operations" - { - "toMap consistency" in { - val original = "name" ~ "Alice" & "age" ~ 30 - val asMap = original.toMap + "zip" - { - assert(asMap.size == 2) - assert(asMap.keys.forall(_.isInstanceOf[Field[?, ?]])) + "pairs values by name" in { + val r1 = ("x" ~ 1) & ("y" ~ 2) + val r2 = ("x" ~ "a") & ("y" ~ "b") + val zipped = r1.zip(r2) + assert(zipped.x == (1, "a")) + assert(zipped.y == (2, "b")) } - "fields set operations" in { - val record = "name" ~ "Alice" & "age" ~ 30 - assert(record.size == 2) - assert(record.fields.toSet == Set("name", "age")) + "type safety" in { + val r1 = ("x" ~ 1) & ("y" ~ 2) + val r2 = ("x" ~ "a") & ("y" ~ "b") + val zipped = r1.zip(r2) + val x: (Int, String) = zipped.x + val y: (Int, String) = zipped.y + assert(x == (1, "a")) + assert(y == (2, "b")) } - } - "compact" - { - "preserves defined fields" in { - val record: Record["name" ~ String & "age" ~ Int] = - "name" ~ "Frank" & "age" ~ 40 - val compacted = record.compact - - assert(compacted.size == 2) - assert(compacted.name == "Frank") - assert(compacted.age == 40) + "single field" in { + val r1 = "x" ~ 1 + val r2 = "x" ~ "a" + val zipped = r1.zip(r2) + assert(zipped.x == (1, "a")) } - "removes extra fields" in { - val record: Record["name" ~ String] = - "name" ~ "Frank" & "age" ~ 40 - val compacted = record.compact - assert(compacted.size == 1) + "three fields" in { + val r1 = ("a" ~ 1) & ("b" ~ "hello") & ("c" ~ true) + val r2 = ("a" ~ 10.0) & ("b" ~ 'x') & ("c" ~ 42L) + val zipped = r1.zip(r2) + val a: (Int, Double) = zipped.a + val b: (String, Char) = zipped.b + val c: (Boolean, Long) = zipped.c + assert(a == (1, 10.0)) + assert(b == ("hello", 'x')) + assert(c == (true, 42L)) + } + + "rejects mismatched fields - left has extra" in { typeCheckFailure(""" - compacted.age - """)("""Invalid field access: ("age" : String)""") + val r1 = "a" ~ 1 & "b" ~ 2 + val r2 = "a" ~ "x" + r1.zip(r2) + """)( + "SameNames" + ) } - } - - "stage" - { - case class AsColumn[A](typ: String) - - object AsColumn: - given AsColumn[Int] = AsColumn("bigint") - given AsColumn[String] = AsColumn("text") - - case class Column[A](name: String)(using AsColumn[A]) derives CanEqual - - object ColumnInline extends Record.StageAs[[n, v] =>> Column[v]]: - inline def stage[Name <: String, Value](field: Field[Name, Value]): Column[Value] = - Column[Value](field.name)(using summonInline[AsColumn[Value]]) - "build record if all inlined" in { - typeCheck(""" - type Person = "name" ~ String & "age" ~ Int - - val columns = Record.stage[Person](ColumnInline) - val result = "name" ~ Column[String]("name") & - "age" ~ Column[Int]("age") - - assert(columns.name == Column[String]("name")) - assert(columns.age == Column[Int]("age")) - assert(columns == result) - """) + "rejects mismatched fields - right has extra" in { + typeCheckFailure(""" + val r1 = "a" ~ 1 + val r2 = "a" ~ "x" & "b" ~ "y" + r1.zip(r2) + """)( + "SameNames" + ) } - "compile error when inlining failed" in { - class Role() - type Person = "name" ~ String & "age" ~ Int & "role" ~ Role - + "rejects mismatched fields - no overlap" in { typeCheckFailure(""" - Record.stage[Person](ColumnInline) - """)("""No given instance of type AsColumn[Role] was found""") + val r1 = "a" ~ 1 + val r2 = "b" ~ "x" + r1.zip(r2) + """)( + "SameNames" + ) } } - "AsFields behavior" - { - "complex intersection types" in { - val fields = Record.AsFields["a" ~ Int & "b" ~ String & "c" ~ Int] - assert(fields.size == 3) - assert(fields.map(_.name).toSet == Set("a", "b", "c")) - } - - "nested intersection types" in { - type Nested = "outer" ~ Int & ("inner1" ~ String & "inner2" ~ Boolean) - val fields = Record.AsFields[Nested] - assert(fields.size == 3) - assert(fields.map(_.name).toSet == Set("outer", "inner1", "inner2")) - } + "values" - { - "intersections with 4 fields" in { - type Fields4 = "a" ~ Int & "b" ~ String & "c" ~ Boolean & "d" ~ List[Int] - val fields = Record.AsFields[Fields4] - assert(fields.size == 4) - assert(fields.map(_.name).toSet == Set("a", "b", "c", "d")) + "typed tuple" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val vs = r.values + assert(vs == ("Alice", 30)) } - "intersections up to 22 fields" in { - type Fields22 = - "1" ~ Int & "2" ~ Int & "3" ~ Int & "4" ~ Int & "5" ~ Int & "6" ~ Int & "7" ~ Int & "8" ~ Int & "9" ~ Int & "10" ~ Int & - "11" ~ Int & "12" ~ Int & "13" ~ Int & "14" ~ Int & "15" ~ Int & "16" ~ Int & "17" ~ Int & "18" ~ Int & "19" ~ Int & - "20" ~ Int & "21" ~ Int & "22" ~ Int - typeCheck("Record.AsFields[Fields22]") + "type safety" in { + val r = ("x" ~ 1) & ("y" ~ 2) + val vs: (Int, Int) = r.values + assert(vs == (1, 2)) } - "intersections with more than 22 fields" in { - type Fields23 = - "1" ~ Int & "2" ~ Int & "3" ~ Int & "4" ~ Int & "5" ~ Int & "6" ~ Int & "7" ~ Int & "8" ~ Int & "9" ~ Int & "10" ~ Int & - "11" ~ Int & "12" ~ Int & "13" ~ Int & "14" ~ Int & "15" ~ Int & "16" ~ Int & "17" ~ Int & "18" ~ Int & "19" ~ Int & - "20" ~ Int & "21" ~ Int & "22" ~ Int & "23" ~ Int - typeCheck("Record.AsFields[Fields23]") + "single field" in { + val r = "name" ~ "Alice" + val v = r.values + assert(v == Tuple1("Alice")) } - "intersections with duplicate field names but different types" in { - type DupeFields = "value" ~ Int & "value" ~ String - val fields = Record.AsFields[DupeFields] - assert(fields.size == 2) - assert(fields.map(_.name).toSet == Set("value")) - assert(fields.map(_.tag).toSet.size == 2) + "three fields" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) & ("active" ~ true) + val v = r.values + assert(v == ("Alice", 30, true)) } } - "AsRecord" - { - "derive for case class" in { - case class Person(name: String, age: Int) - val record = Record.fromProduct(Person("Alice", 30)) - assert(record.name == "Alice") - assert(record.age == 30) - } + "field access" - { - "derive for tuple" in { - val tuple = ("Alice", 30) - val record = Record.fromProduct(tuple) - assert(record._1 == "Alice") - assert(record._2 == 30) + "selectDynamic with inference" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + assert(r.name.length == 5) + assert(r.age + 1 == 31) } - } - "nested records" in { - val inner = "x" ~ 1 & "y" ~ 2 - typeCheck("""("nested" ~ inner)""") - } - - "variance behavior" - { - "allows upcasting to fewer fields" in { - val full: Record["name" ~ String & "age" ~ Int & "city" ~ String] = - "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" - - val nameAge: Record["name" ~ String & "age" ~ Int] = full - assert(nameAge.name == "Alice") - assert(nameAge.age == 30) - - val nameOnly: Record["name" ~ String] = full - assert(nameOnly.name == "Alice") + "getField for special names" in { + val r = ("user-name" ~ "Alice") & ("age" ~ 30) + assert(r.getField("user-name") == "Alice") + assert(r.getField("age") == 30) } - "preserves type safety" in { + "compile error for missing field" in { typeCheckFailure(""" - val partial: Record["name" ~ String] = "age" ~ 30 - """)("""Required: kyo.Record[("name" : String) ~ String]""") - } - - "allows multiple upcasts" in { - val record = "name" ~ "Bob" & "age" ~ 25 & "city" ~ "London" & "active" ~ true - - def takesNameAge(r: Record["name" ~ String & "age" ~ Int]): String = - s"${r.name} is ${r.age}" - - def takesNameOnly(r: Record["name" ~ String]): String = - s"Name: ${r.name}" - - assert(takesNameAge(record) == "Bob is 25") - assert(takesNameOnly(record) == "Name: Bob") + val r = "name" ~ "Alice" + summon[Fields.Have[r.type, "missing"]] + """)("Have") } } - "equality and hashCode" - { - - "equal records with same fields" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 - val record2 = "name" ~ "Alice" & "age" ~ 30 - - assert(record1 == record2) - assert(record1.hashCode == record2.hashCode) - } - - "not equal with different values" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 - val record2 = "name" ~ "Alice" & "age" ~ 31 - - assert(record1 != record2) - } - - "equal with fields in different order" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 - val record2 = "age" ~ 30 & "name" ~ "Alice" + "edge cases" - { - assert(record1 == record2) - assert(record1.hashCode == record2.hashCode) + "empty string values" in { + val r = ("name" ~ "") & ("value" ~ "") + assert(r.name.isEmpty) + assert(r.value.isEmpty) } - "not equal with different number of fields" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 - val record2 = "name" ~ "Alice" - - assert(record1 != record2) + "null values" in { + val r = "name" ~ (null: String) + assert(r.name == null) } - "compile when all fields have CanEqual" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 & "scores" ~ List(1, 2, 3) - val record2 = "name" ~ "Alice" & "age" ~ 30 & "scores" ~ List(1, 2, 3) - - assert(record1 == record2) + "nested records creation and access" in { + val inner = ("x" ~ 1) & ("y" ~ 2) + val outer = ("nested" ~ inner) & ("name" ~ "test") + assert(outer.nested.x == 1) + assert(outer.nested.y == 2) + assert(outer.name == "test") } + } - "not compile with different field names" in { - val record1 = "name" ~ "Alice" & "age" ~ 30 - val record2 = "name" ~ "Alice" & "years" ~ 30 + "update" - { - typeCheckFailure(""" - assert(record1 != record2) - """)("""cannot be compared with == or !=""") + "existing field" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = r.update("name", "Bob") + assert(r2.name == "Bob") + assert(r2.age == 30) } - "not compile when fields lack CanEqual" in { - case class NoEqual(x: Int) - - val record1: Record["test" ~ NoEqual] = "test" ~ NoEqual(1) - val record2 = "test" ~ NoEqual(1) - - typeCheckFailure(""" - assert(record1 == record2) - """)("""cannot be compared with == or !=""") + "wrong field doesn't compile" in { + val r = "name" ~ "Alice" + typeCheckFailure("""r.update("nope", 1)""")("Cannot prove") } - "subtype equality" - { - - "not equal when values differ" in { - val full: Record["name" ~ String & "age" ~ Int & "city" ~ String] = - "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" - val partial: Record["name" ~ String & "age" ~ Int] = - "name" ~ "Bob" & "age" ~ 30 - - assert(partial != full) - assert(full != partial) - } - - "equal across multiple subtype levels" in { - val full: Record["name" ~ String & "age" ~ Int & "city" ~ String] = - "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" - val medium: Record["name" ~ String & "age" ~ Int] = full - val minimal: Record["name" ~ String] = full - - assert(minimal == medium) - assert(medium == full) - assert(minimal == full) - } - - "maintains symmetry" in { - val full: Record["name" ~ String & "age" ~ Int & "city" ~ String] = - "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" - val partial: Record["name" ~ String & "age" ~ Int] = full - - assert((full == partial) == (partial == full)) - } + "wrong type doesn't compile" in { + val r = "name" ~ "Alice" + typeCheckFailure("""r.update("name", 42)""")("Cannot prove") } } - "duplicate field names" - { - "allows fields with same name but different types" in { - val record = "value" ~ "string" & "value" ~ 42 + "combining records" - { - assert((record.value: String) == "string") - assert((record.value: Int) == 42) + "merge via &" in { + val r1 = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = ("city" ~ "Paris") & ("country" ~ "France") + val merged = r1 & r2 + assert(merged.name == "Alice") + assert(merged.age == 30) + assert(merged.city == "Paris") + assert(merged.country == "France") } - "preserves both values when merging records" in { - val record1 = "value" ~ "string" - val record2 = "value" ~ 42 - val merged = record1 & record2 - - assert((merged.value: String) == "string") - assert((merged.value: Int) == 42) + "fromProduct merge with manual" in { + case class Name(first: String) + val r = Record.fromProduct(Name("Alice")) & ("last" ~ "Smith") + assert(r.first == "Alice") + assert(r.last == "Smith") } - "handles multiple duplicate fields" in { - val record = "x" ~ "str" & "x" ~ 42 & "x" ~ true + "empty & record" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val merged = Record.empty & r + assert(merged.name == "Alice") + assert(merged.age == 30) + } - assert((record.x: String) == "str") - assert((record.x: Int) == 42) - assert((record.x: Boolean) == true) + "record & empty" in { + val r = ("name" ~ "Alice") & ("age" ~ 30) + val merged = r & Record.empty + assert(merged.name == "Alice") + assert(merged.age == 30) } } - "Tag derivation" - { - "derives tags for record" in { - typeCheck("""summon[Tag[Record["name" ~ String & "age" ~ Int]]]""") - } + "type system" - { - "doesn't limit the use of nested records" in { - val inner = "x" ~ 1 & "y" ~ 2 - typeCheck(""""z" ~ inner""") + "duplicate fields merge to union" in { + summon[("f" ~ Int & "f" ~ String) =:= ("f" ~ (Int | String))] + succeed } - } - "Render" - { - "simple record" in { - val record = "name" ~ "John" & "age" ~ 30 - assert(Render.asText(record).show == """name ~ John & age ~ 30""") + "structural subtyping assignment" in { + val full = ("name" ~ "Alice") & ("age" ~ 30) + val nameOnly: Record["name" ~ String] = full + assert(nameOnly.name == "Alice") } - "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""") + "structural subtyping function param" in { + def getName(r: Record["name" ~ String]): String = r.name + val r = ("name" ~ "Alice") & ("age" ~ 30) + assert(getName(r) == "Alice") } - "empty record" in { - val record = Record.empty - assert(Render.asText(record).show == "") + "structural subtyping type bounds" in { + def getName[F <: "name" ~ String](r: Record[F]): String = r.name + val r = ("name" ~ "Alice") & ("age" ~ 30) + assert(getName(r) == "Alice") } - "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""") + "generic passthrough preserves type" in { + def passthrough[F <: "name" ~ String](r: Record[F]): Record[F] = r + val r = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = passthrough(r) + assert(r2.name == "Alice") + assert(r2.age == 30) } - "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****""") + "mixed duplicate and unique" in { + summon[("a" ~ Int & "a" ~ String & "b" ~ Boolean) =:= ("a" ~ (Int | String) & "b" ~ Boolean)] + succeed } - } - "malformed record API behavior" - { - - "method call restrictions" - { - type MalformedRecord = Record[Int & "name" ~ String & "age" ~ Int] - - "cannot call methods that take malformed types" in { - def takesMalformed(r: MalformedRecord): String = r.name - typeCheckFailure(""" - takesMalformed("name" ~ "test" & "age" ~ 42) - """)("""Required: kyo.Record[Int & ("age" : String) ~ Int]""") - } - - "cannot return malformed types" in { - typeCheckFailure(""" - def returnsMalformed(): MalformedRecord = - "name" ~ "test" & "age" ~ 42 - """)("""Required: kyo.Record[Int & ("age" : String) ~ Int]""") - } - } - - "behavior with unsafe type cast" - { - val record = - ("name" ~ "test" & "age" ~ 42) - .asInstanceOf[Record[Int & "name" ~ String & "age" ~ Int]] - - "selectDynamic works" in { - val name: String = record.name - val age: Int = record.age - assert(name == "test") - assert(age == 42) - } - - "toMap preserves fields" in { - given [A]: CanEqual[A, A] = CanEqual.derived - val map: Map[Field[?, ?], Any] = record.toMap - assert(map.size == 2) - assert(map(Field("name", Tag[String])) == "test") - assert(map(Field("age", Tag[Int])) == 42) - } - - "fields returns correct list" in { - assert(record.size == 2) - assert(record.fields.toSet == Set("name", "age")) - } - - "size returns correct count" in { - val size: Int = record.size - assert(size == 2) - } - - "& operator works with malformed base" in { - val extended = record & ("extra" ~ "value") - assert(extended.name == "test") - assert(extended.age == 42) - assert(extended.extra == "value") - } - } - - "AsFields behavior" - { - val error = "Given type doesn't match to expected field shape: Name ~ Value" - - "summoning AsFields instance" in { - typeCheckFailure(""" - summon[Record.AsFields[Int & "name" ~ String & "age" ~ Int]] - """)(error) - } - - "AsFields with multiple raw types" in { - typeCheckFailure(""" - Record.AsFields[Int & Boolean & "value" ~ String & String] - """)(error) - } - - "AsFields with duplicate field names" in { - typeCheckFailure(""" - Record.AsFields[Int & "value" ~ String & "value" ~ Int] - """)(error) - } - - "compact with AsFields" in { - val record = ("name" ~ "test" & "age" ~ 42) - .asInstanceOf[Record[Int & "name" ~ String & "age" ~ Int]] - typeCheckFailure(""" - record.compact - """)(error) - } + "duplicate field: last writer wins at runtime" in { + val r = "f" ~ 1 & "f" ~ "hello" + // Type is "f" ~ (Int | String) due to contravariance + val v: Int | String = r.f + // Dict uses last-writer-wins, so "hello" from the right operand is stored + assert(v.equals("hello")) } - } - - "nested records" - { - - "creation and access" in { - val inner = "x" ~ 1 & "y" ~ 2 - val outer = "nested" ~ inner & "name" ~ "test" - assert(outer.nested.x == 1) - assert(outer.nested.y == 2) - assert(outer.name == "test") + "duplicate field: left operand value when no right override" in { + val r1 = "f" ~ 42 + val r2 = "g" ~ "other" + val r = r1 & r2 + assert(r.f == 42) } - "multi-level nesting" in { - val deepest = "value" ~ "deep" & "num" ~ 42 - val middle = "deepRecord" ~ deepest & "flag" ~ true - val outer = "middleRecord" ~ middle & "name" ~ "test" - - assert(outer.middleRecord.deepRecord.value == "deep") - assert(outer.middleRecord.deepRecord.num == 42) - assert(outer.middleRecord.flag == true) - assert(outer.name == "test") + "duplicate field: right operand overrides left" in { + val r1 = "f" ~ 1 & "g" ~ "a" + val r2 = "f" ~ 2 & "h" ~ true + val r = r1 & r2 + assert(r.f == 2) + assert(r.g == "a") + assert(r.h == true) } - "adding fields to nested records" in { - val inner = "x" ~ 1 & "y" ~ 2 - val outer = "nested" ~ inner & "name" ~ "test" - - val outerWithExtra = outer & "extra" ~ "value" - assert(outerWithExtra.extra == "value") - assert(outerWithExtra.nested.x == 1) - - val innerWithExtra = inner & "z" ~ 3 - val newOuter = "nested" ~ innerWithExtra & "name" ~ "test" - assert(newOuter.nested.z == 3) - assert(newOuter.nested.x == 1) + "duplicate field: return type is union" in { + val r = "f" ~ 1 & "f" ~ "hello" + // Verify the return type is Int | String (compile-time check) + typeCheck("""val _: Int | String = r.f""") + // Should NOT compile as just Int + typeCheckFailure("""val _: Int = r.f""")("Found: Int | String") } - "combining records with nested fields" in { - val inner1 = "x" ~ 1 & "y" ~ 2 - val outer1 = "nested" ~ inner1 & "name" ~ "test1" - - val inner2 = "a" ~ "A" & "b" ~ "B" - val outer2 = "nested2" ~ inner2 & "count" ~ 5 - - val combined = outer1 & outer2 - - assert(combined.nested.x == 1) - assert(combined.nested.y == 2) - assert(combined.name == "test1") - assert(combined.nested2.a == "A") - assert(combined.nested2.b == "B") - assert(combined.count == 5) + "duplicate field: three-way union" in { + summon[("f" ~ Int & "f" ~ String & "f" ~ Boolean) =:= ("f" ~ (Int | String | Boolean))] + val r = "f" ~ 1 & "f" ~ "hello" & "f" ~ true + val v: Int | String | Boolean = r.f + assert(v.equals(true)) // last writer wins } - "duplicate field names in nested records" in { - val inner1 = "value" ~ "string" & "count" ~ 1 - val inner2 = "value" ~ 42 & "count" ~ "two" - - val outer = "data" ~ inner1 & "info" ~ inner2 - - assert((outer.data.value: String) == "string") - assert((outer.data.count: Int) == 1) - assert((outer.info.value: Int) == 42) - assert((outer.info.count: String) == "two") + "duplicate field: explicit union type annotation" in { + val r: Record["f" ~ (Int | String)] = "f" ~ 42 + val v: Int | String = r.f + assert(v.equals(42)) } - "nested record type safety" in { - val inner1 = "x" ~ 1 & "y" ~ 2 - val inner2 = "x" ~ "string" & "y" ~ true - - val outer1 = "nested" ~ inner1 & "name" ~ "test1" - val outer2 = "nested" ~ inner2 & "name" ~ "test2" - - typeCheckFailure(""" - val fail: Record["nested" ~ Record["x" ~ Int & "y" ~ Int] & "name" ~ String] = outer2 - """)("""Required: kyo.Record[("nested" : String)""") + "duplicate field: combine with non-duplicate" in { + val r = "name" ~ "Alice" & "value" ~ 1 & "value" ~ "str" + assert(r.name == "Alice") + val v: Int | String = r.value + assert(v.equals("str")) // last writer wins } - "case class conversion with nested records" in { - case class Point(x: Int, y: Int) - case class NamedPoint(point: Point, name: String) - - val point = Point(10, 20) - val namedPoint = NamedPoint(point, "Origin") - - val record = Record.fromProduct(namedPoint) - - assert(record.point.x == 10) - assert(record.point.y == 20) - assert(record.name == "Origin") - - case class Container(record: Record["x" ~ Int & "y" ~ Int], label: String) - val innerRecord = "x" ~ 5 & "y" ~ 10 - val container = Container(innerRecord, "Container") - - val containerRecord = Record.fromProduct(container) - assert(containerRecord.record.x == 5) - assert(containerRecord.record.y == 10) - assert(containerRecord.label == "Container") + "Tag derivation" in { + typeCheck("""summon[Tag[Record["name" ~ String & "age" ~ Int]]]""") } + } - "recursive nested records" in { - val leaf1 = "value" ~ "leaf1" & "leaf" ~ true - val leaf2 = "value" ~ "leaf2" & "leaf" ~ true - val branch = "left" ~ leaf1 & "right" ~ leaf2 & "branch" ~ true - val root = "main" ~ branch & "root" ~ true + "toDict" - { - assert(root.main.left.value == "leaf1") - assert(root.main.left.leaf == true) - assert(root.main.right.value == "leaf2") - assert(root.main.branch == true) - assert(root.root == true) + "preserves all entries" in { + case class Pair(a: Int, b: String) + val r = Record.fromProduct(Pair(1, "two")) + given CanEqual[Any, Any] = CanEqual.derived + assert(r.toDict.is(Dict("a" -> 1, "b" -> "two"))) } + } - "equality and hashCode with nested records" in { - val inner1 = "x" ~ 1 & "y" ~ 2 - val inner2 = "x" ~ 1 & "y" ~ 2 - val inner3 = "x" ~ 1 & "y" ~ 3 - - val outer1 = "nested" ~ inner1 & "name" ~ "test" - val outer2 = "nested" ~ inner2 & "name" ~ "test" - val outer3 = "nested" ~ inner3 & "name" ~ "test" + "equality and hashCode" - { - assert(outer1 == outer2) - assert(outer1.hashCode == outer2.hashCode) - assert(outer1 != outer3) + "equal records" in { + val r1 = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = ("name" ~ "Alice") & ("age" ~ 30) + assert(r1 == r2) } - "render with nested records" in { - val inner = "x" ~ 1 & "y" ~ 2 - val outer = "nested" ~ inner & "name" ~ "test" - - val rendered = Render.asText(outer).show - assert(rendered.contains("nested ~")) - assert(rendered.contains("name ~ test")) + "not equal different values" in { + val r1 = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = ("name" ~ "Bob") & ("age" ~ 25) + assert(r1 != r2) } - "mixed complex nested structures" in { - case class Point(x: Int, y: Int) - case class Circle(center: Point, radius: Int) - - val circleRecord = Record.fromProduct(Circle(Point(0, 0), 10)) - - val manual = "width" ~ 100 & "height" ~ 200 - val mixed = "circle" ~ circleRecord & "rectangle" ~ manual & "name" ~ "shapes" - - assert(mixed.circle.center.x == 0) - assert(mixed.circle.center.y == 0) - assert(mixed.circle.radius == 10) - assert(mixed.rectangle.width == 100) - assert(mixed.rectangle.height == 200) - assert(mixed.name == "shapes") + "field order independence" in { + val r1 = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = ("age" ~ 30) & ("name" ~ "Alice") + assert(r1 == r2) + assert(r1.hashCode == r2.hashCode) } - "nested option fields" in { - val inner = "x" ~ 1 & "y" ~ 2 - - val withSome = "nested" ~ Some(inner) & "name" ~ "hasValue" - val withNone = "nested" ~ Option.empty[Record["x" ~ Int & "y" ~ Int]] & "name" ~ "noValue" - - assert(withSome.nested.isDefined) - assert(withSome.nested.get.x == 1) - assert(withSome.nested.get.y == 2) - assert(withSome.name == "hasValue") - - assert(withNone.nested.isEmpty) - assert(withNone.name == "noValue") + "rejects without Comparable" in { + class NoEq + val r1: Record["x" ~ NoEq] = "x" ~ new NoEq + val r2: Record["x" ~ NoEq] = "x" ~ new NoEq + typeCheckFailure("""r1 == r2""")("cannot be compared") } - "nested collection fields" in { - val record1 = "id" ~ 1 & "value" ~ "one" - val record2 = "id" ~ 2 & "value" ~ "two" - val record3 = "id" ~ 3 & "value" ~ "three" - - val listRecord = "items" ~ List(record1, record2, record3) & "count" ~ 3 - - val items = listRecord.items - assert(items.length == 3) - assert(items(0).id == 1) - assert(items(1).value == "two") - assert(items(2).id == 3) - - // Map of records - val mapRecord = "mapping" ~ Map( - "first" -> record1, - "second" -> record2 - ) & "size" ~ 2 - - val mapping = mapRecord.mapping - assert(mapping.size == 2) - assert(mapping("first").id == 1) - assert(mapping("second").value == "two") + "== works with Comparable" in { + val r1 = ("name" ~ "Alice") & ("age" ~ 30) + val r2 = ("name" ~ "Alice") & ("age" ~ 30) + val r3 = ("name" ~ "Bob") & ("age" ~ 25) + assert(r1 == r2) + assert(r1 != r3) } - "paths with mixed nested records and case classes" in { - case class Location(lat: Double, lng: Double) - case class Address(street: String, location: Location) - case class Company(name: String, address: Address) - - val company = Company("Acme", Address("123 Main St", Location(37.7749, -122.4194))) - val companyRecord = Record.fromProduct(company) + } - val department = "id" ~ 42 & "title" ~ "Engineering" - val employee = "name" ~ "Alice" & "company" ~ companyRecord & "department" ~ department + "Render" - { - assert(employee.name == "Alice") - assert(employee.company.name == "Acme") - assert(employee.company.address.street == "123 Main St") - assert(employee.company.address.location.lat == 37.7749) - assert(employee.company.address.location.lng == -122.4194) - assert(employee.department.id == 42) - assert(employee.department.title == "Engineering") + "Render given" in { + val r = ("name" ~ "Bob") & ("age" ~ 25) + val render = Render[Record["name" ~ String & "age" ~ Int]] + val text = render.asString(r) + assert(text.contains("name")) + assert(text.contains("Bob")) + assert(text.contains("age")) + assert(text.contains("25")) } } - "update method" - { - "updates a field value" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val updated = record.update("age", 31) - assert(updated.name == "Alice") - assert(updated.age == 31) - } + "stage" - { - "updates first field" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val updated = record.update("name", "Bob") - assert(updated.name == "Bob") - assert(updated.age == 30) - } + "with type class" in { + trait AsColumn[A]: + def sqlType: String + object AsColumn: + def apply[A](tpe: String): AsColumn[A] = new AsColumn[A]: + def sqlType = tpe + given AsColumn[Int] = AsColumn("bigint") + given AsColumn[String] = AsColumn("text") + end AsColumn - "preserves record type" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val updated = record.update("name", "Bob") - typeCheck("""val _: Record["name" ~ String & "age" ~ Int] = updated""") - } + case class Column[A](name: String, sqlType: String) derives CanEqual - "updates in record with 3+ fields" in { - val record = "a" ~ 1 & "b" ~ 2 & "c" ~ 3 - val updated = record.update("b", 20) - assert(updated.a == 1) - assert(updated.b == 20) - assert(updated.c == 3) + type Person = "name" ~ String & "age" ~ Int + val columns = Record.stage[Person].using[AsColumn]([v] => + (field: Field[?, v], ac: AsColumn[v]) => + Column[v](field.name, ac.sqlType)) + assert(columns.name == Column[String]("name", "text")) + assert(columns.age == Column[Int]("age", "bigint")) } - "rejects non-existent field name" in { - typeCheckFailure(""" - val record = "name" ~ "Alice" & "age" ~ 30 - record.update("city", "Paris") - """)("Invalid field update") + "without type class" in { + type Person = "name" ~ String & "age" ~ Int + val staged = Record.stage[Person]([v] => (field: Field[?, v]) => Option.empty[v]) + assert(staged.name == None) + assert(staged.age == None) } - "rejects wrong value type" in { - typeCheckFailure(""" - val record = "name" ~ "Alice" & "age" ~ 30 - record.update("age", "not an int") - """)("Invalid field update") - } - } + "compile error when type class missing" in { + trait AsColumn[A] + object AsColumn: + given AsColumn[Int] = new AsColumn[Int] {} + given AsColumn[String] = new AsColumn[String] {} - "empty record" - { - "has size 0" in { - assert(Record.empty.size == 0) - } + class Role() + type BadPerson = "name" ~ String & "age" ~ Int & "role" ~ Role - "combining empty with another record" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val merged = Record.empty & record - assert(merged.name == "Alice") - assert(merged.age == 30) + typeCheckFailure("""Record.stage[BadPerson].using[AsColumn]([v] => + (field: Field[?, v], ac: AsColumn[v]) => Option.empty[v])""")("AsColumn[Role]") } + } - "combining another record with empty" in { - val record = "name" ~ "Alice" & "age" ~ 30 - val merged = record & Record.empty - assert(merged.name == "Alice") - assert(merged.age == 30) + "compact" - { + + "filters to declared fields" in { + val full = ("name" ~ "Alice") & ("age" ~ 30) + val nameOnly: Record["name" ~ String] = full + val compacted = nameOnly.compact + assert(compacted.size == 1) + assert(compacted.name == "Alice") } - } - "compact edge cases" - { - "compact on record with exact fields (no-op)" in { - val record: Record["name" ~ String & "age" ~ Int] = - "name" ~ "Alice" & "age" ~ 30 - val compacted = record.compact + "no-op when exact" in { + val r: Record["name" ~ String & "age" ~ Int] = ("name" ~ "Alice") & ("age" ~ 30) + val compacted = r.compact assert(compacted.size == 2) assert(compacted.name == "Alice") assert(compacted.age == 30) } } - "values edge cases" - { - "single field" in { - val record = "name" ~ "Alice" - val v = record.values - typeCheck("""val _: Tuple1[String] = v""") - assert(v == Tuple1("Alice")) - } + "fields metadata" - { - "three fields" in { - val record = "name" ~ "Alice" & "age" ~ 30 & "active" ~ true - val v = record.values - typeCheck("""val _: (String, Int, Boolean) = v""") - assert(v == ("Alice", 30, true)) + "Fields.names" in { + val names = Fields.names["name" ~ String & "age" ~ Int] + assert(names == Set("name", "age")) } - } - "fields ordering" - { - "returns fields in consistent order" in { - val record = "name" ~ "Alice" & "age" ~ 30 & "city" ~ "Paris" - val f = record.fields - assert(f.toSet == Set("name", "age", "city")) - assert(f.size == 3) + "Fields.fields" in { + val fs = Fields.fields["name" ~ String & "age" ~ Int] + assert(fs.size == 2) + assert(fs.map(_.name).toSet == Set("name", "age")) } - } - "mapFields with field info" - { - "passes correct field names" in { - val record = "name" ~ "Alice" & "age" ~ 30 - var fieldNames = List.empty[String] - record.mapFields([t] => - (field: Record.Field[?, t], value: t) => - fieldNames = fieldNames :+ field.name - Option(value)) - assert(fieldNames.toSet == Set("name", "age")) + "nested Fields" in { + type Inner = "x" ~ Int & "y" ~ Int + type Outer = "point" ~ Record[Inner] + val fs = Fields.fields[Outer] + assert(fs.size == 1) + assert(fs.head.name == "point") + assert(fs.head.nested.size == 2) + assert(fs.head.nested.map(_.name).toSet == Set("x", "y")) } } - "zip edge cases" - { - "single field records" in { - val r1 = "x" ~ 1 - val r2 = "x" ~ "a" - val zipped = r1.zip(r2) - assert((zipped.x: (Int, String)) == (1, "a")) - } - - "three field records" in { - val r1 = "a" ~ 1 & "b" ~ "hello" & "c" ~ true - val r2 = "a" ~ 10.0 & "b" ~ 'x' & "c" ~ 42L - val zipped = r1.zip(r2) - assert((zipped.a: (Int, Double)) == (1, 10.0)) - assert((zipped.b: (String, Char)) == ("hello", 'x')) - assert((zipped.c: (Boolean, Long)) == (true, 42L)) - } + "large record: values" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int + val r: Record[F] = "f1" ~ 1 & "f2" ~ 2 & "f3" ~ 3 & "f4" ~ 4 & "f5" ~ 5 & + "f6" ~ 6 & "f7" ~ 7 & "f8" ~ 8 & "f9" ~ 9 & "f10" ~ 10 & + "f11" ~ 11 & "f12" ~ 12 & "f13" ~ 13 & "f14" ~ 14 & "f15" ~ 15 & + "f16" ~ 16 & "f17" ~ 17 & "f18" ~ 18 & "f19" ~ 19 & "f20" ~ 20 & + "f21" ~ 21 & "f22" ~ 22 & "f23" ~ 23 & "f24" ~ 24 & "f25" ~ 25 + val v = r.values + given CanEqual[Any, Any] = CanEqual.derived + val elems = (0 until v.productArity).map(v.productElement).toSet + assert(elems == (1 to 25).toSet) } - "Render edge cases" - { - "render with duplicate field names" in { - val record = "x" ~ 1 & "x" ~ "hello" - val rendered = Render.asText(record).show - assert(rendered.contains("x ~")) - } + "large record: stage" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int + val staged = Record.stage[F]([v] => (field: Field[?, v]) => Option.empty[v]) + assert(staged.f1 == None) + assert(staged.f25 == None) + } - "render single field" in { - val record = "name" ~ "Alice" - val rendered = Render.asText(record).show - assert(rendered == "name ~ Alice") - } + "large record: stage with type class" in { + type F = "f1" ~ Int & "f2" ~ Int & "f3" ~ Int & "f4" ~ Int & "f5" ~ Int & + "f6" ~ Int & "f7" ~ Int & "f8" ~ Int & "f9" ~ Int & "f10" ~ Int & + "f11" ~ Int & "f12" ~ Int & "f13" ~ Int & "f14" ~ Int & "f15" ~ Int & + "f16" ~ Int & "f17" ~ Int & "f18" ~ Int & "f19" ~ Int & "f20" ~ Int & + "f21" ~ Int & "f22" ~ Int & "f23" ~ Int & "f24" ~ Int & "f25" ~ Int + val staged = Record.stage[F].using[Render]([v] => (field: Field[?, v], r: Render[v]) => Option.empty[v]) + assert(staged.f1 == None) + assert(staged.f25 == None) } + end RecordTest diff --git a/kyo-data/shared/src/test/scala/kyo/internal/TypeIntersectionTest.scala b/kyo-data/shared/src/test/scala/kyo/internal/TypeIntersectionTest.scala deleted file mode 100644 index bcc01c15c..000000000 --- a/kyo-data/shared/src/test/scala/kyo/internal/TypeIntersectionTest.scala +++ /dev/null @@ -1,224 +0,0 @@ -package kyo.internal - -import kyo.Test -import org.scalatest.freespec.AnyFreeSpec - -class TypeIntersectionTest extends Test: - - sealed trait Base - trait A extends Base - trait B extends Base - trait C extends Base - trait D extends Base - trait E extends Base - class Concrete extends Base - class Child extends Concrete - case class Data(value: String) extends Base - trait Generic[T] extends Base - trait MultiParam[A, B] extends Base - trait Recursive[T <: Base] extends Base - trait Empty extends Base with Generic[String] - - "basic type decomposition" - { - "single trait" in { - val typeSet = TypeIntersection[A] - assertType[typeSet.AsTuple, A *: EmptyTuple] - } - - "concrete class" in { - val typeSet = TypeIntersection[Concrete] - assertType[typeSet.AsTuple, Concrete *: EmptyTuple] - } - - "case class" in { - val typeSet = TypeIntersection[Data] - assertType[typeSet.AsTuple, Data *: EmptyTuple] - } - - "generic trait" in { - val typeSet = TypeIntersection[Generic[String]] - assertType[typeSet.AsTuple, Generic[String] *: EmptyTuple] - } - - "empty trait with mixed-in generic" in { - val typeSet = TypeIntersection[Empty] - assertType[typeSet.AsTuple, Empty *: EmptyTuple] - } - - "null type" in { - val typeSet = TypeIntersection[Null] - assertType[typeSet.AsTuple, Null *: EmptyTuple] - } - } - - "intersection types" - { - "two types" in { - val typeSet = TypeIntersection[A & B] - assertType[typeSet.AsTuple, A *: B *: EmptyTuple] - } - - "three types" in { - val typeSet = TypeIntersection[A & B & C] - assertType[typeSet.AsTuple, A *: B *: C *: EmptyTuple] - } - - "four types" in { - val typeSet = TypeIntersection[A & B & C & D] - assertType[typeSet.AsTuple, A *: B *: C *: D *: EmptyTuple] - } - - "nested intersections" in { - val typeSet = TypeIntersection[(A & B) & (C & D)] - assertType[typeSet.AsTuple, A *: B *: C *: D *: EmptyTuple] - } - - "with concrete type" in { - val typeSet = TypeIntersection[Concrete & A & B] - assertType[typeSet.AsTuple, Concrete *: A *: B *: EmptyTuple] - } - - "with generic types" in { - val typeSet = TypeIntersection[Generic[Int] & Generic[String]] - assertType[typeSet.AsTuple, Generic[Int] *: Generic[String] *: EmptyTuple] - } - - "with type parameters" in { - val typeSet = TypeIntersection[MultiParam[Int, String] & A] - assertType[typeSet.AsTuple, MultiParam[Int, String] *: A *: EmptyTuple] - } - - "with recursive bounds" in { - val typeSet = TypeIntersection[Recursive[A] & A] - assertType[typeSet.AsTuple, Recursive[A] *: A *: EmptyTuple] - } - - "with Nothing type" in { - val typeSet = TypeIntersection[A & Nothing] - assertType[typeSet.AsTuple, Nothing *: EmptyTuple] - } - } - - "Map operation" - { - "over single type" in { - class F[T] - val typeSet = TypeIntersection[A] - assertType[typeSet.Map[F], F[A]] - } - - "over intersection" in { - class F[T] - val typeSet = TypeIntersection[A & B] - assertType[typeSet.Map[F], F[A] & F[B]] - } - - "over nested intersection" in { - class F[T] - val typeSet = TypeIntersection[(A & B) & (C & D)] - assertType[typeSet.Map[F], F[A] & F[B] & F[C] & F[D]] - } - - "over generic types" in { - class F[T] - val typeSet = TypeIntersection[Generic[Int] & Generic[String]] - assertType[typeSet.Map[F], F[Generic[Int]] & F[Generic[String]]] - } - - "with multiple type parameters" in { - class F[T] - val typeSet = TypeIntersection[MultiParam[Int, String]] - assertType[typeSet.Map[F], F[MultiParam[Int, String]]] - } - } - - "type relationships" - { - "inheritance" in { - val typeSet = TypeIntersection[Child] - type Result = typeSet.AsTuple - assertType[Result, Child *: EmptyTuple] - } - - "multiple inheritance" in { - trait MA extends A with B - val typeSet = TypeIntersection[MA] - assertType[typeSet.AsTuple, MA *: EmptyTuple] - } - - "deep inheritance" in { - trait Deep extends A with B - class Deeper extends Deep with C - val typeSet = TypeIntersection[Deeper] - assertType[typeSet.AsTuple, Deeper *: EmptyTuple] - } - - "generic inheritance" in { - trait GenericChild[T] extends Generic[T] - val typeSet = TypeIntersection[GenericChild[Int]] - assertType[typeSet.AsTuple, GenericChild[Int] *: EmptyTuple] - } - - "mixed generic and concrete inheritance" in { - trait MixedInheritance extends Generic[Int] with A - val typeSet = TypeIntersection[MixedInheritance] - assertType[typeSet.AsTuple, MixedInheritance *: EmptyTuple] - } - - "self-recursive type" in { - trait SelfRef extends Base: - self: A => - val typeSet = TypeIntersection[SelfRef & A] - assertType[typeSet.AsTuple, SelfRef *: A *: EmptyTuple] - } - } - - "summonAll" - { - trait TC[A]: - def name: String - - given TC[A] with - def name = "A" - given TC[B] with - def name = "B" - given TC[C] with - def name = "C" - - "collects type class instances" in { - val instances = TypeIntersection.summonAll[A & B & C, TC] - assert(instances.map(_.unwrap.name) == List("A", "B", "C")) - } - - "preserves order" in { - val instances = TypeIntersection.summonAll[C & A & B, TC] - assert(instances.map(_.unwrap.name) == List("C", "A", "B")) - } - - "maintains priority of overlapping instances" in { - given TC[A & B] with - def name = "AB" - - val instances = TypeIntersection.summonAll[A & B, TC] - assert(instances.map(_.unwrap.name) == List("A", "B")) - } - - "large intersection" in { - enum Large: - case v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, - v11, v12, v13, v14, v15, v16, v17, v18, v19, v20, - v21, v22, v23, v24, v25, v26, v27, v28, v29, v30, - v31, v32, v33, v34, v35, v36, v37, v38, v39, v40 - end Large - import Large.* - - given [V]: TC[V] with - def name = "test" - - type Values = v1.type & v2.type & v3.type & v4.type & v5.type & v6.type & v7.type & v8.type & v9.type & v10.type & - v11.type & v12.type & v13.type & v14.type & v15.type & v16.type & v17.type & v18.type & v19.type & v20.type & - v21.type & v22.type & v23.type & v24.type & v25.type & v26.type & v27.type & v28.type & v29.type & v30.type & - v31.type & v32.type & v33.type & v34.type & v35.type & v36.type & v37.type & v38.type & v39.type & v40.type - - typeCheck("TypeIntersection.summonAll[Values, TC]") - } - } - - inline def assertType[A, B](using ev: A =:= B): Assertion = assertionSuccess -end TypeIntersectionTest diff --git a/kyo-stm/shared/src/main/scala/kyo/TTable.scala b/kyo-stm/shared/src/main/scala/kyo/TTable.scala index b0e3b0d21..7c3851eb8 100644 --- a/kyo-stm/shared/src/main/scala/kyo/TTable.scala +++ b/kyo-stm/shared/src/main/scala/kyo/TTable.scala @@ -1,7 +1,5 @@ package kyo -import Record.AsFields -import Record.Field import scala.annotation.implicitNotFound /** A transactional table implementation that provides atomic operations on structured records within STM transactions. Tables support CRUD @@ -17,10 +15,10 @@ import scala.annotation.implicitNotFound * - Indexes (if configured) are automatically maintained in sync with record changes * - Concurrent access is safely handled through STM transactions * - * @tparam Fields + * @tparam F * The record structure defined as a type-level list of field definitions (e.g. "name" ~ String & "age" ~ Int) */ -sealed abstract class TTable[Fields]: +sealed abstract class TTable[F]: /** The type of record IDs for this table. Represented as an opaque Int subtype to provide type safety. */ type Id <: Int @@ -41,7 +39,7 @@ sealed abstract class TTable[Fields]: * @return * The record if found, None otherwise, within the STM effect */ - def get(id: Id)(using Frame): Maybe[Record[Fields]] < STM + def get(id: Id)(using Frame): Maybe[Record[F]] < STM /** Inserts a new record into the table. * @@ -50,7 +48,7 @@ sealed abstract class TTable[Fields]: * @return * The ID assigned to the new record, within the STM effect */ - def insert(record: Record[Fields])(using Frame): Id < STM + def insert(record: Record[F])(using Frame): Id < STM /** Updates an existing record in the table. * @@ -61,7 +59,7 @@ sealed abstract class TTable[Fields]: * @return * The previous record if it existed, None otherwise, within the STM effect */ - def update(id: Id, record: Record[Fields])(using Frame): Maybe[Record[Fields]] < STM + def update(id: Id, record: Record[F])(using Frame): Maybe[Record[F]] < STM /** Updates an existing record or inserts a new one if it doesn't exist. * @@ -70,7 +68,7 @@ sealed abstract class TTable[Fields]: * @param record * The record data */ - def upsert(id: Id, record: Record[Fields])(using Frame): Unit < STM + def upsert(id: Id, record: Record[F])(using Frame): Unit < STM /** Removes a record from the table by its ID. * @@ -79,7 +77,7 @@ sealed abstract class TTable[Fields]: * @return * The removed record if it existed, Absent otherwise, within the STM effect */ - def remove(id: Id)(using Frame): Maybe[Record[Fields]] < STM + def remove(id: Id)(using Frame): Maybe[Record[F]] < STM /** Returns the current number of records in the table. * @@ -100,7 +98,7 @@ sealed abstract class TTable[Fields]: * @return * A map of all record IDs to their corresponding records, within the STM effect */ - def snapshot(using Frame): Map[Id, Record[Fields]] < STM + def snapshot(using Frame): Map[Id, Record[F]] < STM end TTable @@ -108,42 +106,42 @@ object TTable: /** Initializes a new basic table without indexing. * - * @tparam Fields + * @tparam F * The record structure for the table * @return * A new TTable instance within the Sync effect */ - def init[Fields: AsFields](using Frame): TTable[Fields] < Sync = + def init[F: Fields](using Frame): TTable[F] < Sync = for nextId <- TRef.init(0) - store <- TMap.init[Int, Record[Fields]] + store <- TMap.init[Int, Record[F]] yield new Base(nextId, store) - final private class Base[Fields]( + final private class Base[F]( private val nextId: TRef[Int], - private val store: TMap[Int, Record[Fields]] - ) extends TTable[Fields]: + private val store: TMap[Int, Record[F]] + ) extends TTable[F]: opaque type Id <: Int = Int def unsafeId(id: Int) = id def get(id: Id)(using Frame) = store.get(id) - def insert(record: Record[Fields])(using Frame) = + def insert(record: Record[F])(using Frame) = for id <- nextId.get _ <- nextId.set(id + 1) _ <- store.put(id, record) yield id - def update(id: Id, record: Record[Fields])(using Frame) = + def update(id: Id, record: Record[F])(using Frame) = store.get(id).map { case Absent => Absent case Present(prev) => store.put(id, record).andThen(Maybe(prev)) } - def upsert(id: Id, record: Record[Fields])(using Frame) = + def upsert(id: Id, record: Record[F])(using Frame) = store.put(id, record) def remove(id: Id)(using Frame) = store.remove(id) @@ -154,15 +152,15 @@ object TTable: /** An indexed table implementation that maintains secondary indexes for efficient querying. * - * @tparam Fields + * @tparam F * The record structure for the table * @tparam Indexes * The subset of fields that should be indexed */ - final class Indexed[Fields, Indexes >: Fields: AsFields] private ( - val store: TTable[Fields], - indexes: Map[Field[?, ?], TMap[Any, Set[Int]]] - ) extends TTable[Fields]: + final class Indexed[F, Indexes >: F: Fields] private ( + val store: TTable[F], + indexes: Map[String, TMap[Any, Set[Int]]] + ) extends TTable[F]: type Id = store.Id @@ -170,13 +168,13 @@ object TTable: def get(id: Id)(using Frame) = store.get(id) - def insert(record: Record[Fields])(using Frame) = + def insert(record: Record[F])(using Frame) = for id <- store.insert(record) _ <- updateIndexes(id, record) yield id - def update(id: Id, record: Record[Fields])(using Frame) = + def update(id: Id, record: Record[F])(using Frame) = for prev <- store.update(id, record) _ <- @@ -187,7 +185,7 @@ object TTable: Kyo.unit yield prev - def upsert(id: Id, record: Record[Fields])(using Frame) = + def upsert(id: Id, record: Record[F])(using Frame) = store.upsert(id, record).andThen(updateIndexes(id, record)) def remove(id: Id)(using Frame) = @@ -201,15 +199,15 @@ object TTable: def isEmpty(using Frame) = store.isEmpty def snapshot(using Frame) = store.snapshot - def indexFields: Set[Field[?, ?]] = indexes.keySet + def indexFields: Set[String] = indexes.keySet private inline val indexMismatch = """ Cannot query on fields that are not indexed. The filter contains fields that are not part of the table's index configuration. - + Filter fields: ${A} Indexed fields: ${Indexes} - + Make sure all fields in the filter are included in the table's index definition. """ @@ -225,12 +223,14 @@ object TTable: @implicitNotFound(indexMismatch) ev: Indexes <:< A, frame: Frame ): Chunk[Id] < STM = - Kyo.foreach(filter.toMap.toSeq) { (field, value) => - indexes(field).getOrElse(value, Set.empty) + val pairs = filter.toDict.foldLeft(List.empty[(String, Any)])((acc, k, v) => (k, v) :: acc) + Kyo.foreach(pairs) { (name, value) => + indexes(name).getOrElse(value, Set.empty) }.map { r => if r.isEmpty then Chunk.empty else Chunk.from(r.reduce(_ intersect _).toSeq.sorted.map(unsafeId(_))) } + end queryIds /** Queries the table for records matching the given filter criteria using indexed fields. * @@ -239,11 +239,11 @@ object TTable: * @return * A chunk containing the matching records, within the STM effect */ - def query[A: AsFields](filter: Record[A])( + def query[A: Fields](filter: Record[A])( using @implicitNotFound(indexMismatch) ev: Indexes <:< A, frame: Frame - ): Chunk[Record[Fields]] < STM = + ): Chunk[Record[F]] < STM = queryIds(filter).map { ids => Kyo.foreach(ids) { id => store.get(id).map { @@ -254,20 +254,20 @@ object TTable: } end query - private def updateIndexes(id: Id, record: Record[Fields])(using Frame): Unit < STM = - val map = record.toMap - Kyo.foreachDiscard(indexes.toSeq) { case (field, idx) => - idx.updateWith(map(field)) { + private def updateIndexes(id: Id, record: Record[F])(using Frame): Unit < STM = + val dict = record.toDict + Kyo.foreachDiscard(indexes.toSeq) { case (name, idx) => + idx.updateWith(dict(name)) { case Absent => Maybe(Set(id)) case Present(c) => Maybe(c + id) } } end updateIndexes - private def removeFromIndexes(id: Id, record: Record[Fields])(using Frame): Unit < STM = - val map = record.toMap - Kyo.foreachDiscard(indexes.toSeq) { case (field, idx) => - idx.updateWith(map(field)) { + private def removeFromIndexes(id: Id, record: Record[F])(using Frame): Unit < STM = + val dict = record.toDict + Kyo.foreachDiscard(indexes.toSeq) { case (name, idx) => + idx.updateWith(dict(name)) { case Absent => Absent case Present(c) => Maybe(c - id) } @@ -279,20 +279,20 @@ object TTable: /** Initializes a new indexed table. * - * @tparam Fields + * @tparam F * The record structure for the table * @tparam Indexes * The subset of fields that should be indexed * @return * A new Indexed table instance within the Sync effect */ - def init[Fields: AsFields as fields, Indexes >: Fields: AsFields as indexFields](using Frame): Indexed[Fields, Indexes] < Sync = + def init[F: Fields as fields, Indexes >: F: Fields as indexFields](using Frame): Indexed[F, Indexes] < Sync = for - table <- TTable.init[Fields] + table <- TTable.init[F] indexes <- - Kyo.foreach(indexFields.toSeq) { field => - TMap.init[Any, Set[Int]].map(field -> _) + Kyo.foreach(indexFields.fields) { field => + TMap.init[Any, Set[Int]].map(field.name -> _) } - yield new Indexed(table, indexes.toMap)(using fields) + yield new Indexed(table, indexes.toMap)(using indexFields) end Indexed end TTable diff --git a/kyo-stm/shared/src/test/scala/kyo/TTableTest.scala b/kyo-stm/shared/src/test/scala/kyo/TTableTest.scala index 1a9555f16..d92a30d69 100644 --- a/kyo-stm/shared/src/test/scala/kyo/TTableTest.scala +++ b/kyo-stm/shared/src/test/scala/kyo/TTableTest.scala @@ -252,9 +252,9 @@ class TTableTest extends Test: indexFields = table.indexFields yield assert(indexFields.size == 2) - assert(indexFields.exists(_.name == "name")) - assert(indexFields.exists(_.name == "age")) - assert(!indexFields.exists(_.name == "email")) + assert(indexFields.exists(_ == "name")) + assert(indexFields.exists(_ == "age")) + assert(!indexFields.exists(_ == "email")) } "should handle single index field" in run { @@ -263,9 +263,9 @@ class TTableTest extends Test: indexFields = table.indexFields yield assert(indexFields.size == 1) - assert(indexFields.exists(_.name == "age")) - assert(!indexFields.exists(_.name == "name")) - assert(!indexFields.exists(_.name == "email")) + assert(indexFields.exists(_ == "age")) + assert(!indexFields.exists(_ == "name")) + assert(!indexFields.exists(_ == "email")) } } }