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

[DRAFT] Exact Builder Dsl #7

Closed

Conversation

ILIYANGERMANOV
Copy link
Collaborator

Created a prototype of an exactBuilder() which can allow us to build complex domain types easily following the already known "Builder" pattern. See #6 (comment)

💡 In a nutshell, we have two types of operations:

  • validation: a predicate (A) -> Boolean that is:
    • safe to compose
    • commutative, composition order doesn't matter
    • e.g NotBlankString
  • transformation: mapping (A) -> B:
    • order matters
    • e.g. TrimmedString

Demo:

@JvmInline
value class Username(val value: String) {
  companion object : Exact<String, Username> by exactBuilder({ builder ->
    builder.mustBe(String::isNotBlank)
      .transform(String::trim)
      .mustBe { it.all(Char::isLetterOrDigit) }
      .transform { it.uppercase() }
      .build(::Username)
  })
}

What are your thoughts?

@ILIYANGERMANOV
Copy link
Collaborator Author

ILIYANGERMANOV commented May 5, 2023

💡 [IDEA] We can also add applyExact(Exact) and applyExact(ExactBuilder) to the builder DSL to compose existing constraints/exacts that we have.

@ustitc
Copy link
Collaborator

ustitc commented May 5, 2023

💡 [IDEA] We can also add applyExact(Exact) and applyExact(ExactBuilder) to the builder DSL to compose existing constraints/exacts that we have.

A good idea! Another name suggestions: with or and

@ustitc
Copy link
Collaborator

ustitc commented May 5, 2023

Great job @ILIYANGERMANOV 💪 , I like builder DSL even more than what I have proposed in the previous PR, actually don't have anything to add here. I can't wait to start using Exact in my projects

We are only are missing one thing, on DSL example it became even more obvious that we need an ability to have custom errors like

ArtificialConstraintType.from(" ").mapLeft {
  when(it) {
    is InvalidUser -> TODO()
    is BadInput -> TODO()  
  }
}

🤔 We will face with two problems

  1. How to not overcomplicate Exact signature. Probably we can introduce another interface that will hold a type for a custom error like class MyType : ExactAware<MyCustromError>
  2. How to chain errors when combining multiple Exact-s

build.gradle.kts Outdated Show resolved Hide resolved
@ILIYANGERMANOV
Copy link
Collaborator Author

I'm glad that you liked it! :) And very good point @ustits 👍 You're right, I also think that we should support custom errors. I haven't tried it but there should be an easy way to introduce a generic type E for the error and build a "simple" extensions with ExactError for people who don't care about having a customer error. We should definitely play with that and explore it further

@ILIYANGERMANOV
Copy link
Collaborator Author

💡 [IDEA] We can also add applyExact(Exact) and applyExact(ExactBuilder) to the builder DSL to compose existing constraints/exacts that we have.

A good idea! Another name suggestions: with or and

Yes, with is a better name. I'm also thinking about inherit? But for now with sounds better to me.
Let's try the above example with few modifications:

@JvmInline
value class Username(val value: String) {
  companion object : Exact<String, Username> by exactSpec({ spec ->
    spec.mustBe(String::isNotBlank)
      .with(NotOffensiveName.Companion) // Exact<String, NotOffensiveName>
      .transform { it.value.trim() } // we need to flatten the NotOffensiveName wrapping
      .mustBe { it.all(Char::isLetterOrDigit) }
      .transform { it.uppercase() }
      .create(::Username)
  })
}

IMO, the creation of any arbitrary Exact type is already easy and obvious enough.
What we should think of is:

  • supporting generic error types
  • Simplifying usage PositiveInt.from(x) is easy to create but hard to operate
val x = PositiveInt.fromOrThrow(2)
val y = PositiveInt.fromOrThrow(3)
val z = PositiveInt.fromOrThrow(10)

// How do we do "(x + y)/z"
PositiveInt.from(
  (x.value + y.value) / z.value
)

I'm wondering can we do better than that?
IMO, we should provide map, mapOrThrow, mapOrNull extensions over the Exact value wrappers instances. @ustits @nomisRev what do you think?

val first = NotBlankTrimmedString("Iliyan") // we can have operator fun invoke() = fromOrThrow
val last = NotBlankTrimmedString("Germanov")

val fullName = first.map { "$it ${last.value}" }
// or
combine(first, last) { f, l -> "$f $l" } // combineOrThrow, ...

💡 For brainstorming purposes 👇
In a nutshell, I believe that:

  • Exact value wrappers should extend ExactWrapper/ExactValueClass or something
  • the ExactValue should provide an easy way to get wrapped value
  • having the above we can make it a monad and define helping extensions
  • map, mapOrNull, ...: unwraps, maps the value and wrap it agains by executing the constraints
  • combine: similarl to the Kotlin Flow combine (I'm not sure how helpful is that)
  • unwrap: a function that returns the underlying value of any arbitrary ExactWrapper

@ustitc
Copy link
Collaborator

ustitc commented May 6, 2023

  1. Played around with our DSL and have to say that without custom errors it is pretty unusable, we definitely need to define them to be able to do something like:
Amount.from(10)
    .mapLeft {
        when (it) {
            NonPositiveNumber -> ValidationError("Amount must be positive")
            is NumberTooLarge -> ValidationError("Amount is too large. Enter value less than: ${it.limit}")
        }
    }

I can prepare a PR with changes, but then we will need to update DSL as well

  1. @ILIYANGERMANOV One more idea came to my mind, maybe we can reuse ensure semantics instead of builders? Something like
  sealed class ArtificialError(val message: String) {
    object BadFormat : ArtificialError("Bad format")
    class BadUserId(id: String) : ArtificialError("Invalid user id in: $id")
    class Unknown(value: String) : ArtificialError("Unknown value: $value")
  }

  exact({ str ->
    ensure(str, NotBlankTrimmedString) {
      ArtificialError.BadFormat
    }
    val type = when {
      str.startsWith("a/") -> Artificial.Admin
      str.startsWith("u/") -> {
        val userId = it.drop(2).toIntOrNull()
        ensureNotNull(userId) { ArtificialError.BadUserId(str) }
        Artificial.User(d)
      }

      else -> raise(ArtificialError.Unknown(str))
    }
    ArtificialConstraintType(type)
  })

@ILIYANGERMANOV
Copy link
Collaborator Author

ILIYANGERMANOV commented May 6, 2023

  1. Played around with our DSL and have to say that without custom errors it is pretty unusable, we definitely need to define them to be able to do something like:
Amount.from(10)
    .mapLeft {
        when (it) {
            NonPositiveNumber -> ValidationError("Amount must be positive")
            is NumberTooLarge -> ValidationError("Amount is too large. Enter value less than: ${it.limit}")
        }
    }

I can prepare a PR with changes, but then we will need to update DSL as well

  1. @ILIYANGERMANOV One more idea came to my mind, maybe we can reuse ensure semantics instead of builders? Something like
  sealed class ArtificialError(val message: String) {
    object BadFormat : ArtificialError("Bad format")
    class BadUserId(id: String) : ArtificialError("Invalid user id in: $id")
    class Unknown(value: String) : ArtificialError("Unknown value: $value")
  }

  exact({ str ->
    ensure(str, NotBlankTrimmedString) {
      ArtificialError.BadFormat
    }
    val type = when {
      str.startsWith("a/") -> Artificial.Admin
      str.startsWith("u/") -> {
        val userId = it.drop(2).toIntOrNull()
        ensureNotNull(userId) { ArtificialError.BadUserId(str) }
        Artificial.User(d)
      }

      else -> raise(ArtificialError.Unknown(str))
    }
    ArtificialConstraintType(type)
  })

Nicely done @ustits! I like it! Feel free to draft the PR 👍
Let's:

  • drop the builder in favor of a Raise<E>.(A) -> Refined block
  • add support for a custom error E

I'm also playing around with a few other ideas but this is definitely an improvement. Feel free to change anything in your PRs, we're exploration phase so all ideas/changes are more than welcome! IMO, we should target simplicity and every complexity that we remove is more than welcome!

@ILIYANGERMANOV
Copy link
Collaborator Author

ILIYANGERMANOV commented May 6, 2023

This "builder' pattern is obsolete. @ustits suggested a less complex approach which I find way better.

@ILIYANGERMANOV
Copy link
Collaborator Author

Hi @ustits can you have a look at #8 I implemented your approach with Raise + a support for generic errors? 👀

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

Successfully merging this pull request may close these issues.

2 participants