Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Sep 6, 2024

Conversation

Lysander
Copy link
Collaborator

@Lysander Lysander commented Aug 19, 2024

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

Copy link
Collaborator

@haukesomm haukesomm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some minor comments about formatting nd similar stuff.

Another general point: Should we adjust the lens documentation and add a paragraph about sealed class/interface specific lenses (such as up- and down-casting lenses and what to use)?

@Lysander
Copy link
Collaborator Author

Regarding new section(s) in the documentation we should rework the following phrase from the section lenses-in-depth:

fritz2 offers an annotation @Lenses you can add to your data-classes in the commonMain source-set of your multiplatform project:

to

fritz2 offers an annotation @Lenses you can add to your data-classes, sealed-classes and sealed-interfaces in the commonMain source-set of your multiplatform project:

This way we do not disturb the readers flow.

We could add some info-box at the end of the section that forwards to the dedicated section(s)

christian.hausknecht added 2 commits September 6, 2024 11:19
- add support for lenses generation for sealed classes and interfaces
- automatic lenses generation generates casting lenses for up- and
down-casting. Those are important building blocks for integrating
central validation code in sealed base classes into the overall validation
process.
- improves support for dealing with a sealed base class as central model
for an application store (up-casting lenses are needed for this)

solves #874
@Lysander Lysander force-pushed the chausknecht/delegating-lenses branch from 03d495f to 46638b7 Compare September 6, 2024 09:32
@Lysander Lysander merged commit 029fa6a into master Sep 6, 2024
2 checks passed
@Lysander Lysander deleted the chausknecht/delegating-lenses branch September 6, 2024 09:55
Lysander pushed a commit that referenced this pull request Sep 9, 2024
The newly introduced support for lenses generation for sealed classes or interfaces (see #876) must allow to configure whether some property should be picked for lens generation or not. This heavily depends on the implementation of those. The default case is to pick a property, but sometimes a property will not be implemented as constructor property inside the child data class. For such cases it is now possible to mark such properties with the `@NoLens` annotation inside the sealed type. Such marked properties will get ignored by the lens generator, so no delegating lens will be created.

Beware that this annotation is not evaluated inside the constructor of data classes!

Imagine the following example to see `@NoLens` in action:
```kotlin
@lenses
sealed class Framework {
	// Ignore this property for delegating lens generation.
	// The property is considered to be constant for all objects,
	//  see data class below
	@nolens
	val ignore: String

	abstract val foo: String

	companion object
}

data class Fritz2 (
	override val foo: String,
) : Framework {
	// not part of the "data", so not possible to change at copy!
	// Because of that, we cannot define any valid lens in the sealed base,
	//  so we must mark it to exclude it for lens creation!
	override val ignore: String = "Fritz2"
}
```
Lysander added a commit that referenced this pull request Sep 9, 2024
* Add new `@NoLens` annotation

The newly introduced support for lenses generation for sealed classes or interfaces (see #876) must allow to configure whether some property should be picked for lens generation or not. This heavily depends on the implementation of those. The default case is to pick a property, but sometimes a property will not be implemented as constructor property inside the child data class. For such cases it is now possible to mark such properties with the `@NoLens` annotation inside the sealed type. Such marked properties will get ignored by the lens generator, so no delegating lens will be created.

Beware that this annotation is not evaluated inside the constructor of data classes!

Imagine the following example to see `@NoLens` in action:
```kotlin
@lenses
sealed class Framework {
	// Ignore this property for delegating lens generation.
	// The property is considered to be constant for all objects,
	//  see data class below
	@nolens
	val ignore: String

	abstract val foo: String

	companion object
}

data class Fritz2 (
	override val foo: String,
) : Framework {
	// not part of the "data", so not possible to change at copy!
	// Because of that, we cannot define any valid lens in the sealed base,
	//  so we must mark it to exclude it for lens creation!
	override val ignore: String = "Fritz2"
}
```


---------

Co-authored-by: christian.hausknecht <[email protected]>
Co-authored-by: Lysander <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
2 participants