diff --git a/arrow-libs/optics/arrow-optics-reflect/api/arrow-optics-reflect.api b/arrow-libs/optics/arrow-optics-reflect/api/arrow-optics-reflect.api new file mode 100644 index 00000000000..d5a82f85bcd --- /dev/null +++ b/arrow-libs/optics/arrow-optics-reflect/api/arrow-optics-reflect.api @@ -0,0 +1,10 @@ +public final class arrow/optics/ReflectionKt { + public static final fun getEvery (Lkotlin/reflect/KProperty1;)Larrow/optics/PEvery; + public static final fun getIter (Lkotlin/jvm/functions/Function1;)Larrow/optics/Fold; + public static final fun getLens (Lkotlin/reflect/KProperty1;)Larrow/optics/PLens; + public static final fun getOgetter (Lkotlin/jvm/functions/Function1;)Larrow/optics/Getter; + public static final fun getOptional (Lkotlin/reflect/KProperty1;)Larrow/optics/POptional; + public static final fun getValues (Lkotlin/reflect/KProperty1;)Larrow/optics/PEvery; + public static final fun instance (Lkotlin/reflect/KClass;)Larrow/optics/PPrism; +} + diff --git a/arrow-libs/optics/arrow-optics-reflect/build.gradle.kts b/arrow-libs/optics/arrow-optics-reflect/build.gradle.kts new file mode 100644 index 00000000000..3124a5f720e --- /dev/null +++ b/arrow-libs/optics/arrow-optics-reflect/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id(libs.plugins.kotlin.multiplatform.get().pluginId) + alias(libs.plugins.arrowGradleConfig.kotlin) + alias(libs.plugins.arrowGradleConfig.publish) +} + +apply(plugin = "io.kotest.multiplatform") + +apply(from = property("TEST_COVERAGE")) +apply(from = property("ANIMALSNIFFER_MPP")) + +kotlin { + sourceSets { + jvmMain { + dependencies { + api(projects.arrowCore) + api(projects.arrowOptics) + implementation(libs.kotlin.stdlibJDK8) + api(libs.kotlin.reflect) + } + } + jvmTest { + dependencies { + implementation(projects.arrowOpticsTest) + implementation(libs.kotlin.stdlibJDK8) + implementation(libs.junitJupiterEngine) + implementation(libs.kotlin.reflect) + } + } + } +} diff --git a/arrow-libs/optics/arrow-optics-reflect/gradle.properties b/arrow-libs/optics/arrow-optics-reflect/gradle.properties new file mode 100644 index 00000000000..73a39de367f --- /dev/null +++ b/arrow-libs/optics/arrow-optics-reflect/gradle.properties @@ -0,0 +1,4 @@ +# Maven publishing configuration +pom.name=Arrow Optics for Kotlin Reflection +# Build configuration +kapt.incremental.apt=false diff --git a/arrow-libs/optics/arrow-optics-reflect/src/jvmMain/kotlin/arrow/optics/Reflection.kt b/arrow-libs/optics/arrow-optics-reflect/src/jvmMain/kotlin/arrow/optics/Reflection.kt new file mode 100644 index 00000000000..4e34bc43880 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-reflect/src/jvmMain/kotlin/arrow/optics/Reflection.kt @@ -0,0 +1,64 @@ +package arrow.optics + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import kotlin.reflect.* +import kotlin.reflect.full.instanceParameter +import kotlin.reflect.full.memberFunctions + +/** Focuses on those elements of the specified [klass] */ +public fun instance(klass: KClass): Prism = + object: Prism { + override fun getOrModify(source: S): Either = + klass.safeCast(source)?.right() ?: source.left() + override fun reverseGet(focus: A): S = focus + } + +/** Focuses on those elements of the specified class */ +public inline fun instance(): Prism = + object: Prism { + override fun getOrModify(source: S): Either = + (source as? A)?.right() ?: source.left() + override fun reverseGet(focus: A): S = focus + } + +/** Focuses on a given field */ +public val ((S) -> A).ogetter: Getter + get() = Getter { s -> this(s) } + +/** + * [Lens] that focuses on a field in a data class + * + * WARNING: this should only be called on data classes, + * but that is checked only at runtime! + */ +public val KProperty1.lens: Lens + get() = PLens( + get = this, + set = { s, a -> clone(this, s, a) } + ) + +/** [Optional] that focuses on a nullable field */ +public val KProperty1.optional: Optional + get() = lens compose Optional.nullable() + +public val ((S) -> Iterable).iter: Fold + get() = ogetter compose Fold.iterable() + +public val KProperty1>.every: Every + get() = lens compose Every.list() + +public val KProperty1>.values: Every + get() = lens compose Every.map() + +private fun clone(prop: KProperty1, value: S, newField: A): S { + // based on https://stackoverflow.com/questions/49511098/call-data-class-copy-via-reflection + val klass = prop.instanceParameter?.type?.classifier as? KClass<*> + val copy = klass?.memberFunctions?.firstOrNull { it.name == "copy" } + if (klass == null || !klass.isData || copy == null) { + throw IllegalArgumentException("may only be used with data classes") + } + val fieldParam = copy.parameters.first { it.name == prop.name } + return copy.callBy(mapOf(copy.instanceParameter!! to value, fieldParam to newField)) as S +} diff --git a/arrow-libs/optics/arrow-optics-reflect/src/jvmTest/kotlin/arrow/optics/ReflectionTest.kt b/arrow-libs/optics/arrow-optics-reflect/src/jvmTest/kotlin/arrow/optics/ReflectionTest.kt new file mode 100644 index 00000000000..b4c32470df9 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-reflect/src/jvmTest/kotlin/arrow/optics/ReflectionTest.kt @@ -0,0 +1,55 @@ +package arrow.optics + +import arrow.core.test.UnitSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.string + +data class Person(val name: String, val friends: List) + +sealed interface Cutlery +object Fork: Cutlery +object Spoon: Cutlery + +object ReflectionTest: UnitSpec() { + init { + "optional for function" { + checkAll(Arb.list(Arb.int())) { ints -> + val firsty = { it: List -> it.firstOrNull() } + firsty.ogetter.get(ints) shouldBe ints.firstOrNull() + } + } + + "lenses for field, get" { + checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> + val p = Person(nm, fs.toMutableList()) + Person::name.lens.get(p) shouldBe nm + } + } + + "lenses for field, set" { + checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> + val p = Person(nm, fs.toMutableList()) + val m = Person::name.lens.modify(p) { it.capitalize() } + m shouldBe Person(nm.capitalize(), fs) + } + } + + "traversal for list, set" { + checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> + val p = Person(nm, fs) + val m = Person::friends.every.modify(p) { it.capitalize() } + m shouldBe Person(nm, fs.map { it.capitalize() }) + } + } + + "instances" { + val things = listOf(Fork, Spoon, Fork) + val forks = Every.list() compose instance() + val spoons = Every.list() compose instance() + forks.size(things) shouldBe 2 + spoons.size(things) shouldBe 1 + } + } +} diff --git a/arrow-libs/optics/arrow-optics/api/arrow-optics.api b/arrow-libs/optics/arrow-optics/api/arrow-optics.api index 66b1f0b1053..d8ef229e8fe 100644 --- a/arrow-libs/optics/arrow-optics/api/arrow-optics.api +++ b/arrow-libs/optics/arrow-optics/api/arrow-optics.api @@ -14,6 +14,7 @@ public abstract interface class arrow/optics/Fold { public abstract fun getAll (Ljava/lang/Object;)Ljava/util/List; public abstract fun isEmpty (Ljava/lang/Object;)Z public abstract fun isNotEmpty (Ljava/lang/Object;)Z + public static fun iterable ()Larrow/optics/Fold; public abstract fun lastOrNull (Ljava/lang/Object;)Ljava/lang/Object; public abstract fun left ()Larrow/optics/Fold; public static fun list ()Larrow/optics/Fold; @@ -40,6 +41,7 @@ public final class arrow/optics/Fold$Companion { public final fun codiagonal ()Larrow/optics/Fold; public final fun either ()Larrow/optics/Fold; public final fun id ()Larrow/optics/Fold; + public final fun iterable ()Larrow/optics/Fold; public final fun list ()Larrow/optics/Fold; public final fun map ()Larrow/optics/Fold; public final fun nonEmptyList ()Larrow/optics/Fold; @@ -464,6 +466,7 @@ public abstract interface class arrow/optics/POptional : arrow/optics/PEvery, ar public static fun listTail ()Larrow/optics/POptional; public abstract fun modify (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public abstract fun modifyNullable (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static fun nullable ()Larrow/optics/POptional; public abstract fun plus (Larrow/optics/POptional;)Larrow/optics/POptional; public abstract fun second ()Larrow/optics/POptional; public abstract fun set (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; @@ -476,6 +479,7 @@ public final class arrow/optics/POptional$Companion { public final fun invoke (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Larrow/optics/POptional; public final fun listHead ()Larrow/optics/POptional; public final fun listTail ()Larrow/optics/POptional; + public final fun nullable ()Larrow/optics/POptional; public final fun void ()Larrow/optics/POptional; } diff --git a/arrow-libs/optics/arrow-optics/build.gradle.kts b/arrow-libs/optics/arrow-optics/build.gradle.kts index f8e36cfa7bc..734dc90557c 100644 --- a/arrow-libs/optics/arrow-optics/build.gradle.kts +++ b/arrow-libs/optics/arrow-optics/build.gradle.kts @@ -26,12 +26,14 @@ kotlin { jvmMain { dependencies { implementation(libs.kotlin.stdlibJDK8) + api(libs.kotlin.reflect) } } jvmTest { dependencies { implementation(libs.kotlin.stdlibJDK8) implementation(libs.junitJupiterEngine) + implementation(libs.kotlin.reflect) } } jsMain { diff --git a/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Fold.kt b/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Fold.kt index 6c1d332e722..3b63a914094 100644 --- a/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Fold.kt +++ b/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Fold.kt @@ -10,6 +10,7 @@ import arrow.core.Tuple6 import arrow.core.Tuple7 import arrow.core.Tuple8 import arrow.core.Tuple9 +import arrow.core.foldMap import arrow.core.identity import arrow.typeclasses.Monoid import kotlin.jvm.JvmStatic @@ -199,6 +200,13 @@ public interface Fold { public fun void(): Fold = POptional.void() + @JvmStatic + public fun iterable(): Fold, A> = + object : Fold, A> { + override fun foldMap(M: Monoid, source: Iterable, map: (focus: A) -> R): R = + source.foldMap(M, map) + } + /** * [Traversal] for [List] that focuses in each [A] of the source [List]. */ diff --git a/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Optional.kt b/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Optional.kt index a2c8be3e57d..556cef13ab3 100644 --- a/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Optional.kt +++ b/arrow-libs/optics/arrow-optics/src/commonMain/kotlin/arrow/optics/Optional.kt @@ -7,6 +7,7 @@ import arrow.core.Some import arrow.core.flatMap import arrow.core.identity import arrow.core.prependTo +import arrow.core.toOption import arrow.typeclasses.Monoid import kotlin.jvm.JvmStatic @@ -198,5 +199,11 @@ public interface POptional : PSetter, POptionalGetter if (list.isNotEmpty()) list[0] prependTo newTail else emptyList() } ) + + @JvmStatic + public fun nullable(): Optional = Optional( + getOption = { it.toOption() }, + set = { source, new -> source?.let { new } } + ) } } diff --git a/arrow-site/docs/_data/sidebar-optics.yml b/arrow-site/docs/_data/sidebar-optics.yml index a7a0b0d2b44..2d8f6ee46da 100644 --- a/arrow-site/docs/_data/sidebar-optics.yml +++ b/arrow-site/docs/_data/sidebar-optics.yml @@ -27,6 +27,9 @@ options: - title: Setter url: /optics/setter/ + - title: Reflection + url: /optics/reflection/ + - title: Collections DSL nested_options: diff --git a/arrow-site/docs/docs/optics/README.md b/arrow-site/docs/docs/optics/README.md index e5ff17fec3f..17e9fabc170 100644 --- a/arrow-site/docs/docs/optics/README.md +++ b/arrow-site/docs/docs/optics/README.md @@ -72,6 +72,8 @@ Scroll down and learn what Arrow Optics can do for you(r code)! - [Getter]({{ '/optics/getter/' | relative_url }}): focus on one value - [OptionalGetter]({{ '/optics/optional_getter/' | relative_url }}): focus on optional value - [Setter]({{ '/optics/setter/' | relative_url }}): modify one value + +[Usage with reflection]({{ '/optics/reflection/' | relative_url }}) diff --git a/arrow-site/docs/docs/optics/reflection/README.md b/arrow-site/docs/docs/optics/reflection/README.md new file mode 100644 index 00000000000..091157cb4c3 --- /dev/null +++ b/arrow-site/docs/docs/optics/reflection/README.md @@ -0,0 +1,68 @@ +--- +layout: docs-optics +title: Index +permalink: /optics/reflection/ +--- + +## Usage with reflection + +Although we strongly recommend generating optics using the [DSL and `@optics` attribute]({{ '/optics/dsl/' | relative_url }}), sometimes this is not possible. For those scenarios we provide a small utility package `arrow-optics-reflect` which bridges Arrow Optics with [Kotlin's reflection](https://kotlinlang.org/docs/reflection.html) capabilities. + +Kotlin provides a simple way to obtain a reference to a member of a class, by using `ClassName::memberName`. For example, given the following class definition: + +```kotlin +data class Person(val name: String, val friends: List) +``` + +we can use `Person::name` and `Person::friends` to refer to each of the fields in the class. Those references are very similar to optics. + +In fact, what `arrow-optics-reflect` does is provide extension methods which turn those references into optics. You can obtain a lens for the `name` field in `Person` by writing: + +```kotlin +Person::name.lens +``` + +which you can later use as [any other lens]({{ '/optics/lens' | relative_url }}): + +```kotlin +val p = Person("me", listOf("pat", "mat")) +val m = Person::name.lens.modify(p) { it.capitalize() } +``` + +⚠️ **WARNING**: this only works on `data` classes with a public `copy` method (which is the default.) Remember that, as opposed to a mutable variable, optics will always create a _new_ copy when asking for modification. + +### Nullables and collections + +Sometimes it's preferable to expose a field using a different optic: + +- When the type of the field is nullable, you can use `optional` to obtain an [optional]({{ '/optics/optional' | relative_url }}) instead of a lens. +- When the type of the field is a collection, you can use `iter` to obtain _read-only_ access to it (technically, you obtain a [fold]({{ '/optics/fold' | relative_url }}).) If the type is a subclass of `List`, you can use `every` to get read/write access. + +```kotlin +val p = Person("me", listOf("pat", "mat")) +val m = Person::friends.every.modify(p) { it.capitalize() } +``` + +### Prisms + +A common pattern in Kotlin programming is to define a sealed abstract class (or interface) with subclasses representing choices in a union. + +```kotlin +sealed interface Cutlery +object Fork: Cutlery +object Spoon: Cutlery +``` + +We provide an `instance` method which creates a [prism]({{ '/optics/prism' | relative_url }}) which focus only on a certain subclass of a parent class. Both ends are important and must be provided when creating the optic: + +```kotlin +instance() +``` + +You can compose this optic freely with others. Here's an example in which we obtain the number of forks in a list of cutlery using optics: + +```kotlin +val things = listOf(Fork, Spoon, Fork) +val forks = Every.list() compose instance() +val noOfForks = forks.size(things) +``` \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b8706e30f1..e3d77c490da 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,9 @@ project(":arrow-fx-stm").projectDir = file("arrow-libs/fx/arrow-fx-stm") include("arrow-optics") project(":arrow-optics").projectDir = file("arrow-libs/optics/arrow-optics") +include("arrow-optics-reflect") +project(":arrow-optics-reflect").projectDir = file("arrow-libs/optics/arrow-optics-reflect") + include("arrow-optics-ksp-plugin") project(":arrow-optics-ksp-plugin").projectDir = file("arrow-libs/optics/arrow-optics-ksp-plugin")