Skip to content

Commit

Permalink
Delicate optics (#3454)
Browse files Browse the repository at this point in the history
  • Loading branch information
serras authored Jun 19, 2024
1 parent 432ffba commit 6b7fe97
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 15 deletions.
17 changes: 15 additions & 2 deletions arrow-libs/optics/arrow-optics/api/arrow-optics.api
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ public final class arrow/optics/CopyKt {
public static final fun copy (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
}

public final class arrow/optics/DelicateKt {
public static final fun filter (Larrow/optics/POptional$Companion;Lkotlin/jvm/functions/Function1;)Larrow/optics/POptional;
public static final fun fromLenses (Larrow/optics/PTraversal$Companion;Larrow/optics/PLens;[Larrow/optics/PLens;)Larrow/optics/PTraversal;
}

public abstract interface annotation class arrow/optics/DelicateOptic : java/lang/annotation/Annotation {
}

public final class arrow/optics/Every {
public static final field INSTANCE Larrow/optics/Every;
public static final fun either ()Larrow/optics/PTraversal;
Expand Down Expand Up @@ -467,6 +475,11 @@ public final class arrow/optics/dsl/AtKt {
public static final fun atSet (Larrow/optics/PTraversal;Ljava/lang/Object;)Larrow/optics/PTraversal;
}

public final class arrow/optics/dsl/DelicateKt {
public static final fun filter (Larrow/optics/POptional;Lkotlin/jvm/functions/Function1;)Larrow/optics/POptional;
public static final fun filter (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
}

public final class arrow/optics/dsl/EitherKt {
public static final fun getLeft (Larrow/optics/POptional;)Larrow/optics/POptional;
public static final fun getLeft (Larrow/optics/PPrism;)Larrow/optics/PPrism;
Expand All @@ -488,9 +501,9 @@ public final class arrow/optics/dsl/EveryKt {
}

public final class arrow/optics/dsl/FilterIndexKt {
public static final fun filter (Larrow/optics/PTraversal;Larrow/optics/typeclasses/FilterIndex;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filter (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterChars (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterIndex (Larrow/optics/PTraversal;Larrow/optics/typeclasses/FilterIndex;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterIndex (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterNonEmptyList (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterSequence (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
public static final fun filterValues (Larrow/optics/PTraversal;Lkotlin/jvm/functions/Function1;)Larrow/optics/PTraversal;
Expand Down
19 changes: 13 additions & 6 deletions arrow-libs/optics/arrow-optics/api/arrow-optics.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package arrow.optics

import arrow.core.None
import arrow.core.Some

@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message = "Delicate optic, please check the documentation for correct usage."
)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION)
public annotation class DelicateOptic

/**
* Focuses on the value only if the [predicate] is true.
* This optics The optic is perfectly OK when used to get
* values using `getOrNull` or `getAll`; but requires
* some caution using `modify` with it.
*
* ⚠️ Warning: when using `modify` with this optic,
* the transformation should not alter the values that are
* taken into account by the predicate. For example, it is
* fine to `filter` by `name` and then increase the `age`,
* but not to `filter` by `name` and then capitalize the `name`.
*
* In general terms, this optic does not satisfy the rule that
* applying two modifications in a row is equivalent to
* applying those two modifications at once. The following
* example shows that increasing by one twice is not equivalent
* to increasing by two.
*
* ```
* val p = Optional.filter<Int> { it % 2 == 0 } // focus on even numbers
* val n = 2 // an even number
*
* p.modify(p.modify(n) { it + 1 }) { it + 1 }
* // ---------------------- = 3
* // ---------------------------------------- = null
*
* p.modify(n) { it + 2 }
* // ------------------- = 4
* ```
*
* The reader interested in a (deep) discussion about why
* this rule is important may consult the blog post
* [_Finding (correct) lens laws_](https://oleg.fi/gists/posts/2018-12-12-find-correct-laws.html)
* by Oleg Genrus.
*/
@DelicateOptic
public fun <S> POptional.Companion.filter(
predicate: (S) -> Boolean
): Optional<S, S> = Optional(
getOption = { if (predicate(it)) Some(it) else None },
set = { s, x -> if (predicate(s)) x else s }
)

/**
* Focuses on a sequence of lenses.
*
* Warning: using a lens more than once in the list
* may give unexpected results upon modification.
*/
@DelicateOptic
public fun <S, A, B> PTraversal.Companion.fromLenses(
lens1: PLens<S, S, A, B>, vararg lenses: PLens<S, S, A, B>
): PTraversal<S, S, A, B> = object : PTraversal<S, S, A, B> {
override fun <R> foldMap(initial: R, combine: (R, R) -> R, source: S, map: (focus: A) -> R): R =
lenses.fold(map(lens1.get(source))) { current, lens -> combine(current, map(lens.get(source))) }
override fun modify(source: S, map: (focus: A) -> B): S =
lenses.fold(lens1.modify(source, map)) { current, lens -> lens.modify(current, map) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package arrow.optics.dsl

import arrow.optics.DelicateOptic
import arrow.optics.Optional
import arrow.optics.Traversal
import arrow.optics.filter

/**
* Focuses on those values for which the [predicate] is true.
*
* See [Optional.filter] for further description of the
* caution one should be with this optic.
*/
@DelicateOptic
public fun <S, A> Traversal<S, A>.filter(
predicate: (A) -> Boolean
): Traversal<S, A> = this compose Optional.filter(predicate)

/**
* Focuses on the value only if the [predicate] is true.
*
* See [Optional.filter] for further description of the
* caution one should be with this optic.
*/
@DelicateOptic
public fun <S, A> Optional<S, A>.filter(
predicate: (A) -> Boolean
): Optional<S, A> = this compose Optional.filter(predicate)
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@ import kotlin.jvm.JvmName
* @param i index [I] to focus into [S] and find focus [A]
* @return [Optional] with a focus in [A] at given index [I]
*/
public fun <T, S, I, A> Traversal<T, S>.filter(filter: FilterIndex<S, I, A>, predicate: Predicate<I>): Traversal<T, A> =
public fun <T, S, I, A> Traversal<T, S>.filterIndex(filter: FilterIndex<S, I, A>, predicate: Predicate<I>): Traversal<T, A> =
this.compose(filter.filter(predicate))

public fun <T, A> Traversal<T, List<A>>.filter(predicate: Predicate<Int>): Traversal<T, A> =
public fun <T, A> Traversal<T, List<A>>.filterIndex(predicate: Predicate<Int>): Traversal<T, A> =
this.compose(FilterIndex.list<A>().filter(predicate))

@JvmName("filterNonEmptyList")
public fun <T, A> Traversal<T, NonEmptyList<A>>.filter(predicate: Predicate<Int>): Traversal<T, A> =
public fun <T, A> Traversal<T, NonEmptyList<A>>.filterIndex(predicate: Predicate<Int>): Traversal<T, A> =
this.compose(FilterIndex.nonEmptyList<A>().filter(predicate))

@JvmName("filterSequence")
public fun <T, A> Traversal<T, Sequence<A>>.filter(predicate: Predicate<Int>): Traversal<T, A> =
public fun <T, A> Traversal<T, Sequence<A>>.filterIndex(predicate: Predicate<Int>): Traversal<T, A> =
this.compose(FilterIndex.sequence<A>().filter(predicate))

@JvmName("filterValues")
public fun <T, K, A> Traversal<T, Map<K, A>>.filter(predicate: Predicate<K>): Traversal<T, A> =
public fun <T, K, A> Traversal<T, Map<K, A>>.filterIndex(predicate: Predicate<K>): Traversal<T, A> =
this.compose(FilterIndex.map<K, A>().filter(predicate))

@JvmName("filterChars")
public fun <T> Traversal<T, String>.filter(predicate: Predicate<Int>): Traversal<T, Char> =
public fun <T> Traversal<T, String>.filterIndex(predicate: Predicate<Int>): Traversal<T, Char> =
this.compose(FilterIndex.string().filter(predicate))
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import arrow.core.Either.Right
import arrow.core.getOrElse
import arrow.core.identity
import arrow.core.toOption
import arrow.optics.dsl.filter
import arrow.optics.test.functionAToB
import arrow.optics.test.laws.OptionalLaws
import arrow.optics.test.laws.testLaws
Expand All @@ -19,6 +20,7 @@ import io.kotest.property.arbitrary.pair
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
import kotlinx.coroutines.test.runTest
import kotlin.test.Ignore
import kotlin.test.Test

class OptionalTest {
Expand Down Expand Up @@ -187,4 +189,61 @@ class OptionalTest {
joinedOptional.getOrNull(Left(listOf(int))) shouldBe joinedOptional.getOrNull(Right(int))
}
}

@OptIn(DelicateOptic::class) @Test
fun filterPrismTrue() = runTest {
checkAll(Arb.sumType()) { sum: SumType ->
Prism.sumType().filter { true }.getOrNull(sum) shouldBe (sum as? SumType.A)?.string
}
}

@OptIn(DelicateOptic::class) @Test
fun filterPrismFalse() = runTest {
checkAll(Arb.sumType()) { sum: SumType ->
Prism.sumType().filter { false }.getOrNull(sum) shouldBe null
}
}

@OptIn(DelicateOptic::class) @Test
fun filterListBasic() = runTest {
checkAll(Arb.list(Arb.int(), range = 0 .. 20)) { lst: List<Int> ->
Every.list<Int>().filter { true }.getAll(lst) shouldBe lst
Every.list<Int>().filter { false }.getAll(lst) shouldBe emptyList()
}
}

@OptIn(DelicateOptic::class) @Test
fun filterPredicate() = runTest {
checkAll(Arb.list(Arb.int())) { lst: List<Int> ->
Every.list<Int>().filter { it > 7 }.getAll(lst) shouldBe lst.filter { it > 7 }
}
}

@OptIn(DelicateOptic::class) @Test
fun filterModify() = runTest {
checkAll(Arb.list(Arb.pair(Arb.int(), Arb.string(maxSize = 10)))) { lst: List<Pair<Int, String>> ->
Every.list<Pair<Int, String>>()
.filter { (n, _) -> n % 2 == 0 }
.compose(Lens.pairSecond())
.modify(lst) { it.uppercase() }
.shouldBe(lst.map { (n, s) -> Pair(n, if (n % 2 == 0) s.uppercase() else s) })
}
}

@OptIn(DelicateOptic::class) @Test
fun filterSetGetFails() = runTest {
checkAll(Arb.int()) { n ->
val p = Optional.filter<Int> { it % 2 == 0 }
p.getOrNull(p.set(n, n)) shouldBe n.takeIf { it % 2 == 0 }
}
}

@OptIn(DelicateOptic::class) @Test @Ignore
// 'filter' does not satisfy the double-modification law
fun filterSetSetFails() = runTest {
checkAll(Arb.int(range = 0 .. 100)) { n ->
val p = Optional.filter<Int> { it % 2 == 0 }
p.modify(p.modify(n) { it + 1 }) { it + 1 } shouldBe p.modify(n) { it + 2 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,5 +205,4 @@ class PrismTest {
Prism.sumType().all(sum) { predicate } shouldBe (predicate || sum is SumType.B)
}
}

}

0 comments on commit 6b7fe97

Please sign in to comment.