jwstegemann / fritz2

Easily build reactive web-apps in Kotlin based on flows and coroutines.
https://www.fritz2.dev
MIT License
663 stars 28 forks source link

Improve Validation Support for Sealed Class Hierarchies by Providing a new Annotation and Automatic Validation-Dispatcher Implementation #875

Open Lysander opened 3 months ago

Lysander commented 3 months ago

In addition to the problem described in #874 there arises another problem with validation in sealed class hiearchies: Using a ValidatingStore managing such types!

Consider the example hierarchy from #874 shortened for better readability:

sealed interface Product {
    val name: String
}

@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
    companion object {
        val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
            // ...
        }
    }
}

@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
    companion object {
        val validation: Validation<Pizza, Unit, ComponentValidationMessage> = validation { inspector ->
            // ...
        }
    }
}

enum class Technology {
    Kotlin,
    PHP,
    FSharp,
}

Now let us create some ValidatingStore for this types in order to manage the domain state of some app:

val storedProduct = storeOf<Product, ComponentValidationMessage>(
    WebFramework("fritz2", Technology.Kotlin),
    validation = WebFramework.validation, // <- won't compile! The Validation-Object's `D` type must be of type `Product`!
)

Two problems arises here: the type mismatch and the lack of runtime polymorphism! The type of the provided validation is Validation<WebFramework, Unit, ComponentValidationMessage>, but Validation<Product, Unit, ComponentValidationMessage> ist required in order to satisfy the store's requirements. Even if the type would be accepted - how about the Pizza.validation-Validation? If the object inside the store would change at runtime - which this is all about! - the validation of WebFramework would remain!

So in order to solve this problem, we must provide some Validation-object, that is based upon Product and delegates to the appropriate validation based upon the current type!

This could be implemented like this:

val delegatingValidation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
    addAll(
        // as the hierarchy is sealed, the `when` expression is exausting - important fact for further deductions!
        when (val product = inspector.data) {
            is WebFramework -> WebFramework.validation(inspectorOf(product))
            is Pizza -> Pizza.validation(inspectorOf(product))
        }
    )
}

Now we can provide this validation to the store:

val storedProduct = storeOf<Product, ComponentValidationMessage>(
    WebFramework("fritz2", Technology.Kotlin),
    validation = Product.delegatingValidation
)

Imagine some state change of the store:

button {
    +"Push Pizza"
    clicks.map { 
        Pizza(
            "Rustica Salami (90ies best pizza in Germany!)", 
            listOf("tomato-sauce", "cheese", "salami", "onion", "paprika", "love")
        ) 
    } handled by storedProduct.update
}

The store will automatically call the validation provided, in this case the delegating one. The latter will recognize that the type is now Pizza and will call the provided Validation within the when-branch.

[!NOTE] This code works without any changes in fritz2 right now.

But as this is a common problem, can we provide some tooling support?

I think this is not so hard and possible!

Let us recap, that we support this only for sealed hierarchies - that way we can be sure, that we know at compile time every implementation! So we can create at compile time such a delegatingValidation, that is fully functional.

We could create new annotations @DelegatingValidation and @Validation that needs to be applied as follows:

@DelegatingValidation
sealed interface Product {
    val name: String

    companion object
}

@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
    companion object {
        @Validation
        val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
            // this call would become possible:
            addAll(Product.delegatingValidation()(inspector.data))
        }
    }
}

@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
    companion object {
        @Validation
        val validation: Validation<Pizza, Unit, ComponentValidationMessage> = validation { inspector ->
            // this call would become possible:
            addAll(Product.delegatingValidation()(inspector.data))
        }
    }
}

This should provide our annotation processor with the following information:

This shoud be sufficient to implement a delegating validation as shown above!

fun Product.Companion.delegatingValidation(): Validation<Product, Unit, ComponentValidationMessage> = validation { inspector, metadata ->
    addAll(
        when (val product = inspector.data) {
            is WebFramework -> WebFramework.validation(inspectorOf(product, metadata))
            is Pizza -> Pizza.validation(inspectorOf(product, metadata))
        }
    )
}

Because of the annotations on the sub-type validation properties, the user is free to choose any name and is not forced to use validation. We might consider to allow a custom name-property for the @DelegatingValidation to override the default name delegatingValidation - which is nice imho: @DelegatingValidation(name = "myAwesomeDelegationValidation")

Is something missing? Are the conclusions all valid?

Lysander commented 1 month ago

After some first attempts to implement this, it appears to be harder than thought!

As the benefit is not so huge, we have decided to downgrade its importance. Until then you must craft the delegatingValidation by yourself - which is not so hard as shown.