Skip to content

Commit 1622498

Browse files
authored
[data] Improve Record and introduce Row (#1467)
In the new kyo-http module, I need to accumulate the definition of tuples for inputs and outputs when building a route. Libraries typically do that via regular tuples but, since we use Scala 3, we can use the new named tuples feature. Named tuples are awkward to handle at the type level, though. They take two type parameters, which complicates type signatures. This PR introduces `Row` with a single type parameter, additional features, and better integration with Kyo's primitives. There's some intersection with the scope of `Record` but they're fundamentally different so it makes sense to keep both.
1 parent c4e6d6a commit 1622498

File tree

4 files changed

+1224
-60
lines changed

4 files changed

+1224
-60
lines changed

kyo-data/shared/src/main/scala/kyo/Record.scala

Lines changed: 145 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ import scala.language.implicitConversions
1818
* combinations with static type checking, making them ideal for configuration, data transformation, and API integrations where the shape
1919
* of data needs to be flexible but still type-safe.
2020
*
21+
* =Record vs Row=
22+
* Record and [[Row]] both provide type-safe named fields, but represent different abstractions:
23+
*
24+
* '''Record''' is a '''labeled set''' — fields are unordered, a record with more fields can be used where fewer are expected (structural
25+
* subtyping), and the same name can appear with different types. Use Record when different consumers need different field subsets or when
26+
* composing fields from multiple sources.
27+
*
28+
* '''Row''' is a '''labeled sequence''' — fields have a fixed order, each name is unique, and the type must match exactly. Use Row when
29+
* field order matters, when you need positional operations (head/tail/take/drop), or when working with case classes.
30+
*
31+
* The two interconvert freely:
32+
* {{{
33+
* val record = "name" ~ "Alice" & "age" ~ 30
34+
* val row = Row.fromRecord(record) // Record -> Row (rejects duplicate field names)
35+
* val back = row.toRecord // Row -> Record
36+
* }}}
37+
*
2138
* =Creation=
2239
* Records can be created through direct field construction using the `~` operator and combined with `&`:
2340
* {{{
@@ -128,22 +145,9 @@ export Record.`~`
128145
object Record:
129146
/** Creates an empty Record
130147
*/
131-
val empty: Record[Any] = Record[Any](Map())
132-
133-
/** Returns the set of fields in this Record.
134-
*
135-
* @return
136-
* A Set of Field instances
137-
*/
138-
def fieldsOf[Fields](record: Record[Fields]): Set[Field[?, ?]] =
139-
record.toMap.keySet
140-
141-
/** Returns the number of fields in this Record.
142-
*/
143-
def sizeOf[Fields](record: Record[Fields]): Int =
144-
record.toMap.size
148+
val empty: Record[Any] = Record[Any](Map.empty)
145149

146-
private def unsafeFrom[Fields](map: Map[Field[?, ?], Any]): Record[Fields] = Record(map)
150+
private[kyo] def unsafeFrom[Fields](map: Map[Field[?, ?], Any]): Record[Fields] = Record(map)
147151

148152
inline def stage[Fields]: StageOps[Fields] = new StageOps[Fields](())
149153

@@ -172,13 +176,31 @@ object Record:
172176

173177
type MapValue[F[_]] = Map[[n, v] =>> F[v]]
174178

175-
type MapName[F[_]] = Map[[n, v] =>> F[n]]
176179
end `~`
177180

181+
type FieldsOf[Names <: Tuple, Values <: Tuple] = (Names, Values) match
182+
case (EmptyTuple, EmptyTuple) => Any
183+
case (n *: EmptyTuple, v *: EmptyTuple) => n ~ v
184+
case (n *: ns, v *: vs) => (n ~ v) & FieldsOf[ns, vs]
185+
186+
type ZipLookup[N <: String, T <: Tuple] = T match
187+
case (N ~ v) *: _ => v
188+
case _ *: rest => ZipLookup[N, rest]
189+
190+
type ZipFields[T1 <: Tuple, T2 <: Tuple] = T1 match
191+
case EmptyTuple => Any
192+
case (n ~ v1) *: EmptyTuple => n ~ (v1, ZipLookup[n, T2])
193+
case (n ~ v1) *: rest => (n ~ (v1, ZipLookup[n, T2])) & ZipFields[rest, T2]
194+
178195
/** Creates a Record from a product type (case class or tuple).
179196
*/
180197
def fromProduct[A](value: A)(using ar: AsRecord[A]): Record[ar.Fields] = ar.asRecord(value)
181198

199+
/** Creates a Record from a Row.
200+
*/
201+
inline def fromRow[A <: NamedTuple.AnyNamedTuple](row: Row[A]): Record[Row.ToRecordFields[Row.Names[A], Row.Values[A]]] =
202+
row.toRecord
203+
182204
/** A field in a Record, containing a name and associated type information.
183205
*
184206
* @param name
@@ -189,11 +211,54 @@ object Record:
189211
case class Field[Name <: String, Value](name: Name, tag: Tag[Value])
190212

191213
extension [Fields](self: Record[Fields])
192-
/** Creates a new Record containing only the fields specified in the type parameter Fields.
193-
*
194-
* @return
195-
* A new Record with only the specified fields
196-
*/
214+
215+
def size: Int = self.toMap.size
216+
217+
inline def fields(using ti: TypeIntersection[Fields]): List[String] =
218+
collectFieldNames[ti.AsTuple]
219+
220+
inline def values(using ti: TypeIntersection[Fields]): Row.FieldValues[ti.AsTuple] =
221+
collectValues[ti.AsTuple](self.toMap).asInstanceOf[Row.FieldValues[ti.AsTuple]]
222+
223+
def update[Name <: String & Singleton, Value](name: Name, value: Value)(using
224+
@implicitNotFound("""
225+
Invalid field update: ${Name}
226+
227+
Record[${Fields}]
228+
229+
Possible causes:
230+
1. The field does not exist in this Record
231+
2. The field exists but has a different type than expected
232+
""")
233+
ev: Fields <:< Name ~ Value,
234+
tag: Tag[Value]
235+
): Record[Fields] =
236+
Record.unsafeFrom(self.toMap.updated(Field(name, tag), value))
237+
238+
inline def map[F[_]](using
239+
ti: TypeIntersection[Fields]
240+
)(
241+
f: [t] => t => F[t]
242+
): Record[ti.Map[~.MapValue[F]]] =
243+
mapFields([t] => (_: Field[?, t], v: t) => f[t](v))
244+
245+
inline def mapFields[F[_]](using
246+
ti: TypeIntersection[Fields]
247+
)(
248+
f: [t] => (Field[?, t], t) => F[t]
249+
): Record[ti.Map[~.MapValue[F]]] =
250+
Record.unsafeFrom[ti.Map[~.MapValue[F]]](
251+
mapFieldsImpl[ti.AsTuple, F](self.toMap, f)
252+
)
253+
254+
inline def zip[Fields2](other: Record[Fields2])(using
255+
ti1: TypeIntersection[Fields],
256+
ti2: TypeIntersection[Fields2]
257+
): Record[ZipFields[ti1.AsTuple, ti2.AsTuple]] =
258+
Record.unsafeFrom[ZipFields[ti1.AsTuple, ti2.AsTuple]](
259+
zipImpl[ti1.AsTuple, ti2.AsTuple](self.toMap, other.toMap)
260+
)
261+
197262
def compact(using AsFields[Fields]): Record[Fields] =
198263
Record(self.toMap.view.filterKeys(AsFields[Fields].contains(_)).toMap)
199264
end extension
@@ -226,46 +291,37 @@ object Record:
226291

227292
object AsRecord:
228293

229-
type FieldsOf[Names <: Tuple, Values <: Tuple] = Names match
230-
case nHead *: EmptyTuple => Values match
231-
case vHead *: _ => (nHead ~ vHead)
232-
case nHead *: nTail => Values match
233-
case vHead *: vTail => (nHead ~ vHead) & FieldsOf[nTail, vTail]
234-
case _ => Any
235-
236294
type RMirror[A, Names <: Tuple, Values <: Tuple] = Mirror.ProductOf[A] {
237295
type MirroredElemLabels = Names
238296
type MirroredElemTypes = Values
239297
}
240298

241299
trait RecordContents[Names <: Tuple, Values <: Tuple]:
242-
def values(product: Tuple): List[(Field[?, ?], Any)]
300+
def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any]
243301

244302
object RecordContents:
245303
given empty: RecordContents[EmptyTuple, EmptyTuple] with
246-
def values(product: Tuple): List[(Field[?, ?], Any)] = Nil
304+
def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any] = map
247305

248306
given nonEmpty[NH <: (String & Singleton), NT <: Tuple, VH, VT <: Tuple](
249307
using
250308
tag: Tag[VH],
251309
vo: ValueOf[NH],
252310
next: RecordContents[NT, VT]
253311
): RecordContents[NH *: NT, VH *: VT] with
254-
def values(product: Tuple): List[(Field[?, ?], Any)] =
255-
(Field[NH, VH](vo.value, tag), product.head) +: next.values(product.tail)
312+
def addTo(product: Product, idx: Int, map: Map[Field[?, ?], Any]): Map[Field[?, ?], Any] =
313+
next.addTo(product, idx + 1, map.updated(Field[NH, VH](vo.value, tag), product.productElement(idx)))
256314
end nonEmpty
257315
end RecordContents
258316

259317
given [A <: Product, Names <: Tuple, Values <: Tuple](using
260318
mir: RMirror[A, Names, Values],
261319
rc: RecordContents[Names, Values]
262320
): AsRecord[A] with
263-
type Fields = FieldsOf[Names, Values]
321+
type Fields = Record.FieldsOf[Names, Values]
264322

265323
def asRecord(value: A): Record[Fields] =
266-
val record_contents = rc.values(Tuple.fromProduct(value))
267-
val map = Map(record_contents*)
268-
Record(map).asInstanceOf[Record[Fields]]
324+
Record(rc.addTo(value, 0, Map.empty)).asInstanceOf[Record[Fields]]
269325
end asRecord
270326
end given
271327
end AsRecord
@@ -318,20 +374,16 @@ object Record:
318374
inline def apply[T]: (String, Render[?]) =
319375
inline erasedValue[T] match
320376
case _: (n ~ v) =>
321-
val ev = summonInline[n <:< String]
322-
val inst = summonInline[Render[v]]
323-
ev(constValue[n]) -> inst
377+
constValue[n & String] -> summonInline[Render[v]]
324378
end RenderInliner
325379

326380
inline given [Fields: TypeIntersection]: Render[Record[Fields]] =
327381
val insts = TypeIntersection.inlineAll[Fields](RenderInliner).toMap
328382
Render.from: (value: Record[Fields]) =>
329-
value.toMap.foldLeft(Vector[String]()) { case (acc, (field, value)) =>
330-
insts.get(field.name) match
331-
case Some(r: Render[x]) =>
332-
acc :+ (field.name + " ~ " + r.asText(value.asInstanceOf[x]))
333-
case None => acc
334-
end match
383+
value.toMap.iterator.collect {
384+
case (field, v) if insts.contains(field.name) =>
385+
val r = insts(field.name).asInstanceOf[Render[Any]]
386+
field.name + " ~ " + r.asText(v)
335387
}.mkString(" & ")
336388
end given
337389

@@ -350,6 +402,54 @@ object Record:
350402
ForSome2(stage[n, v](Field(name, prevTag)))
351403
)
352404
end StageAs
405+
406+
private inline def collectFieldNames[T <: Tuple]: List[String] =
407+
inline erasedValue[T] match
408+
case _: EmptyTuple => Nil
409+
case _: ((n ~ v) *: rest) =>
410+
constValue[n & String] :: collectFieldNames[rest]
411+
case _: (_ *: rest) =>
412+
collectFieldNames[rest]
413+
414+
private inline def collectValues[T <: Tuple](
415+
map: Map[Field[?, ?], Any]
416+
): Tuple =
417+
inline erasedValue[T] match
418+
case _: EmptyTuple => EmptyTuple
419+
case _: ((n ~ v) *: rest) =>
420+
val name = constValue[n & String]
421+
val tag = summonInline[Tag[v]]
422+
map(Field(name, tag)) *: collectValues[rest](map)
423+
424+
private inline def mapFieldsImpl[T <: Tuple, F[_]](
425+
map: Map[Field[?, ?], Any],
426+
f: [t] => (Field[?, t], t) => F[t]
427+
): Map[Field[?, ?], Any] =
428+
inline erasedValue[T] match
429+
case _: EmptyTuple => Map.empty
430+
case _: ((n ~ v) *: rest) =>
431+
val name = constValue[n & String]
432+
val prevTag = summonInline[Tag[v]]
433+
val nextTag = summonInline[Tag[F[v]]]
434+
val field = Field(name, prevTag)
435+
val value = map(field).asInstanceOf[v]
436+
val result = f[v](field, value)
437+
mapFieldsImpl[rest, F](map, f).updated(Field(name, nextTag), result)
438+
439+
private inline def zipImpl[T1 <: Tuple, T2 <: Tuple](
440+
map1: Map[Field[?, ?], Any],
441+
map2: Map[Field[?, ?], Any]
442+
): Map[Field[?, ?], Any] =
443+
inline erasedValue[T1] match
444+
case _: EmptyTuple => Map.empty
445+
case _: ((n ~ v1) *: rest) =>
446+
val name = constValue[n & String]
447+
val tag1 = summonInline[Tag[v1]]
448+
val tag2 = summonInline[Tag[ZipLookup[n, T2]]]
449+
val pairTag = summonInline[Tag[(v1, ZipLookup[n, T2])]]
450+
val value1 = map1(Field(name, tag1))
451+
val value2 = map2(Field(name, tag2))
452+
zipImpl[rest, T2](map1, map2).updated(Field(name, pairTag), (value1, value2))
353453
end Record
354454

355455
object AsFieldsInternal:

0 commit comments

Comments
 (0)