cats-eo

Spark integration

The cats-eo-spark module turns the Spark SQL operations that are optics into eo optic constructions, so they compose with andThen and run through the usual .get / .modify / .replace / .getOption surface. The operations that are not optics (joins, grouping, explode) become consumers that read their keys through a capability.

The central idea: a Spark Column is a plain Scala value, an unevaluated expression. A lens whose source and focus are typed columns is an ordinary eo Lens. Its get builds a getField, its replace builds a withField, and Spark's optimizer compiles the composed expression. No row is ever decoded into a JVM object.

libraryDependencies ++= Seq(
  "dev.constructive" %% "cats-eo-spark" % "1.0",
  // Spark publishes Scala 2.13 artifacts only; Scala 3 consumes them like this:
  ("org.apache.spark" %% "spark-connect-client-jvm" % "4.2.0").cross(CrossVersion.for3Use2_13),
)

The module compiles against spark-sql-api alone (Provided). That is the API Spark Classic and Spark Connect share.

Runtime: Spark Connect

Spark Classic cannot run in a JVM on Scala 3.8+. Since 3.8 the Scala standard library is compiled by Scala 3, and Spark's Scala 2 runtime reflection (ScalaReflection, the reflective session lookup in SparkSession.builder()) can no longer start on it: class Array does not have a member apply. Use Spark Connect instead. The client runs in your Scala 3 JVM, and the engine runs in a Connect server, on Spark's own Scala 2.13 stdlib.

import org.apache.spark.sql.SparkSession

// NOT org.apache.spark.sql.SparkSession.builder(): its implementation lookup is reflective.
val spark: SparkSession =
  org.apache.spark.sql.connect.SparkSession.builder().remote("sc://localhost:15002").getOrCreate()

Two consequences:

import org.apache.spark.sql.catalyst.encoders.AgnosticEncoder
import org.apache.spark.sql.functions.{col, lit, struct, upper}
import dev.constructive.eo.*
import dev.constructive.eo.spark.*
import dev.constructive.eo.spark.SparkEncoders.given

case class Address(street: String, zip: Int)
case class Person(name: String, address: Address, work: Option[Address])

given AgnosticEncoder[Address] = SparkEncoders.derived
given AgnosticEncoder[Person] = SparkEncoders.derived

Typed columns and struct lenses

Col[A] is an opaque Column tagged with the Scala shape of its values. A struct field is a core Lens over Cols. A row is a struct column, so its edits are column expressions too:

val row: Col[Person] = Col(struct(col("name"), col("address"), col("work")))
// row: Col[Person] = struct(name, address, work)

val street = ColumnOptics.field[Person, Address]("address")
  .andThen(ColumnOptics.field[Address, String]("street"))
// street: GetReplaceLens[Col[Person], Col[Person], Col[String], Col[String]] = dev.constructive.eo.optics.GetReplaceLens@62fcfb57

street.get(row).column
// res0: Column = struct(name, address, work)['address']['street']
street.modify(s => Col(upper(s.column)))(row).column
// res1: Column = update_field(struct(name, address, work), address, update_field(struct(name, address, work)['address'], street, upper(struct(name, address, work)['address']['street'])))

The selector macro builds the same optic from a path, and also builds its eager twin, the ordinary optic over Person values. Spark runs the first and Scala runs the second. Because both come from one selector, they address the same field by construction:

val streetPath = ColumnOptics.path[Person](_.address.street)
// streetPath: Total[Person, String] = dev.constructive.eo.spark.ColumnPath$Total@79822ce7

streetPath.symbolic.modify(s => Col(upper(s.column)))(row).column
// res2: Column = update_field(struct(name, address, work), `address`.`street`, upper(struct(name, address, work)['address']['street']))
streetPath.eager.modify(_.toUpperCase)(Person("eo", Address("main st", 1), None))
// res3: Person = Person(
//   name = "eo",
//   address = Address(street = "MAIN ST", zip = 1),
//   work = None
// )

Partial foci: partiality lives in the complement

A symbolic optic runs when the query is built, before any row exists. A partial focus therefore has two kinds of miss, and core Optional holds both. There is no new carrier.

.get on an Option field marks a nullable step:

val workZip = ColumnOptics.path[Person](_.work.get.zip)
// workZip: Partial[Person, Int] = dev.constructive.eo.spark.ColumnPath$Partial@3150f700

workZip.symbolic.modify(z => Col(z.column + 1))(row).column
// res4: Column = CASE WHEN isNotNull(struct(name, address, work)['work']) THEN update_field(struct(name, address, work), `work`.`zip`, +(struct(name, address, work)['work']['zip'], 1)) ELSE struct(name, address, work) END

Catalyst folds that guard into the NULL check withField emits anyway. The optimized plan of a whole-Dataset edit is the projection you would write by hand:

Project [name#16, address#17, if (isnull(work#18)) null else named_struct(street, work#18.street, zip, (work#18.zip + 1)) AS work#31]
+- LocalRelation [name#16, address#17, work#18]

The other partial foci follow the same pattern:

Constructor Focus Writes Lawful
key(k) try_element_at transform_values on that entry yes
atKey(k) Col[Option[V]] create / update / delete for maps without NULL values
index(i) get positional transform yes
cast(to, back) try_cast cast back on canonical inputs ("007" writes back as "7")
json(schema) from_json + corrupt-record column to_json on canonical JSON
parseJson try_parse_json to_json on canonical JSON
variantGet(path, type) try_variant_get read-only (Getter) —

json carries a reserved corrupt-record column because plain PERMISSIVE from_json decodes malformed or non-object input to a struct of NULL fields, not to NULL. A naive presence test would then rewrite "not json" as {}.

Containers: writes are Modify, reads are aggregates

A symbolic array has no elements to hand out while the query is being built, so element-wise writes are a core Modify (transform, transform_values) and element-wise reads are Getters into one aggregate. An effectful modifyA cannot run inside a Spark expression at all.

case class Item(sku: String, qty: Int, price: Double)
case class Order(id: String, items: List[Item])

val orderRow: Col[Order] = Col(struct(col("id"), col("items")))
// orderRow: Col[Order] = struct(id, items)
val qty = ColumnOptics.field[Order, List[Item]]("items")
  .andThen(ColumnOptics.each[List, Item])
  .andThen(ColumnOptics.field[Item, Int]("qty"))
// qty: Optic[Col[Order], Col[Order], Col[Int], Col[Int], [X >: Nothing <: Any, A >: Nothing <: Any] =>> ModifyF[X, A]] = repl.MdocSession$MdocApp$$anon$11@54074694

qty.modify(q => Col(q.column * 2))(orderRow).column
// res5: Column = update_field(struct(id, items), items, transform(struct(id, items)['items'], x_1 -> update_field(x_1, qty, *(x_1['qty'], 2))))

Datasets: one projection, consumed through capabilities

DatasetOptics lifts any Col[S] optic onto a Dataset[S] as one projection, whose wrapping struct Catalyst removes. The non-optic operations take the optic as a capability, per eo's consume-via-capability rule:

import org.apache.spark.sql.{Dataset, SparkSession}
import org.apache.spark.sql.functions.count
import dev.constructive.eo.spark.DatasetOptics.*

val spark: SparkSession = ???
val people: Dataset[Person] = spark.createDataset(Seq.empty[Person])

people.editRows(streetPath.symbolic.modify(s => Col(upper(s.column))))   // DataFrame

given CanModify[Col[Person], Col[String]] = streetPath.symbolic
people.modifyAll[String](s => Col(upper(s.column)))                      // Dataset[Person]

given CanGetOption[Col[Person], Col[Int]] = workZip.symbolic
people.filterHits[Int]                                                   // rows with a work zip

joinOn, groupAggregate and explodeAll follow the same pattern. Each one reads its keys and values through CanGet.

The eager bridge

mapThrough runs an eager optic inside a typed Dataset.map. It is the escape hatch for edits a column expression cannot say. It leaves Catalyst, and the closure runs on the server, so the session needs:

Optics are Serializable. Define bridge functions where the server can load their class, e.g. in a top-level object.

import org.apache.spark.sql.{Dataset, SparkSession}
import dev.constructive.eo.spark.DatasetOptics.*

object Fns:
  val upperCase: String => String = _.toUpperCase

val spark: SparkSession = ???
ScalaRuntime.addTo(spark) // plus spark.addArtifact("my-app.jar")

given CanModify[Person, String] = streetPath.eager
spark.createDataset(Seq.empty[Person]).mapThrough[String](Fns.upperCase)

The cost, for one nested edit over a cached 200 000-row Dataset (local Connect server, JDK 25, sparkBench):

ms/op
hand-written withField 107.0 ± 5.4
ColumnOptics.path (symbolic) 106.4 ± 12.2
mapThrough (eager bridge) 187.6 ± 10.9
Dataset.map with a hand-written copy 188.7 ± 11.3
Dataset.map(identity) 174.2 ± 11.7

The bridge costs what any typed Dataset.map costs: every row is decoded to JVM objects and re-encoded. The optic itself adds nothing measurable.

Spark's own values: schemas, rows and variants

These kits need no session. Schemas covers the DataType tree. It is not named DataTypes, because Spark has a Java factory of that name in org.apache.spark.sql.types. Plated gives whole-schema rewrites:

import org.apache.spark.sql.types.*
import dev.constructive.eo.optics.Plated
import Schemas.platedDataType

val schema: DataType = StructType.fromDDL("id INT, address STRUCT<zip: INT, street: STRING>")
Schemas.field("address").andThen(Schemas.field("zip")).getOption(schema)
// res8: Option[DataType] = Some(IntegerType)
Plated.transform[DataType] { case IntegerType => LongType; case t => t }(schema).simpleString
// res9: String = "struct<id:bigint,address:struct<zip:bigint,street:string>>"

Rows gives typed field[A] / at[A] access into untyped Rows, keeping the schema on writes. Variants edits Spark 4 VARIANT values. The binary format has no in-place update, so writes rebuild the value, always in one canonical encoding. That makes VariantVal's byte equality structural:

val doc = Variants.obj(
  "name" -> Variants.string.reverseGet("eo"),
  "tags" -> Variants.arr(Variants.long.reverseGet(1L), Variants.long.reverseGet(2L)),
)
// doc: VariantVal = {"name":"eo","tags":[1,2]}

val tags = Variants.field("tags").andThen(Variants.each).andThen(Variants.long)
// tags: Optic[VariantVal, VariantVal, Long, Long, [X >: Nothing <: Any, A >: Nothing <: Any] =>> MultiFocusK[[B >: Nothing <: Any] =>> PSVec[B], X, A]] = repl.MdocSession$MdocApp$$anon$19@d0584ed
tags.modify(_ * 10)(doc).toJson(java.time.ZoneOffset.UTC)
// res10: String = "{\"name\":\"eo\",\"tags\":[10,20]}"

Which Spark operations are optics?

Spark operation eo construction Lawful?
getField / withField Lens (field, path) on non-NULL structs
nullable struct / Option field guarded Optional (nullableField, path(_.x.get…)) per row
map key, array index guarded Optional (key, atKey, index) yes (atKey: no NULL values)
try_cast, from_json, try_parse_json guarded Optional on canonical inputs
transform, transform_values Modify (each, values) yes
aggregate, size, exists, forall Getter Getter laws
try_variant_get Getter (read-only) —
select / withColumn over the whole row editRows / modifyAll as the optic
filter on a focus, join, groupBy().agg, explode capability consumers not optics
Dataset.map mapThrough (eager bridge) as the eager optic
DataType, Row, VariantVal values Schemas, Rows, Variants kits yes (law-checked)