All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.Validations.kt Maven / Gradle / Ivy

There is a newer version: 0.23.0
Show newest version
@file:OptIn(ExperimentalTypeInference::class)

package io.kform

import kotlin.experimental.ExperimentalTypeInference
import kotlin.js.JsName
import kotlin.jvm.JvmName
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow

/** Value information of a validation's dependencies. */
public typealias DependenciesValueInfo = Map?>

/**
 * Context provided to a validation when running it, contains the values of the validation's
 * dependencies at the time the validation was executed.
 */
public open class ValidationContext(
    private val value: Any?,
    private val schema: Schema<*>,
    /** Path of the value being validated. */
    public val path: AbsolutePath,
    /** Path of the schema of the value being validated. */
    public val schemaPath: AbsolutePath,
    /**
     * Value information for each dependency of the validation. Mapping of keys as declared in the
     * [validation dependencies][Validation.dependencies] to their respective value information.
     */
    private val dependenciesInfo: DependenciesValueInfo,
    private val externalContexts: ExternalContexts,
) {
    // internal var invalidateAfter: Duration? = null

    /** Schema of the value being validated. */
    @Suppress("UNCHECKED_CAST") public fun  schema(): Schema = schema as Schema

    /** Value being validated. */
    @Suppress("UNCHECKED_CAST") public fun  value(): T = value as T

    /**
     * Obtains the value information of the dependency with key [dependencyKey] or `null` if the
     * dependency could not be found in the form.
     *
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    @Suppress("UNCHECKED_CAST")
    public fun  dependencyInfoOrNull(dependencyKey: String): ValueInfo? {
        require(dependencyKey in dependenciesInfo) {
            "No dependency found with key '$dependencyKey'."
        }
        return dependenciesInfo[dependencyKey] as ValueInfo?
    }

    /**
     * Obtains the value information of the dependency with key [dependencyKey].
     *
     * @throws DependencyNotFound If the dependency could not be found in the form.
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun  dependencyInfo(dependencyKey: String): ValueInfo =
        dependencyInfoOrNull(dependencyKey) ?: throw DependencyNotFound(dependencyKey)

    /**
     * Returns the path of the dependency with key [dependencyKey] or `null` if the dependency could
     * not be found in the form.
     *
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun dependencyPathOrNull(dependencyKey: String): AbsolutePath? =
        dependencyInfoOrNull(dependencyKey)?.path

    /**
     * Returns the path of the dependency with key [dependencyKey].
     *
     * @throws DependencyNotFound If the dependency could not be found in the form.
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun dependencyPath(dependencyKey: String): AbsolutePath =
        dependencyInfo(dependencyKey).path

    /**
     * Returns the schema of the dependency with key [dependencyKey] or `null` if the dependency
     * could not be found in the form.
     *
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun  dependencySchemaOrNull(dependencyKey: String): Schema? =
        dependencyInfoOrNull(dependencyKey)?.schema

    /**
     * Returns the schema of the dependency with key [dependencyKey].
     *
     * @throws DependencyNotFound If the dependency could not be found in the form.
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun  dependencySchema(dependencyKey: String): Schema =
        dependencyInfo(dependencyKey).schema

    /**
     * Returns the value of the dependency with key [dependencyKey] or `null` if the dependency
     * could not be found in the form.
     *
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun  dependencyOrNull(dependencyKey: String): T? =
        dependencyInfoOrNull(dependencyKey)?.value

    /**
     * Returns the value of the dependency with key [dependencyKey].
     *
     * @throws DependencyNotFound If the dependency could not be found in the form.
     * @throws IllegalArgumentException If no dependency with key [dependencyKey] was defined in the
     *   validation.
     */
    public fun  dependency(dependencyKey: String): T = dependencyInfo(dependencyKey).value

    /**
     * Returns the external context with name [externalContextName] available to the validation or
     * `null` if the external context could not be found.
     *
     * @throws IllegalArgumentException If no external context with key [externalContextName] was
     *   defined in the validation.
     */
    @Suppress("UNCHECKED_CAST")
    public fun  externalContextOrNull(externalContextName: String): T? {
        require(externalContextName in externalContexts) {
            "No external context found with name '$externalContextName'."
        }
        return externalContexts[externalContextName] as T?
    }

    /**
     * Returns the external context with name [externalContextName] available to the validation.
     *
     * @throws ExternalContextNotFound If the external context could not be found during the
     *   validation.
     * @throws IllegalArgumentException If no external context with key [externalContextName] was
     *   defined in the validation.
     */
    public fun  externalContext(externalContextName: String): T =
        externalContextOrNull(externalContextName)
            ?: throw ExternalContextNotFound(externalContextName)

    // TODO: Implement something like this
    // private fun invalidateAfter(duration: Duration) {
    //     require(duration >= Duration.ZERO) { "Duration must not be negative." }
    //     invalidateAfter = duration
    // }
}

/** Information about a validation dependency, includes its [path] and [type]. */
public data class DependencyInfo(val path: Path, val type: KType)

/**
 * Validation for values of type [T].
 *
 * Validations may depend on other values. These dependencies may be defined via the [dependency]
 * function, by providing the path of the dependency relative to the value being validated.
 *
 * Example validation that emits an error when the integer being validated is odd if the value of a
 * dependency `allowOdd` is `false`:
 * ```kotlin
 * object DisallowOdd : Validation() {
 *     private val ValidationContext.allowOdd: Boolean by dependency("../allowOdd")
 *
 *     ValidationContext.validate() = flow {
 *         if (!allowOdd && value % 2 != 0) {
 *             emit(ValidationError("oddNotAllowed"))
 *         }
 *     }
 * }
 * ```
 */
@JsName("ValidationKt")
public abstract class Validation {
    /** Schema of the value being validated. */
    protected inline val ValidationContext.schema: Schema<@UnsafeVariance T>
        get() = schema()

    /** Value being validated. */
    protected inline val ValidationContext.value: @UnsafeVariance T
        get() = value()

    /**
     * Dependencies of the validation. Mapping of keys to the paths this validation depends on. Keys
     * can be used within a [ValidationContext] to access the value of the dependencies.
     *
     * Validation dependency paths may contain a single recursive wildcard at the end, indicating
     * that the validation should be reevaluated by the [form manager][FormManager] whenever a child
     * value of the dependency changes. Otherwise, a dependency must contain no wildcards.
     */
    public open val dependencies: Map = hashMapOf()

    /**
     * Whether the [form manager][FormManager] should reevaluate this validation whenever a
     * descendant of the value being validated changes. This is `false` by default.
     *
     * Conceptually, when `false`, it is as if the validation has an implicit dependency on the path
     * `"."` (itself). When `true`, this dependency becomes `"./∗∗"` (itself and all of its
     * descendants).
     */
    public open val dependsOnDescendants: Boolean
        get() = false

    /**
     * Set of external context dependencies of the validation.
     *
     * Each entry of this set represents the name of the external context being depended upon.
     */
    public open val externalContextDependencies: Set = hashSetOf()

    /**
     * Declares a dependency to [path], accessible in the validation's context via key
     * [dependencyKey].
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    @JvmName("reifiedAddDependency")
    protected inline fun  addDependency(dependencyKey: String, path: Path) {
        (dependencies as MutableMap)[dependencyKey] =
            DependencyInfo(path, typeOf())
    }

    /**
     * Declares a dependency to [path], accessible in the validation's context via key
     * [dependencyKey].
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun addDependency(dependencyKey: String, path: Path): Unit =
        addDependency(dependencyKey, path)

    /**
     * Declares a dependency to [path], accessible in the validation's context via key
     * [dependencyKey].
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    @JvmName("reifiedAddDependency")
    protected inline fun  addDependency(
        dependencyKey: String,
        path: String
    ): Unit = addDependency(dependencyKey, Path(path))

    /**
     * Declares a dependency to [path], accessible in the validation's context via key
     * [dependencyKey].
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun addDependency(dependencyKey: String, path: String): Unit =
        addDependency(dependencyKey, path)

    /**
     * Declares an external context dependency to [externalContextName].
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun addExternalContextDependency(externalContextName: String) {
        (externalContextDependencies as MutableSet).add(externalContextName)
    }

    /**
     * Function used to declare a dependency to a [path] and delegate access to its value within a
     * [ValidationContext].
     *
     * If the dependency could not be found in the form during the execution of the validation, then
     * accessing this dependency will return `null`.
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.dependencyKey: Type? by dependencyOrNull(path)
     * ```
     *
     * Validation dependency paths may contain a single recursive wildcard at the end, indicating
     * that the validation should be reevaluated by the [form manager][FormManager] whenever a child
     * value of the dependency changes. Otherwise, a dependency must contain no wildcards.
     */
    protected inline fun  dependencyOrNull(
        path: Path,
        dependencyKey: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = PropertyDelegateProvider { validation, property ->
        validation.addDependency(dependencyKey ?: property.name, path)
        // Return a delegate to the dependency value within a [ValidationContext]
        ReadOnlyProperty { ctx, prop -> ctx.dependencyOrNull(dependencyKey ?: prop.name) }
    }

    /**
     * Function used to declare a dependency to a [path] and delegate access to its value within a
     * [ValidationContext].
     *
     * If the dependency could not be found in the form during the execution of the validation, then
     * accessing this dependency will return `null`.
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.dependencyKey: Type? by dependencyOrNull(path)
     * ```
     *
     * Validation dependency paths may contain a single recursive wildcard at the end, indicating
     * that the validation should be reevaluated by the [form manager][FormManager] whenever a child
     * value of the dependency changes. Otherwise, a dependency must contain no wildcards.
     */
    protected inline fun  dependencyOrNull(
        path: String,
        dependencyKey: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = dependencyOrNull(Path(path), dependencyKey)

    /**
     * Function used to declare a dependency to a [path] and delegate access to its value within a
     * [ValidationContext].
     *
     * If the dependency could not be found in the form during the execution of the validation, then
     * accessing this dependency will throw [DependencyNotFound].
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.dependencyKey: Type by dependency(path)
     * ```
     *
     * Validation dependency paths may contain a single recursive wildcard at the end, indicating
     * that the validation should be reevaluated by the [form manager][FormManager] whenever a child
     * value of the dependency changes. Otherwise, a dependency must contain no wildcards.
     */
    protected inline fun  dependency(
        path: Path,
        dependencyKey: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = PropertyDelegateProvider { validation, property ->
        validation.addDependency(dependencyKey ?: property.name, path)
        // Return a delegate to the dependency value within a [ValidationContext]
        ReadOnlyProperty { ctx, prop -> ctx.dependency(dependencyKey ?: prop.name) }
    }

    /**
     * Function used to declare a dependency to a [path] and delegate access to its value within a
     * [ValidationContext].
     *
     * If the dependency could not be found in the form during the execution of the validation, then
     * accessing this dependency will throw [DependencyNotFound].
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.dependencyKey: Type by dependency(path)
     * ```
     *
     * Validation dependency paths may contain a single recursive wildcard at the end, indicating
     * that the validation should be reevaluated by the [form manager][FormManager] whenever a child
     * value of the dependency changes. Otherwise, a dependency must contain no wildcards.
     */
    protected inline fun  dependency(
        path: String,
        dependencyKey: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = dependency(Path(path), dependencyKey)

    /**
     * Function used to declare a dependency to an external context and delegate access to its value
     * within a [ValidationContext].
     *
     * If the external context could not be found during the execution of the validation, then
     * accessing this external context will return `null`.
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.externalContextName: Type? by externalContextOrNull()
     * ```
     */
    protected fun  externalContextOrNull(
        externalContextName: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = PropertyDelegateProvider { validation, property ->
        validation.addExternalContextDependency(externalContextName ?: property.name)
        // Return a delegate to the external context dependency value within a [ValidationContext]
        ReadOnlyProperty { ctx, prop ->
            ctx.externalContextOrNull(externalContextName ?: prop.name)
        }
    }

    /**
     * Function used to declare a dependency to an external context and delegate access to its value
     * within a [ValidationContext].
     *
     * If the external context could not be found during the execution of the validation, then
     * accessing this external context will throw [ExternalContextNotFound].
     *
     * It should be used as follows:
     * ```kotlin
     * private val ValidationContext.externalContextName: Type by externalContext()
     * ```
     */
    protected fun  externalContext(
        externalContextName: String? = null
    ): PropertyDelegateProvider<
        Validation<@UnsafeVariance T>,
        ReadOnlyProperty
    > = PropertyDelegateProvider { validation, property ->
        validation.addExternalContextDependency(externalContextName ?: property.name)
        // Return a delegate to the external context dependency value within a [ValidationContext]
        ReadOnlyProperty { ctx, prop -> ctx.externalContext(externalContextName ?: prop.name) }
    }

    /**
     * Runs the validation within a [ValidationContext] containing the [value] being validated and
     * the value of all declared dependencies. Returns a flow over all found issues.
     */
    public abstract fun ValidationContext.validate(): Flow

    // Default [toString] implementation
    public override fun toString(): String = this::class.simpleName ?: super.toString()
}

/**
 * Function used to update the state of a stateful validation.
 *
 * This function is used to update the validation's state given the previous `state` and the `event`
 * whose path matches the path being observed.
 */
public typealias UpdateStateFn = suspend (state: TState, event: ValueEvent) -> TState

/**
 * Observer used within a stateful validation.
 *
 * Defines a path to observe and a function to update the state of the validation whenever an event
 * with a path matching the one being observed occurs.
 */
public open class Observer(
    /** Path to observe. */
    public val toObserve: Path,

    /**
     * Updates the validation's state given the previous state and the event whose path matches the
     * path being observed.
     *
     * This function must return the new validation state which, unless it hasn't changed, should be
     * different ([equals]-wise) from the previous validation state (as such, when using an object
     * as state, a new instance should be returned when the state is updated). If the new state
     * differs from the old one (using [equals]), the value will be revalidated by the
     * [form manager] [FormManager] using [validateFromState][StatefulValidation.validateFromState].
     */
    public val updateState: UpdateStateFn
)

/**
 * Validation for values of type [T] where the [form manager][FormManager] maintains a validation
 * state of type [TState] for each managed value.
 *
 * Stateful validations are useful when validations require an expensive computation over data and
 * if it is possible to save the result of such expensive computation and tweak it as data changes
 * instead of running the expensive computation all over again.
 *
 * As an example, imagine that we have a list of people and that we want to validate that the
 * average age of all people isn't over a certain age. Instead of iterating over the whole list
 * every time a person is added or removed, we can use a stateful validation to save, as state, the
 * sum of all ages, and simply tweak this sum as people are added or removed. Having access to the
 * sum of all ages as state allows us to implement the validation function with a complexity of O(1)
 * as opposed to O(N).
 *
 * The following snippet implements the example above of making sure that the average age of all
 * people doesn't surpass a dependency `maxAvgAge`:
 * ```kotlin
 * object AvgAgeNotOverMax : StatefulValidation, Int>() {
 *     private val ValidationContext.maxAvgAge: Int by dependency("../maxAvgAge")
 *
 *     override suspend fun ValidationContext.initState(value: List): Int =
 *         value.fold(0) { sum, person -> sum + person.age }
 *
 *     private val ageObserver by observe("∗/age") { agesSum, event ->
 *         when (event) {
 *             is ValueEvent.Init -> agesSum + event.newValue
 *             is ValueEvent.Change -> agesSum + event.newValue - event.oldValue
 *             is ValueEvent.Destroy -> agesSum - event.oldValue
 *             else -> agesSum
 *         }
 *     }
 *
 *     override fun ValidationContext.validateFromState(value: List, state: Int) = flow {
 *         val avgAge = state / value.size
 *         if (avgAge > maxAvgAge) {
 *             emit(ValidationError("avgAgeOverMax"))
 *         }
 *     }
 * }
 * ```
 */
@JsName("StatefulValidationKt")
public abstract class StatefulValidation : Validation() {
    /**
     * List of observers.
     *
     * Each observer contains a path to be observed and a function to update the state of the
     * validation, which will be called whenever an event with a path matching the one being
     * observed occurs.
     */
    public open val observers: List> = mutableListOf()

    /**
     * Adds an [observer] to the path [observer.toObserve][Observer.toObserve] to update the
     * validation state via [observer.updateState][Observer.updateState] whenever an event with a
     * path matching [observer.toObserve][Observer.toObserve] occurs.
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun  addObserver(observer: Observer) {
        @Suppress("UNCHECKED_CAST")
        (observers as MutableList>) += observer
    }

    /**
     * Adds an observer to the path [pathToObserve] to update the validation state via [updateState]
     * whenever an event with a path matching [pathToObserve] occurs.
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun  addObserver(
        pathToObserve: Path,
        @BuilderInference updateState: UpdateStateFn
    ): Unit = addObserver(Observer(pathToObserve, updateState))

    /**
     * Adds an observer to the path [pathToObserve] to update the validation state via [updateState]
     * whenever an event with a path matching [pathToObserve] occurs.
     *
     * **NOTE**: This method must only be called during the initialization process of the class.
     */
    protected fun  addObserver(
        pathToObserve: String,
        @BuilderInference updateState: UpdateStateFn
    ): Unit = addObserver(Path(pathToObserve), updateState)

    /**
     * Function used to observe the path [pathToObserve] and update the validation state via
     * [updateState] whenever an event with a path matching [pathToObserve] occurs.
     *
     * It should be used as follows:
     * ```kotlin
     * private val observer by observe(path) { state, event ->
     *     when (event) {
     *         is ValueEvent.Init      -> // Compute new state
     *         is ValueEvent.Change    -> // Compute new state
     *         is ValueEvent.Destroy   -> // Compute new state
     *
     *         // These events only occur when observing a collection:
     *         is ValueEvent.Add    -> // Compute new state
     *         is ValueEvent.Remove -> // Compute new state
     *     }
     * }
     * ```
     *
     * The provided function must return the new validation state which, unless it hasn't changed,
     * should be different ([equals]-wise) from the previous validation state (as such, when using
     * an object as state, a new instance should be returned when the state is updated). If the new
     * state differs from the old one (using [equals]), the value will need to be revalidated by the
     * [form manager][FormManager] using [validateFromState].
     */
    protected fun  observe(
        pathToObserve: Path,
        @BuilderInference updateState: UpdateStateFn
    ): PropertyDelegateProvider<
        StatefulValidation<@UnsafeVariance T, TState>,
        ReadOnlyProperty, Observer>
    > = PropertyDelegateProvider { validation, _ ->
        val observer = Observer(pathToObserve, updateState)
        validation.addObserver(observer)
        ReadOnlyProperty { _, _ -> observer }
    }

    /**
     * Function used to observe the path [pathToObserve] and update the validation state via
     * [updateState] whenever an event with a path matching [pathToObserve] occurs.
     *
     * It should be used as follows:
     * ```kotlin
     * private val observer by observe(path) { state, event ->
     *     when (event) {
     *         is ValueEvent.Init      -> // Compute new state
     *         is ValueEvent.Change    -> // Compute new state
     *         is ValueEvent.Destroy   -> // Compute new state
     *
     *         // These events only occur when observing a collection:
     *         is ValueEvent.Add    -> // Compute new state
     *         is ValueEvent.Remove -> // Compute new state
     *     }
     * }
     * ```
     *
     * The provided function must return the new validation state which, unless it hasn't changed,
     * should be different ([equals]-wise) from the previous validation state (as such, when using
     * an object as state, a new instance should be returned when the state is updated). If the new
     * state differs from the old one (using [equals]), the value will need to be revalidated by the
     * [form manager][FormManager] using [validateFromState].
     */
    protected fun  observe(
        pathToObserve: String,
        @BuilderInference updateState: UpdateStateFn
    ): PropertyDelegateProvider<
        StatefulValidation<@UnsafeVariance T, TState>,
        ReadOnlyProperty, Observer>
    > = observe(Path(pathToObserve), updateState)

    /**
     * Initialises and returns the validation's state, given the [value] to validate within a
     * [ValidationContext] containing the values of all declared dependencies.
     *
     * This method is called by the [form manager][FormManager] whenever a new value that needs to
     * be validated by this validation is initialised.
     */
    public abstract suspend fun ValidationContext.initState(): TState

    /**
     * Destroys the validation [state].
     *
     * Called by the [form manager][FormManager] whenever a state is no longer needed (i.e. when a
     * newer state has been produced or when the value being validated has been destroyed).
     */
    public open suspend fun destroyState(state: TState): Unit = Unit

    /**
     * Runs the validation, given its [state], within a [ValidationContext] containing the [value]
     * being validated and the value of all declared dependencies. Returns a flow over all found
     * issues.
     *
     * This method is called by the [form manager][FormManager] instead of [validate] when
     * validating stateful validations.
     *
     * Note that the form utilities [validate][io.kform.validate] function (and, by consequence, the
     * [form validator][FormValidator]) does **not** maintain validation states and, as such, does
     * **not** ever explicitly call any methods specific to stateful validations like
     * [validateFromState]. This is why we provide the following default implementation of
     * [validate]:
     * ```kotlin
     * override fun ValidationContext.validate(): Flow = flow {
     *     emitAll(validateFromState(initState(value)))
     * }
     * ```
     */
    public abstract fun ValidationContext.validateFromState(state: TState): Flow

    // Default [validate] implementation for stateful validations.
    override fun ValidationContext.validate(): Flow = flow {
        emitAll(validateFromState(initState()))
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy