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

Feature: Using Exact for data class / class with multiple properties #30

Open
PoisonedYouth opened this issue Jun 9, 2023 · 4 comments

Comments

@PoisonedYouth
Copy link

I have the following requirement in one of my productive applications using Either for exeption handling.

I have a domain model that consists of multiple properties and it should only be possible to create valid objects. A valid object depends on a validation that includes multiple of the properties. To achieve this with a data class I have an implementation similar to below (simplified for better overview):

sealed interface Topic {
    val id: UUID
    val name: String
    val title: String
    val category: String

    companion object {
        operator fun invoke(id: UUID, name: String, title: String, category: String): Either<Failure, Topic> {
            return TopicModel.create(id, name, title, category)
        }
    }

    private data class TopicModel private constructor(
        override val id: UUID,
        override val name: String,
        override val title: String,
        override val category: String,
    ) : Topic {

        companion object {
            fun create(id: UUID, name: String, title: String, category: String): Either<Failure, Topic> {
                return either {
                    ensure(category.length > 5) {
                        Failure.ValidationFailure("The category must be longer than 5 characters!")
                    }
                    ensure(name.isNotEmpty() || title.isNotEmpty()) {
                        Failure.ValidationFailure("Either name or title must not be empty!")
                    }
                    TopicModel(
                        id = id,
                        name = name,
                        title = title,
                        category = category
                    )
                }
            }
        }
    }
}

The complexity comes from the fact that I don't want to expose the copy - constructor.

For this requirement I can also use Exact but only by providing a kind of DTO object as constructor parameter. This does not feel great.

I used a class instead of a data class because the implementation is simpler.

class TopicDto(
    val id: UUID,
    val name: String,
    val title: String,
    val category: String
)

class Topic private constructor(
    val id: UUID,
    val name: String,
    val title: String,
    val category: String,
) {
    companion object : Exact<TopicDto, Topic> {
        override fun Raise<ExactError>.spec(raw: TopicDto): Topic {
            ensure(raw.category.length > 5)
            ensure(raw.name.isNotEmpty() || raw.title.isNotEmpty())
            return Topic(
                id = raw.id,
                name = raw.name,
                title = raw.title,
                category = raw.category
            )
        }

    }
}

I would like to have a solution to either use the spec - function with multiple parameters.

Is this a valid requirement or is the usage of Exact only intended for single parameter types like value classes and compose complexer types out of it (no longer contain own validation)?

@ustitc
Copy link
Collaborator

ustitc commented Jun 10, 2023

... the usage of Exact only intended for single parameter types like value classes and compose complexer types out of it

You are right, Exact is designed around the idea of single parameter classes (types). It can't and probably won't be able to support multiple parameters. Mostly because it will require code-generation or compiler plugins which we didn't want not to use for this project

... I can also use Exact but only by providing a kind of DTO object as constructor parameter. This does not feel great.

Yes, looks too complicated for such scenario. I think for your example Exact won't be a good fit, your implementation with either or even a simple require would be better:

class Topic private constructor(
    val id: UUID,
    val name: String,
    val title: String,
    val category: String,
) {

    init {
        require(category.length > 5) {
            "The category must be longer than 5 characters!"
        }
        require(name.isNotEmpty() || title.isNotEmpty()) {
            "Either name or title must not be empty!"
        }
    }

    companion object {
        
        fun from(id: UUID, name: String, title: String, category: String): Either<Failure.ValidationFailure, Topic> {
            return Either.catchOrThrow<IllegalArgumentException, Topic> {
                Topic(id, name, title, category)
            }.mapLeft { Failure.ValidationFailure(it.message) }
        }
    }
}

@nomisRev
Copy link
Member

nomisRev commented Jun 12, 2023

I think the idea of Exact, and the fact that's build on top of Raise, is that you can easily combine with the existing Arrow DSLs.

@JvmInline value class NonEmptyString private constructor(val raw: String) {
  companion object : Exact<String, NonEmptyString> by exact({ raw ->
     ensure(raw.isNotEmpty()) // optional { "custom message" }
     NonEmptString(raw)
  })
}

@JvmInline value class Category private constructor(val raw: String) {
  companion object : Exact<String, Category> by exact({ raw ->
     ensure(raw.length > 5) // optional { "custom message" }
     Category(raw)
  })
}

class Topic private constructor(
  val id: UUID,
  val name: NonEmptyString,
  val title: NonEmptyString,
  val category: Category, // Custom exact type with min-length 5
) {
  companion object {
    fun from(id: UUID, name: String, title: String, category: String): EitherNel<ExactError, Topic> = either {
      zipOrAccumulate(
        { ensure(name, NonEmptyString)) },
        { ensure(title, NonEmptyString) },
        { ensure(category, Category) }
      ) { name, title, category -> Topic(id, name, title, category) }
    }
  }
}

I wish https://github.com/facebookincubator/dataclassgenerate could get rid of copy for cases like this 😅 So we can just have toString, equals and hashCode for free but no copy.

For completeness I've show here the NonEmptyString and Category definitions and if we think about the domain we can probably already imagine that both Name and Title have different requirements than simply NonEmptyString so we can model this even better by making it more explicit.

@JvmInline value class Name private cosntructor(val raw: String) {
  companion object : Exact<String, Name> by exact({ raw ->
    ensure(raw.isNotEmpty()) { "Name cannot be empty" }
    ensure(raw.length <= 50) { "Name cannot be longer than 50 characters" }
    Name(raw)
  })
}

private val ILLEGAL_TITLE_CHARS: Regex = ...

@JvmInline value class Title private cosntructor(val title: String) {
  companion object : Exact<String, Title> by exact({ raw ->
    ensure(raw.isNotEmpty()) { "Title cannot be empty" }
    ensure(raw.length <= 200) { "Title cannot be longer than 200 characters" }
    ensure(raw.contains(ILLEGAL_TITLE_CHARS)) { "Title cannot contain $ILLEGAL_TITLE_CHARS" }
    Title(raw)
  })
}

@PoisonedYouth
Copy link
Author

Thanks for clarification.

I think the main switch I have to do in my thinking is to change the way of creating domain models. Instead of using plain types like string or integer, it is intended to use custom types that already include a validation. and compose the domain model out of this.

The part that is still remains is the problem, when there are multiple properties that needs to be validated together.

@CLOVIS-AI
Copy link

We're hitting limitations of our approach sooner than I expected. Maybe we should step back and consider simpler alternatives?

Since we are going to make the Exact DSL use the Arrow DSL, we could further reduce boilerplate with:

class Raise<T> {
    fun ensure(predicate: Boolean, lazyFailure: () -> T) {}
}


class Foo private constructor(
    val a: Int,
    val b: Int,
    checked: Unit, // hack to force the signature to be different
) {
    
    context(Raise<String>)
    constructor(a: Int, b: Int) : this(a, b, Unit) {
        ensure(a != 2) { "Single parameter check" }
        ensure(a > 1 || b < 0) { "Multiple parameters check" }
    }
}

Though that does tie us to context receivers (and this example doesn't compile currently).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants