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

Typesafe directive - for wrapping case classes on fields (incl. input types) and arguments #2091

Merged

Conversation

develeon
Copy link
Contributor

@develeon develeon commented Jan 21, 2024

By using @typesafe directive, the code generator will wrap your typesafe fields in case classes without
imposing further changes to GraphQl schema. Hence allowing type safety on important identifiers and fields.
Tested with Option and List as well.

Schema example:

directive @typesafe(name : String) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION

type Query {
  getUser(id: ID! @typesafe(name: "UserId")): User
}

type User {
  id: ID! @typesafe(name: "UserId")
  ...
}

with ID as scalar mapping to Int you would get something like this:

case class UserId(value: Int) extends AnyVal
object UserId {
  implicit val schema: Schema[Any, UserId] = summon[Schema[Any, Int]].contramap(_.value)
  implicit val argBuilder: ArgBuilder[UserId] = summon[ArgBuilder[Int]].map(UserId(_))
}

final case class QueryGetUserArgs(id: UserId)

final case class User(
  @GQLDirective(Directive("typesafe", Map("name" -> StringValue("UserId"))))
  id: UserId,
  ...
}

See SchemaWriterSpec.scala for more extensive type mapping.

@develeon develeon force-pushed the series/2.x_typesafe_directive branch 2 times, most recently from d64bcb9 to 9657ad0 Compare January 21, 2024 22:15
@oyvindberg
Copy link
Contributor

To give some background in case it's not obvious: We're working on improving the story for schema-first development. One thing we identified which we really wanted was to have statically typed IDs on the backend.

So given that preference, we identified a few paths forward:

  • add a scalar for each ID type to our schema. As far as I understand, this pushes some complexity over to the clients of the API in that they need to provide a mapping for them when generating code and so on. We want the API to be easily consumable, so that's not optimal.
  • add object types for each ID type in the schema. In this case clients will need to handle the wrapping themselves for all queries, and using the API will feel different and less convenient than a normal graphql API.
  • This solution where we "tag" ID's as being of different flavours with directives. This way, clients will just see ID like in any other graphql API. They are free to read the annotated ID types in the schema as documentation, or customize their own codegen in the same manner as we suggest here. I think this is a very powerful and flexible technique to solve this use case.

Any thoughts on this @ghostdogpr?

@ghostdogpr
Copy link
Owner

This looks good! I just need a little bit of time to review because it's code I'm not super familiar with (schema codegen was mostly contributed externally). Worst case scenario I'll take a look during the weekend 👍

@kyri-petrou if you have time feel free to review as well

@oyvindberg
Copy link
Contributor

Nice!

One very obvious todo is that it needs to be documented, likely alongside the @lazy changes from earlier @develeon .

Can do that after we agree on the exact path forward here 👍

@develeon
Copy link
Contributor Author

Nice!

One very obvious todo is that it needs to be documented, likely alongside the @lazy changes from earlier @develeon .

Can do that after we agree on the exact path forward here 👍

I'm all up for that. Also I think the @lazy directive seems very powerful, so it would be a pity if it where not to be used due to limited documentation.

@oyvindberg
Copy link
Contributor

Oh, it is! check https://ghostdogpr.github.io/caliban/docs/server-codegen.html#lazy-evaluation . I meant we should document this on the same page

@kyri-petrou
Copy link
Collaborator

At a glance it looks good, but I wanna take some more time and understand the code better since I'm not too familiar with it either.

One question that comes to mind at a very high level, why @typesafe? Is this a directive already used by other schema-first libraries?

PS: I'm don't mind it either way, I'm just curious on whether there's any specific reasoning behind it :)

@develeon
Copy link
Contributor Author

develeon commented Jan 23, 2024

There is no particular reason for the directive name, so open for suggestions.
Had some other working names, but decided to go back to using @typesafe as feel that more descriptive to what we want to achieve.

Copy link
Owner

@ghostdogpr ghostdogpr left a comment

Choose a reason for hiding this comment

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

About the name @typesafe sounds a little bit too general for what it does here? How about something like @typeAlias?


val LazyDirective = "lazy"
val TypesafeDirective = "typesafe"
val DeprecatedDiretive = "deprecated"
Copy link
Owner

Choose a reason for hiding this comment

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

typo: Diretive => Directive

def isTypesafe(directives: List[Directive]): Boolean =
directives.exists(_.name == TypesafeDirective)
def typesafeName(directives: List[Directive]): Option[String] =
Directives
Copy link
Owner

Choose a reason for hiding this comment

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

You can call findDirective directly

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well spotted!

def writeInputValue(value: InputValueDefinition): String =
s"""${writeDescription(value.description)}${safeName(value.name)} : ${writeType(value.ofType)}"""
def writeGQLTypesafeDirective(field: FieldDefinition) =
if (field.directives.exists(_.name == TypesafeDirective)) {
Copy link
Owner

Choose a reason for hiding this comment

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

nitpick: rather than exists + filter + head, you could use find and then fold on the Option. Same below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks I even found some further optimization cause of this :)

val fnName = directive.arguments("name").toInputString.replace("\"", "")
val typesafeType = writeTypesafeType(fieldType)

s"""case class $fnName(value : $typesafeType) extends AnyVal
Copy link
Owner

Choose a reason for hiding this comment

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

Do we need to sanitize the name?

@kyri-petrou
Copy link
Collaborator

How about something like @typeAlias

I was thinking the same. Another option would be @newtype but not sure how well known is that term

@develeon develeon force-pushed the series/2.x_typesafe_directive branch from 678ee91 to 977b8fa Compare January 25, 2024 23:07
@develeon
Copy link
Contributor Author

develeon commented Jan 25, 2024

How about something like @typeAlias

I was thinking the same. Another option would be @newtype but not sure how well known is that term

I refactor to use @newtype, think at least from world of Haskell it is established and fit well with our application. But if you prefer @typeAlias I will update it.

Also added documentation.

@develeon develeon force-pushed the series/2.x_typesafe_directive branch from d950194 to 7389539 Compare January 27, 2024 19:09
Copy link
Collaborator

@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

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

Looks great overall! My main comment / concern is that unless I'm missing something, the summon keyword wouldn't work when generating Scala 2 code

Comment on lines 9 to 11
val LazyDirective = "lazy"
val NewtypeDirective = "newtype"
val DeprecatedDirective = "deprecated"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Very minor, but perhaps make these final val instead


s"""case class $fnName(value : $newtype) extends AnyVal
|object $fnName {
| implicit val schema: Schema[Any, $fnName] = summon[Schema[Any, $newtype]].contramap(_.value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm a bit confused, does summon work for Scala 2?

Also, perhaps just use Schema[$newtype].contramap(_.value) instead, the Schema.apply method "summons" the implicit Schema

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could not get that to work, but used implicitly (Scala 2 version of summon).
Tested it on my Scala 3 setup and that worked, but have to double check my Scala options.

Copy link
Owner

Choose a reason for hiding this comment

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

To catch these kind of issues it would be nice to add the new annotation to the sbt plug-in "scripted" test suite because it runs both on scala 2 and scala 3

s"""case class $fnName(value : $newtype) extends AnyVal
|object $fnName {
| implicit val schema: Schema[Any, $fnName] = summon[Schema[Any, $newtype]].contramap(_.value)
| implicit val argBuilder: ArgBuilder[$fnName] = summon[ArgBuilder[$newtype]].map($fnName(_))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same as above, I believe you can use [ArgBuilder[$newtype].map($fnName(_))

Copy link
Collaborator

@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

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

I've never worked on this part of the codebase, but AFAICT it looks great :) The only thing that "saddens" me a bit is that we don't get to utilize Scala 3's opaque types, but I think it's likely for the best to have a unified Scala 2/3 implementation.

Thank you very much for the contribution, really highly appreciated!

@develeon
Copy link
Contributor Author

I've never worked on this part of the codebase, but AFAICT it looks great :) The only thing that "saddens" me a bit is that we don't get to utilize Scala 3's opaque types, but I think it's likely for the best to have a unified Scala 2/3 implementation.

Thank you very much for the contribution, really highly appreciated!

If we are able to support opaque types in the future I think we can just add a type argument to the directive.
ex. directive @newtype(name : String, type : String)

But I did try bit with opaque types initially and ran into a issue, if I recall it was:
Failed to synthesize an instance of type deriving.Mirror.Of

Hence similar to the issue for union types: #1926

@kyri-petrou
Copy link
Collaborator

But I did try bit with opaque types initially and ran into a issue, if I recall it was: Failed to synthesize an instance of type deriving.Mirror.Of

With opaque types, you have to generate the Schema in the companion object of the opaque type for it to work, something along these lines:

import scala.compiletime.summonInline

opaque type Foo = Int
object Foo {
  inline given Schema[Any, Foo] = summonInline[Schema[Any, Int]]
}

Either way, I think let's steer off opaque types for now and we can revisit this if there is interest in having that option

@develeon
Copy link
Contributor Author

Removed documentation from this PR
and created new PR with it here : #2101

@ghostdogpr ghostdogpr merged commit 9c3d9c9 into ghostdogpr:series/2.x Jan 30, 2024
10 checks passed
@ghostdogpr
Copy link
Owner

Thanks!

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.

None yet

4 participants