fritz2 icon indicating copy to clipboard operation
fritz2 copied to clipboard

Improve Validation Support for Sealed Class Hierarchies by Enhancing Lenses Type and their automatic Generation

Open Lysander opened this issue 1 year ago • 0 comments

[!IMPORTANT] During the work on this issue we found a different solution. So don't be confused, that there is no GetterLens at all. Refer to the description of the according PR #876. The text remains here just for "historical" reasons. The original problem and its motivation are still relevant though.

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...

But let us recap:

  • the Inspector needs the id in order to generate the path
  • the inspector needs the getter in order to provide the data value of a property.

But it does not need the setter after all!

So we could think of a special Lens, that only provides the two needed properties:

interface GetterLens<P, T> {
    val id: String
    fun get(parent: P): T
}

interface Lens<P, T> : GetterLens<P, T> {
//                     ^^^^^^^^^^^^^^^^
//                     implement the parent interface so every `Lens` is also a `GetterLens` -> no API changes!

   // remains as before...
   fun set(parent: P, value: T): P
}

Now we can change the Inspector's API to rely on GetterLens instead of Lens:

interface Inspector<D> {
    val data: D
    val path: String

    fun <X> map(lens: GetterLens<D, X>): Inspector<X> = SubInspector(this, lens)
    //                ^^^^^^^^^^^^^^^^
    //                accept GetterLens for inspector's mapping
}

class SubInspector<P, T>(
    val parent: Inspector<P>,
    private val lens: GetterLens<P, T> // also here
) : Inspector<T> {
    // ...
}

This way we could omit the setter for lenses inside sealed interfaces:

sealed interface Product {
    val name: String

    companion object {
        fun name(): GetterLens<Product, String> = lensOf("name", Product::name) // no more setter!

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

This offers further improvements towards our lenses-annotation-processor: We could add the support for sealed interfaces and generate GetterLenses inside their companion objects.

// Allow annotation on sealed interfaces
@Lenses
sealed interface Product {
    val name: String

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

One remaining question: Should we allow this also on sealed classes? In my opinion this would be consistent and we should support this too.

Lysander avatar Aug 07 '24 16:08 Lysander