Skip to content

Commit

Permalink
Add mapNullable function to Stores and Inspectors (#928)
Browse files Browse the repository at this point in the history
  • Loading branch information
haukesomm authored Jan 6, 2025
1 parent f89a5c1 commit 32dacf2
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 21 deletions.
1 change: 1 addition & 0 deletions core/api/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public final class dev/fritz2/core/InspectorKt {
public static final fun mapByIndex (Ldev/fritz2/core/Inspector;I)Ldev/fritz2/core/Inspector;
public static final fun mapByKey (Ldev/fritz2/core/Inspector;Ljava/lang/Object;)Ldev/fritz2/core/Inspector;
public static final fun mapNull (Ldev/fritz2/core/Inspector;Ljava/lang/Object;)Ldev/fritz2/core/Inspector;
public static final fun mapNullable (Ldev/fritz2/core/Inspector;Ljava/lang/Object;)Ldev/fritz2/core/Inspector;
}

public abstract interface class dev/fritz2/core/Lens {
Expand Down
22 changes: 18 additions & 4 deletions core/src/commonMain/kotlin/dev/fritz2/core/Inspector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,26 @@ class SubInspector<P, T>(
* Creates a new [Inspector] from a _nullable_ parent inspector that either contains the original value or a given
* [default] value if the original value was `null`.
*
* The resulting inspector behaves similarly to a `Store` created via `Store.mapNull`.
* This means that the resulting [Inspector.path] will be the same as if `mapNull`
* was called on an equivalent store of the same value.
* When updating the value of the resulting [Inspector] to this [default] value,
* null is used instead updating the parent. When this [Inspector]'s value would be null according to it's parent's
* value, the [default] value will be used instead.
*
* @param default value to be used instead of `null`
*/
fun <D> Inspector<D?>.mapNull(default: D): Inspector<D> =
SubInspector(this, defaultLens("", default))
SubInspector(this, mapToNonNullLens(default))

/**
* Creates a new [Inspector] from a _non-nullable_ parent inspector that either contains the original value or `null` if
* its value matches the given [placeholder].
*
* When updating the value of the resulting [Store] to `null`, the [placeholder] is used instead.
* When the resulting [Inspector]'s value would be the [placeholder], `null` will be used instead.
*
* @param placeholder value to be mapped to `null`
*/
fun <T> Inspector<T>.mapNullable(placeholder: T): Inspector<T?> =
map(mapToNullableLens(placeholder))

/**
* Creates a new [Inspector] containing the element for the given [element] and [idProvider]
Expand Down
27 changes: 23 additions & 4 deletions core/src/commonMain/kotlin/dev/fritz2/core/Lens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,33 @@ inline fun <P, reified C : P> lensForUpcasting(): Lens<P, C> = object : Lens<P,
}

/**
* Creates a lens from a nullable parent to a non-nullable value using a given default-value.
* Creates a [Lens] from a nullable parent to a non-nullable value using the provided [default] value.
*
* Use this method to apply a default value that will be used in the case that the real value is null.
* When setting that value to the default value it will accordingly translate to null.
*
* @param default value to be used instead of null
* The inverse Lens can be created using the [mapToNullableLens] factory.
*
* @param default value to be used instead of `null`
*/
internal fun <T> defaultLens(id: String, default: T): Lens<T?, T> = object : Lens<T?, T> {
override val id: String = id
internal fun <T> mapToNonNullLens(default: T): Lens<T?, T> = object : Lens<T?, T> {
override val id: String = ""
override fun get(parent: T?): T = parent ?: default
override fun set(parent: T?, value: T): T? = value.takeUnless { it == default }
}

/**
* Creates a [Lens] from a _non-nullable_ parent to a _nullable_ value, mapping the provided [placeholder] to `null`
* and vice versa.
*
* Use this method in cases where a nullable Store is needed but the data model used is actually non-nullable.
*
* The inverse Lens can be created using the [mapToNonNullLens] factory.
*
* @param placeholder value to be mapped to `null`
*/
internal fun <T> mapToNullableLens(placeholder: T): Lens<T, T?> = object : Lens<T, T?> {
override val id: String = ""
override fun get(parent: T): T? = parent.takeUnless { parent == placeholder }
override fun set(parent: T, value: T?): T = value ?: placeholder
}
48 changes: 46 additions & 2 deletions core/src/commonTest/kotlin/dev/fritz2/core/Lens.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package dev.fritz2.core

import kotlin.js.JsName
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull

class LensesTests {

Expand Down Expand Up @@ -64,7 +66,7 @@ class LensesTests {
fun testDefaultLens() {
val defaultValue = "fritz2"
val nonNullValue = "some value"
val defaultLens = defaultLens("", defaultValue)
val defaultLens = mapToNonNullLens(defaultValue)

assertEquals(defaultValue, defaultLens.get(null), "default value not applied on null")
assertEquals(nonNullValue, defaultLens.get(nonNullValue), "wrong value on not-null")
Expand All @@ -75,7 +77,7 @@ class LensesTests {
}

@Test
fun testNotNullLens() {
fun test_mapToNonNullLens() {
data class PostalAddress(val street: String, val co: String?)

val streetLens = lensOf("street", PostalAddress::street) { p, v -> p.copy(street = v) }
Expand Down Expand Up @@ -104,6 +106,48 @@ class LensesTests {
) { notNullLens.set(null, newValue)?.street }
}

@Test
fun mapToNullableLens_replaces_placeholder_with_null_when_getting() {
val placeholder = "Unknown"
val sut = mapToNullableLens(placeholder)

val result = sut.get(placeholder)

assertNull(result, "The placeholder should be returned as `null` when getting")
}

@Test
fun mapToNullableLens_returns_same_value_as_parent_when_parent_is_not_the_placeholder() {
val placeholder = "Unknown"
val sut = mapToNullableLens(placeholder)

val parent = "Some actual value"
val result = sut.get("Some actual value")

assertEquals(expected = parent, actual = result)
}

@Test
fun mapToNullableLens_sets_parent_to_placeholder_when_given_null() {
val placeholder = "Unknown"
val sut = mapToNullableLens(placeholder)

val result = sut.set("", null)

assertEquals(expected = placeholder, actual = result)
}

@Test
fun mapToNullableLens_set_parent_to_given_value_when_other_than_null() {
val placeholder = "Unknown"
val sut = mapToNullableLens(placeholder)

val newValue = "New value"
val result = sut.set("", newValue)

assertEquals(expected = newValue, actual = result)
}

sealed interface ConsultationModel {
val stockNumber: String

Expand Down
16 changes: 14 additions & 2 deletions core/src/jsMain/kotlin/dev/fritz2/core/SubStores.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,19 @@ fun <P, T> Store<P?>.map(lens: Lens<P & Any, T>): Store<T> =
* null is used instead updating the parent. When this [Store]'s value would be null according to it's parent's
* value, the [default] value will be used instead.
*
* @param default value to translate null to and from
* @param default value to be used instead of `null`
*/
fun <T> Store<T?>.mapNull(default: T): Store<T> =
map(defaultLens("", default))
map(mapToNonNullLens(default))

/**
* Creates a new [Store] from a _non-nullable_ parent store that either contains the original value or `null` if its
* value matches the given [placeholder].
*
* When updating the value of the resulting [Store] to `null`, the [placeholder] is used instead.
* When the resulting [Store]'s value would be the [placeholder], `null` will be used instead.
*
* @param placeholder value to be mapped to `null`
*/
fun <T> Store<T>.mapNullable(placeholder: T): Store<T?> =
map(mapToNullableLens(placeholder))
37 changes: 28 additions & 9 deletions www/src/pages/docs/50_StoreMapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,24 @@ val personStore = storeOf(Person(null), job = Job())
val nameStore = personStore.map(Person.name()).mapNull("")
```

#### The other way around

You may also encounter special cases where you would like to apply the above-mentioned mapping the other way around:
e.g. when dealing with a non-nullable data model in combination with a nullable data-binding of a component such as a
combobox.

For those cases, use the `mapNullable` mapper function:

```kotlin
val nonNullableStore: Store<String> = storeOf("")

val nullableStore: Store<String?> =
nonNullableStore.mapNullable(placeholder = "Unknown")
// ^^^^^^^^^^^^^^^^^^^^^^^
// When the parent has the specified placeholder value,
// the mapped Store will have `null` as its value.
```

### Combining Lenses

A `Lens` supports the `plus`-operator with another lens in order to create a new lens which combines the two.
Expand Down Expand Up @@ -400,15 +418,16 @@ Take a look at our complete [validation example](/examples/validation) to get an

### Summary of Store-Mapping-Factories

| Factory | Use case |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Store<P>.map(lens: Lens<P, T>): Store<T>` | Most generic map-function. Maps any `Store` given a `Lens`. Use for model destructuring with automatic generated lenses for example. |
| `Store<P?>.map(lens: Lens<P & Any, T>): Store<T>` | Maps any nullable `Store` given a `Lens` to a `Store` of a definitely none nullable `T`. Use in `render*`-content expressions combined with a null check. |
| `Store<List<T>>.mapByElement(element: T, idProvider): Store<T>` | Maps a `Store` of a `List<T>` to one element of that list. Works for entities, as a stable Id is needed. |
| `Store<List<T>>.mapByIndex(index: Int): Store<T>` | Maps a `Store` of a `List<T>` to one element of that list using the index. |
| `Store<Map<K, V>>.mapByKey(key: K): Store<V>` | Maps a `Store` of a `Map<T>` to one element of that map using the key. |
| `Store<T?>.mapNull(default: T): Store<T>` | Maps a `Store` of a nullable `T` to a `Store` of a definitely none nullable `T` using a default value in case of `null` in source-store. |
| `MapRouter.mapByKey(key: String): Store<String>` | Maps a `MapRouter` to a `Store`. See [chapter about routers](/docs/routing/#maprouter) for more information. |
| Factory | Use case |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Store<P>.map(lens: Lens<P, T>): Store<T>` | Most generic map-function. Maps any `Store` given a `Lens`. Use for model destructuring with automatic generated lenses for example. |
| `Store<P?>.map(lens: Lens<P & Any, T>): Store<T>` | Maps any nullable `Store` given a `Lens` to a `Store` of a definitely none nullable `T`. Use in `render*`-content expressions combined with a null check. |
| `Store<List<T>>.mapByElement(element: T, idProvider): Store<T>` | Maps a `Store` of a `List<T>` to one element of that list. Works for entities, as a stable Id is needed. |
| `Store<List<T>>.mapByIndex(index: Int): Store<T>` | Maps a `Store` of a `List<T>` to one element of that list using the index. |
| `Store<Map<K, V>>.mapByKey(key: K): Store<V>` | Maps a `Store` of a `Map<T>` to one element of that map using the key. |
| `Store<T?>.mapNull(default: T): Store<T>` | Maps a `Store` of a nullable `T` to a `Store` of a definitely none nullable `T` using a default value in case of `null` in source-store. |
| `Store<T>.mapNullable(placeholder: T): Store<T?>` | Maps a `Store` of `T` to a `Store` of `T?`, replacing the given `placeholder` from the parent with `null` in the sub Store. This function is the reverse equivalent of `mapNull`. |
| `MapRouter.mapByKey(key: String): Store<String>` | Maps a `MapRouter` to a `Store`. See [chapter about routers](/docs/routing/#maprouter) for more information. |

### Summary Lens-Factories

Expand Down

0 comments on commit 32dacf2

Please sign in to comment.