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

non-orphan implicit typeclasses #912

Merged
merged 11 commits into from
May 11, 2023
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,8 @@ lazy val example = projectMatrix
(ThisBuild / baseDirectory).value / "sampleSpecs" / "mixins.smithy",
(ThisBuild / baseDirectory).value / "sampleSpecs" / "defaults.smithy",
(ThisBuild / baseDirectory).value / "sampleSpecs" / "quoted_string.smithy",
(ThisBuild / baseDirectory).value / "sampleSpecs" / "numeric.smithy"
(ThisBuild / baseDirectory).value / "sampleSpecs" / "numeric.smithy",
(ThisBuild / baseDirectory).value / "sampleSpecs" / "typeclass.smithy"
),
Compile / resourceDirectory := (ThisBuild / baseDirectory).value / "modules" / "example" / "resources",
libraryDependencies += Dependencies.Http4s.emberServer.value,
Expand Down
2 changes: 2 additions & 0 deletions modules/codegen/src/smithy4s/codegen/internals/IR.scala
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ private[internals] object Hint {
case object IndexedSeq extends SpecializedList
}
case object UniqueItems extends Hint
case class Typeclass(id: ShapeId, targetType: String, interpreter: String)
Copy link
Contributor

@yisraelU yisraelU Apr 21, 2023

Choose a reason for hiding this comment

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

A nice-to-have would be a type alias wherever a String refers to a Class (for doc purposes)
Perhaps this should extend to the Smithy definitions too

case class Typeclasses(values: NonEmptyList[Typeclass]) extends Hint

implicit val eq: Eq[Hint] = Eq.fromUniversalEquals
}
Expand Down
28 changes: 25 additions & 3 deletions modules/codegen/src/smithy4s/codegen/internals/Renderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,24 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
}
}

private def renderTypeclass(hint: Hint.Typeclass, tpe: NameRef): Line = {
val target = NameRef(hint.targetType)
val interpreter = NameRef(hint.interpreter)
val lowerCasedName = s"${tpe.name.head.toLower.toString}${tpe.name.tail}"
Copy link
Contributor

@Baccata Baccata Apr 21, 2023

Choose a reason for hiding this comment

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

I think we have a helper for this : uncapitalise

line"implicit val $lowerCasedName${hint.id.getName.capitalize}: $target[$tpe] = $interpreter.fromSchema(schema)"
}

private def renderTypeclasses(
hints: List[Hint],
tpe: NameRef
): Option[Lines] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

You can return Lines

(hints.collectFirst { case h: Hint.Typeclasses =>
Copy link
Contributor

Choose a reason for hiding this comment

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

I find collectFirst to be a bit misleading. There can be only one ,of type Hint.Typeclass and if there could be more , I think we would want them all

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not misleading, I think it's just wrong. It should be collect here.

Copy link
Contributor

Choose a reason for hiding this comment

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

oh of course .I was thinking this was Hints as opposed to a List of hint

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it's right. There should only ever be one Typeclasses hint which may contain multiple 'Typeclass' instances. It doesn't need to be modeled that way, but that's how I did it so we'd keep the idea that you can only have 1 of a given trait/hint per shape. Collect first is just so I could do the find and map at the same time

Copy link
Contributor

Choose a reason for hiding this comment

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

but that's how I did it so we'd keep the idea that you can only have 1 of a given trait/hint per shape

I've "violated" that assumption in my commit 😅. For non-meta hints, I think you are correct, but there is a distinction to be had between hints that get rendered and available at runtime and hints that aren't. Trying to abide by the invariant at codegen time seems to be detrimental to the readability of the code .

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, sorry ,should have amended my comment. It wasn't "wrong", I was just confused between the existence of a Typeclass and a Typeclasses type, which got mixed together in my brain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah that totally makes sense, I think it's clearer without the extra typeclasses hint so works for me 👍🏼

newline ++ Lines(
h.values.map(renderTypeclass(_, tpe)).toList
)
})
}

private def renderProductNonMixin(
product: Product,
adtParent: Option[NameRef],
Expand Down Expand Up @@ -591,6 +609,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
} else {
line"implicit val schema: $Schema_[${product.nameRef}] = $constant_(${product.nameRef}()).withId(id).addHints(hints)"
},
renderTypeclasses(product.hints, product.nameRef),
additionalLines
)
)
Expand Down Expand Up @@ -842,7 +861,8 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
if (error) "" else ".withId(id).addHints(hints)"
)
.appendToLast(if (recursive) ")" else "")
}
},
renderTypeclasses(hints, name)
)
)
}
Expand Down Expand Up @@ -914,7 +934,8 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
line"val values: $list[$name] = $list".args(
values.map(_.name)
),
line"implicit val schema: $Schema_[$name] = $enumeration_(values).withId(id).addHints(hints)"
line"implicit val schema: $Schema_[$name] = $enumeration_(values).withId(id).addHints(hints)",
renderTypeclasses(hints, name)
)
)

Expand All @@ -940,7 +961,8 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
line"val underlyingSchema: $Schema_[$tpe] = ${tpe.schemaRef}$trailingCalls",
lines(
line"implicit val schema: $Schema_[$name] = $definition$bijection_(underlyingSchema, asBijection)$closing"
)
),
renderTypeclasses(hints, name)
)
)
}
Expand Down
27 changes: 26 additions & 1 deletion modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import scala.annotation.nowarn
import scala.jdk.CollectionConverters._

import Type.Alias
import smithy4s.meta.TypeclassTrait

private[codegen] object SmithyToIR {

Expand Down Expand Up @@ -810,6 +811,29 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
}
}

def maybeTypeclassesHint(shape: Shape): Option[Hint.Typeclasses] = {
val result =
shape
.getAllTraits()
.asScala
.flatMap { case (_, trt) =>
model
.getShape(trt.toShapeId)
.asScala
.flatMap(_.getTrait(classOf[TypeclassTrait]).asScala)
.map(trt -> _)
}
.map { case (typeclassName, typeclassInfo) =>
Hint.Typeclass(
typeclassName.toShapeId,
typeclassInfo.getTargetType,
typeclassInfo.getInterpreter
)
}
.toList
result.toNel.map(Hint.Typeclasses)
}

@annotation.nowarn(
"msg=class UniqueItemsTrait in package traits is deprecated"
)
Expand Down Expand Up @@ -897,7 +921,8 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
}
traits.collect(traitToHint(shape)) ++
documentationHint(shape) ++
nonConstraintNonMetaTraits.map(unfoldTrait)
nonConstraintNonMetaTraits.map(unfoldTrait) ++
maybeTypeclassesHint(shape)
}

case class AltInfo(name: String, tpe: Type, isAdtMember: Boolean)
Expand Down
69 changes: 69 additions & 0 deletions modules/docs/markdown/04-codegen/01-customisation/09-typeclass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
sidebar_label: Typeclass Instances
title: Non-Orphan Typeclass Instances
---

As of smithy4s version `0.18.x` you are able to create custom typeclass instances inside the companion objects of classes in the code generated by smithy4s. This allows you to have instances that are found via implicit resolution without any need to special imports. Common examples where this will come in handy are for the `cats.Show` and `cats.Eq` typeclasses, but you can use this feature for any typeclass.

Here we will show an example using the `cats.Eq` typeclass.

## Setup Typeclass in Smithy

Here we will use the `smithy4s.meta#typeclass` trait to define an `eq` trait that represents the `cats.Eq` typeclass.

```smithy
use smithy4s.meta#typeclass

@trait
@typeclass(targetType: "cats.Eq", interpreter: "smithy4s.example.typeclass.EqInterpreter")
structure eq {}
```

We are specifying `cats.Eq` as the `targetType` since that is the typeclass which this trait represents. We are then specifying `smithy4s.example.typeclass.EqInterpreter` as the classpath which points to a `CachedSchemaCompiler` for the `cats.Eq` typeclass.

## Implement CachedSchemaCompiler

Smithy4s has a concept called `CachedSchemaCompiler` which is an abstraction which we use here to interpret a `smithy4s.Schema` to produce an instance of a typeclass. Here is what this will look like:

```scala
object EqInterpreter extends CachedSchemaCompiler.Impl[Eq] {

protected type Aux[A] = Eq[A]

def fromSchema[A](
schema: Schema[A],
cache: Cache
): Eq[A] = {
schema.compile(new EqSchemaVisitor(cache))
}

}
```

Here we are delegating to the `EqSchemaVisitor` which is doing the heavy lifting of interpreting the `smithy4s.Schema`. The `CachedSchemaCompiler.Impl` provides a `Cache` which we utilize to make sure we are not recompiling the same schema more than once. For more details on implementing a `SchemaVisitor`, you can check out the full `cats.Eq` schema compiler and visitor [here](<!-- TODO ADD LINK ONCE AVAILABLE -->).

## Use the eq typeclass trait

Now we are ready to use the `eq` trait we defined above.

```smithy
@eq
structure MovieTheater {
name: String
}
```

This tells smithy4s to generate an instance of the `Eq` typeclass in the companion object of the `MovieTheater` type and to use the `CachedSchemaCompiler` defined above for the implementation. The generated code will look like:

```scala
case class MovieTheater(name: Option[String] = None)
object MovieTheater extends ShapeTag.Companion[MovieTheater] {
val id: ShapeId = ShapeId("smithy4s.example", "MovieTheater")

// ...

implicit val schema: Schema[MovieTheater] = // ...

implicit val movieTheaterEq: cats.Eq[MovieTheater] = EqInterpreter.fromSchema(schema)
}
```
18 changes: 18 additions & 0 deletions modules/example/src/smithy4s/example/Eq.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.ShapeTag
import smithy4s.schema.Schema.constant

case class Eq()
object Eq extends ShapeTag.Companion[Eq] {
val id: ShapeId = ShapeId("smithy4s.example", "eq")

val hints: Hints = Hints(
smithy.api.Trait(selector = None, structurallyExclusive = None, conflicts = None, breakingChanges = None),
)

implicit val schema: Schema[Eq] = constant(Eq()).withId(id).addHints(hints)
}
26 changes: 26 additions & 0 deletions modules/example/src/smithy4s/example/MovieTheater.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.ShapeTag
import smithy4s.example.typeclass.EqInterpreter
import smithy4s.schema.Schema.string
import smithy4s.schema.Schema.struct

case class MovieTheater(name: Option[String] = None)
object MovieTheater extends ShapeTag.Companion[MovieTheater] {
val id: ShapeId = ShapeId("smithy4s.example", "MovieTheater")

val hints: Hints = Hints(
smithy4s.example.Eq(),
)

implicit val schema: Schema[MovieTheater] = struct(
string.optional[MovieTheater]("name", _.name),
){
MovieTheater.apply
}.withId(id).addHints(hints)

implicit val movieTheaterEq: cats.Eq[MovieTheater] = EqInterpreter.fromSchema(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.

This is the main thing to look at here. I still have a ways to go on this PR, but I wanted to put it up now to get early feedback before I get further along with it.

Copy link
Member

Choose a reason for hiding this comment

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

Looks great to me.

}
37 changes: 37 additions & 0 deletions modules/example/src/smithy4s/example/NetworkConnectionType.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package smithy4s.example

import smithy4s.Enumeration
import smithy4s.Hints
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.ShapeTag
import smithy4s.example.typeclass.EqInterpreter
import smithy4s.schema.Schema.enumeration

sealed abstract class NetworkConnectionType(_value: String, _name: String, _intValue: Int, _hints: Hints) extends Enumeration.Value {
override type EnumType = NetworkConnectionType
override val value: String = _value
override val name: String = _name
override val intValue: Int = _intValue
override val hints: Hints = _hints
override def enumeration: Enumeration[EnumType] = NetworkConnectionType
@inline final def widen: NetworkConnectionType = this
}
object NetworkConnectionType extends Enumeration[NetworkConnectionType] with ShapeTag.Companion[NetworkConnectionType] {
val id: ShapeId = ShapeId("smithy4s.example", "NetworkConnectionType")

val hints: Hints = Hints(
smithy4s.example.Eq(),
)

case object ETHERNET extends NetworkConnectionType("ETHERNET", "ETHERNET", 0, Hints())
case object WIFI extends NetworkConnectionType("WIFI", "WIFI", 1, Hints())

val values: List[NetworkConnectionType] = List(
ETHERNET,
WIFI,
)
implicit val schema: Schema[NetworkConnectionType] = enumeration(values).withId(id).addHints(hints)

implicit val networkConnectionTypeEq: cats.Eq[NetworkConnectionType] = EqInterpreter.fromSchema(schema)
}
44 changes: 44 additions & 0 deletions modules/example/src/smithy4s/example/PersonContactInfo.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.ShapeTag
import smithy4s.example.typeclass.EqInterpreter
import smithy4s.schema.Schema.bijection
import smithy4s.schema.Schema.union

sealed trait PersonContactInfo extends scala.Product with scala.Serializable {
@inline final def widen: PersonContactInfo = this
}
object PersonContactInfo extends ShapeTag.Companion[PersonContactInfo] {
val id: ShapeId = ShapeId("smithy4s.example", "PersonContactInfo")

val hints: Hints = Hints(
smithy4s.example.Eq(),
)

case class EmailCase(email: PersonEmail) extends PersonContactInfo
case class PhoneCase(phone: PersonPhoneNumber) extends PersonContactInfo

object EmailCase {
val hints: Hints = Hints.empty
val schema: Schema[EmailCase] = bijection(PersonEmail.schema.addHints(hints), EmailCase(_), _.email)
val alt = schema.oneOf[PersonContactInfo]("email")
}
object PhoneCase {
val hints: Hints = Hints.empty
val schema: Schema[PhoneCase] = bijection(PersonPhoneNumber.schema.addHints(hints), PhoneCase(_), _.phone)
val alt = schema.oneOf[PersonContactInfo]("phone")
}

implicit val schema: Schema[PersonContactInfo] = union(
EmailCase.alt,
PhoneCase.alt,
){
case c: EmailCase => EmailCase.alt(c)
case c: PhoneCase => PhoneCase.alt(c)
}.withId(id).addHints(hints)

implicit val personContactInfoEq: cats.Eq[PersonContactInfo] = EqInterpreter.fromSchema(schema)
}
20 changes: 20 additions & 0 deletions modules/example/src/smithy4s/example/PersonEmail.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Newtype
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.example.typeclass.EqInterpreter
import smithy4s.schema.Schema.bijection
import smithy4s.schema.Schema.string

object PersonEmail extends Newtype[String] {
val id: ShapeId = ShapeId("smithy4s.example", "PersonEmail")
val hints: Hints = Hints(
smithy4s.example.Eq(),
)
val underlyingSchema: Schema[String] = string.withId(id).addHints(hints)
implicit val schema: Schema[PersonEmail] = bijection(underlyingSchema, asBijection)

implicit val personEmailEq: cats.Eq[PersonEmail] = EqInterpreter.fromSchema(schema)
}
20 changes: 20 additions & 0 deletions modules/example/src/smithy4s/example/PersonPhoneNumber.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Newtype
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.example.typeclass.EqInterpreter
import smithy4s.schema.Schema.bijection
import smithy4s.schema.Schema.string

object PersonPhoneNumber extends Newtype[String] {
val id: ShapeId = ShapeId("smithy4s.example", "PersonPhoneNumber")
val hints: Hints = Hints(
smithy4s.example.Eq(),
)
val underlyingSchema: Schema[String] = string.withId(id).addHints(hints)
implicit val schema: Schema[PersonPhoneNumber] = bijection(underlyingSchema, asBijection)

implicit val personPhoneNumberEq: cats.Eq[PersonPhoneNumber] = EqInterpreter.fromSchema(schema)
}
2 changes: 2 additions & 0 deletions modules/example/src/smithy4s/example/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ package object example {
type ArbitraryData = smithy4s.example.ArbitraryData.Type
type DogName = smithy4s.example.DogName.Type
type SomeVector = smithy4s.example.SomeVector.Type
type PersonPhoneNumber = smithy4s.example.PersonPhoneNumber.Type
type FancyList = smithy4s.example.FancyList.Type
type DefaultStringMap = smithy4s.example.DefaultStringMap.Type
@deprecated
Expand All @@ -40,6 +41,7 @@ package object example {
@deprecated
type DeprecatedString = smithy4s.example.DeprecatedString.Type
type ObjectSize = smithy4s.example.ObjectSize.Type
type PersonEmail = smithy4s.example.PersonEmail.Type
type NonEmptyCandies = smithy4s.example.NonEmptyCandies.Type
type SomeIndexSeq = smithy4s.example.SomeIndexSeq.Type
type StringList = smithy4s.example.StringList.Type
Expand Down
Loading