konform-kt / konform

Portable validations for Kotlin
https://www.konform.io
MIT License
651 stars 39 forks source link

Cross-field validation #29

Open cerker opened 3 years ago

cerker commented 3 years ago

Is it possible to validate one field in relation to others?

For example, my user has a validFrom: LocalDateTime and a validUntil: LocalDateTime? field. How can I validate that validUntil is after validFrom?

I started writing a custom validator:

fun ValidationBuilder<LocalDateTime>.isAfter(other: LocalDateTime): Constraint<LocalDateTime> {
    return addConstraint(
        "must be after {0}",
        other.toString()
    ) { it.isAfter(other) }
}

but how can I pass the other value?

UserProfile::validUntil ifPresent {
        isAfter(???)
}
aSemy commented 3 years ago

What do you think about using run to conditionally include specific validators?

EDIT: this doesn't work as I expected - ifPresent doesn't run conditionally, and will always execute the run , which adds the validation whether validUntil is null or not.

import java.time.LocalDate
import java.time.LocalDateTime
import io.konform.validation.Validation

data class User(
    val validFrom: LocalDateTime,
    val validUntil: LocalDateTime?,
) {
  companion object {
    /** validate two fields on User */
    private val validateUserDates = Validation<User> {
      addConstraint("before/after") {
        it.validUntil?.isAfter(it.validFrom) ?: false
      }
    }

    /** public validator, combining private validators */
    val validateUser = Validation<User> {
      User::validUntil ifPresent {
        run(validateUserDates)
      }
    }

  }
}

fun main() {
  val time = LocalDate.of(2020, 1, 1).atStartOfDay()

  val userValid = User(time, time.plusDays(10))
  println(User.validateUser(userValid))
  // Valid(value=User(validFrom=2020-01-01T00:00, validUntil=2020-01-11T00:00))

  val userInvalid = User(time, time.plusDays(-10))
  println(User.validateUser(userInvalid))
  // Invalid(errors=[ValidationError(dataPath=, message=before/after)])

  // EDIT: the `ifPresent` didn't work as I expected
  val userNoUntil = User(time, null)
  println(User.validateUser(userNoUntil))
  // Invalid(errors=[ValidationError(dataPath=, message=before/after)])
}

The downside is that the error message isn't dynamic. It would be nice to have a Constraint implementation that can dynamically create a message.

JohannesZick commented 2 years ago

I'm a little surprised this feature is missing. I keep running into this, and it feels like the most missed feature in good ol' JSR303.

Example I have right now: validate a postal code depending on the country.

Basically, it should be possible to select validations to apply to an object dynamically from the objects runtime state, not just statically. Assuming custom validations:

val validateAddress = Validation<Address> {
    Address::countryCode {
        validIsoCountryCode()
    }

    Address::postalCode dynamicValidation { it: Address ->
        when (it.countryCode) {
            "US" -> validUsPostalCode()
            "DE" -> validGermanPostalCode()
            else -> // default validation of some kind
        }
    }
}
floatdrop commented 2 years ago

I'm not quite sure, why ValidationBuilder should not give reference inside init block to the variable under validation:

val validateAddress = Validation<Address> { it: Address ->
    Address::countryCode {
        validIsoCountryCode()
    }

    Address::postalCode {
        when (it.countryCode) {
            "US" -> validUsPostalCode()
            "DE" -> validGermanPostalCode()
            else -> // default validation of some kind
        }
    }
}

Is there a reason behind building static validators (caching validators by PropKey?)?

minisaw commented 1 year ago

Is it possible to validate one field in relation to others?

For example, my user has a validFrom: LocalDateTime and a validUntil: LocalDateTime? field. How can I validate that validUntil is after validFrom?

doesn't the following snippet address your scenario?

data class User(val validFrom: LocalDateTime, val validUntil: LocalDateTime?) {
    companion object {
        val validateUser = Validation<User> {
            addConstraint("validUntil, if present, must be after validFrom") {
                it.validUntil?.isAfter(it.validFrom) ?: true
            }
        }
    }
}
lnhrdt commented 4 months ago

doesn't the following snippet address your scenario?

Hey @minisaw I'm not @cerker but the limitation of what you suggested is that the error is associated with the top level value, not the field (i.e. the dataPath will be "" rather than ".validUntil").

@nlochschmidt do you have any intention to support cross-field validation in konform? I tried looking through the issues for more context on this feature request.