fritz2 icon indicating copy to clipboard operation
fritz2 copied to clipboard

Introduce automatic Generation of Delegating Lenses: Improves Validation Support for Sealed Class Hierarchies

Open Lysander opened this issue 1 year ago • 1 comments

Motivation

Currently the support for validating sealed class hierachies is rather limited, as our provided mechanisms wont work as the following example shows:


import dev.fritz2.core.Lens
import dev.fritz2.core.Lenses
import dev.fritz2.core.lensOf
import dev.fritz2.headless.validation.ComponentValidationMessage
import dev.fritz2.headless.validation.warningMessage
import dev.fritz2.validation.Validation
import dev.fritz2.validation.validation
import dev.fritz2.validation.invoke

sealed interface Product {
    val name: String

    companion object {
        // Try to implement validation for the common property `name` at a central place.
        // (Have a look at the validation code of `WebFramework` data class, which calls this central validation)

        val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
            val name = inspector.map(Product.name()) // won't compile -> there is no Lens generated.
            // ...
        }

        fun <T : Product> createGenericValidation(): Validation<T, Unit, ComponentValidationMessage> =
            validation { inspector ->
                val name = inspector.map(T.name()) // won't compile -> generic types dont carry companion objects!
            }

        inline fun <reified T> createGenericValidationInlined(): Validation<T, Unit, ComponentValidationMessage> where T : Product =
            validation { inspector ->
                val name = inspector.map(T.name()) // won't compile -> generic types dont carry companion objects!
            }
    }
}

@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
    companion object {
        val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
            val technology = inspector.map(WebFramework.technology())
            if (technology.data == Technology.PHP) {
                add(technology.warningMessage("Consider a better language for this task ;-)"))
            }

            // call the validation of the parent type
            addAll(Product.validation(inspector.data))
        }
    }
}

@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
    companion object {
        // analogous procedure:
        // val validation ...
    }
}

enum class Technology {
    Kotlin,
    PHP,
    FSharp,
}

We cannot access the generated Lenses from within the interface scope, as we cannot refer to the companion objects of the implementing classes.

We could create the needed lenses by hand of course:

sealed interface Product {
    val name: String

    companion object {
        // generate `Lens` by hand:
        fun manualNameLens(): Lens<Product, String> = lensOf("name", Product::name) { _, _ -> TODO() }
        //                                                                            ^^^^^^^^^^^^^^
        //                                                                            This is ugly!

        val validationWithManualLens: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
            val name = inspector.map(manualNameLens())
            // ...
        }
    }
}

We cannot really provide some good setter though!

[!WARNING] This is very dangerous, as anyone could use the Lens for store-mapping, which would fail then! making this lens private would minimize the risk of leaking outside, but a small risk remains of course...

Solution

The solution is kinda simple: We have to implement the lens within the sealed base type, but delegate the "work" to the implementing types! This way we can use the polymorphic aspect in a safe way, as the sealed property guarantees alle types are known at compile time:

sealed interface Product {
    val name: String

    companion object {
        fun name(): Lens<Product, String> = lensOf(
            "name",
            { parent ->
                when (parent) {
                    is WebFramework -> parent.name
                    is Pizza -> parent.name
                }
            },
            { parent, value ->
                when (parent) {
                    is WebFramework -> parent.copy(name = value)
                    is Pizza -> parent.copy(name = value)
                }
            }
        )
    }
}

Armed with a working Lens for the common properties, we could use those within the central validation code:

sealed interface Product {
    val name: String

    companion object {
        fun name(): Lens<Product, String> = lensOf(
            // ...
        )

        val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
            val name = inspector.map(Product.name()) // works now, as Lens is properly defined
            // ...
        }
    }
}

There is one problem left: How can we call this common validation from within the validation code of one implementing child? The type of the inspector hast to be Inspector<Product> and not Inspector<WebFramework> or Inspector<Pizza>.

Another Lens will help us there: It must cast the specific type to the base type, so its role is down-casting. That is why we refer to those kind of lenses with the term "down-casting-lens":

@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
    companion object {
        // lens for down-casting to object's base type, so we can map an inspector later
        fun produkt(): Lens<WebFramework, Product> = lensOf("", { it }, { _, v -> v as WebFramework })

        val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
            val technology = inspector.map(WebFramework.technology())
            if (technology.data == Technology.PHP) {
                add(technology.warningMessage("Consider a better language for this task ;-)"))
            }

            // call the validation of the parent type by down-casting the inspector's type by appropriate lens
            addAll(Product.validation(inspector.map(produkt())))
        }
    }
}

Now we got all the building blocks to compose idiomatic fritz2 validation codes within a sealed class hierarchy.

Technical Solution

This PR adds the generation of delegating lenses and up- and down-casting-lenses to the lenses-annotation-processor of fritz2. So by now you can add @Lenses annotations also to sealed classes or interfaces in order to generate the delegating- and up-casting-lenses.

(up-casting-lenses are useful for dealing with sealed base types as model-store-types. See issue #875 for more details)

The down-casting-lenses are always named by the base type's name, so sealed class BaseType would create baseType() lenses-factories in each child's companion objects.

View the full example code aggreagated in one code snippet:


@Lenses // annotate base type too now!
sealed interface Product {
    val name: String

    companion object {
        val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
            val name = inspector.map(Product.name()) // use newly generated "delegating lens"
            // ...
        }
    }
}

@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
    companion object {
        val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
            val technology = inspector.map(WebFramework.technology())
            if (technology.data == Technology.PHP) {
                add(technology.warningMessage("Consider a better language for this task ;-)"))
            }

            // use newly generated "down-casting-lens" to call common base type's validation
            addAll(Product.validation(inspector.map(WebFramework.product())))
        }
    }
}

@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
    companion object {
        // analogous procedure:
        // val validation ...
    }
}

enum class Technology {
    Kotlin,
    PHP,
    FSharp,
}

// generated in file `ProductLenses.kt`; delegating lens, chaining lens and up-casting-lenses
public fun Product.Companion.name(): Lens<Product, String> = lensOf(
    "name",
    { parent ->
        when(parent) {
            is Pizza -> parent.name
            is WebFramework -> parent.name
        }
    },
    { parent, value ->
        when(parent) {
            is Pizza -> parent.copy(name = value)
            is WebFramework -> parent.copy(name = value)
        }
    }
)

public fun <PARENT> Lens<PARENT, Product>.name(): Lens<PARENT, String> = this + Product.name()

public fun Product.Companion.pizza(): Lens<Product, Pizza> = lensOf(
    "",
    { it as Pizza },
    { _, v -> v }
)

public fun Product.Companion.webFramework(): Lens<Product, WebFramework> = lensOf(
    "",
    { it as WebFramework },
    { _, v -> v }
)

// generated in file `WebFrameworkLenses.kt`, standard lenses and down-casting-lens at the end
public fun WebFramework.Companion.name(): Lens<WebFramework, String> = lensOf(
    "name",
    { it.name },
    { p, v -> p.copy(name = v)}
  )

public fun <PARENT> Lens<PARENT, WebFramework>.name(): Lens<PARENT, String> = this +
    WebFramework.name()

public fun WebFramework.Companion.technology(): Lens<WebFramework, Technology> = lensOf(
    "technology",
    { it.technology },
    { p, v -> p.copy(technology = v)}
  )

public fun <PARENT> Lens<PARENT, WebFramework>.technology(): Lens<PARENT, Technology> = this +
    WebFramework.technology()

public fun WebFramework.Companion.product(): Lens<WebFramework, Product> = lensOf(
    "",
    { it },
    { _, v -> v as WebFramework }
)

closes #874

Lysander avatar Aug 19 '24 11:08 Lysander