8000 Require discriminator on polymorphic queries by rpiaggio · Pull Request #855 · gemini-hlsw/clue · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 60 additions & 17 deletions README.md
< 8000 td class="blob-code blob-code-deletion js-file-line"> ``` scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ Experimental GraphQL client for Scala and Scala.js.
### 1) Create a client

Either:
* A `FetchClient[F[_], S]` (supporting queries and mutations), or
* A `StreamingClient[F[_], S]` (supporting queries, mutations and subscriptions).

`S` is a type denoting the schema. It can be any type, even a phantom type. It's only used to type-match clients and operations.
- A `FetchClient[F[_], S]` (supporting queries and mutations), or
- A `StreamingClient[F[_], S]` (supporting queries, mutations and subscriptions).

`S` is a type denoting the schema. It can be any type, even a phantom type. It's only used to type-match clients and operations.

#### Example

``` scala
```scala
import clue.*
import cats.effect.IO

Expand Down Expand Up @@ -59,7 +60,7 @@ Either:

They must extend `GraphQLOperation[S]`, defining the following members:

``` scala
```scala
val document: GraphQLDocument // built with the `gql` interpolator (see below)

type Variables
Expand All @@ -78,7 +79,7 @@ subqueries correctly (see [Subquery variables](#subquery-variables) below). `.st

#### Example

```scala
import io.circe._
import io.circe.generic.semiauto._

Expand Down Expand Up @@ -114,7 +115,7 @@ generator — no code generation involved. Any definition extending `GraphQLOper
`GraphQLOperation.Typed[S, ...]` (checked via its `document`) or `GraphQLSubquery[S]` /
`GraphQLSubquery.Typed[S, ...]` (checked via its `subquery`) is validated; fields, arguments,
variables and deprecations that don't typecheck against schema `S` are reported as scalafix
diagnostics. Operations that *are* generated (annotated with `@GraphQL`) are validated during
diagnostics. Operations that _are_ generated (annotated with `@GraphQL`) are validated during
generation.

A subquery declares the GraphQL root type(s) its selection applies to with a
Expand All @@ -134,6 +135,47 @@ Hand-written subqueries may declare **multiple** types (the selection is validat
`@GraphQLType("Human", "Droid")`). A subquery processed by the generator (`@GraphQL`) must declare
**exactly one** type. `@GraphQLType` is only valid on a subquery, not on a `GraphQLOperation`.

#### Interfaces and unions

When a selection on an interface or union field has inline fragments (or fragment spreads) on its
subtypes, the generator emits a `sealed trait` for the field, one case class per fragment type
carrying the shared fields plus the fragment's own, and, if the schema has concrete types none of
the fragments cover, a case class `Other` with just the shared fields. For example, the StarWars
selection

```graphql
character { __typename id name ... on Human { homePlanet } }
```

(`character`'s type is an interface implemented by `Human` and `Droid`) generates roughly:

```scala
sealed trait Character { val id: String; val name: Option[String] }
object Character {
case class Human(id: String, name: Option[String], homePlanet: Option[String]) extends Character
case class Other(id: String, name: Option[String]) extends Character // Droid, uncovered
}
```

The selection **must** include `__typename` at the same level as the fragments; the generated
decoder switches on it to pick the case class to decode into. Omitting it is a generation error: "Selection on [...] has fragments on subtypes [...] but does not select `__typename`". It must also
be selected **unconditionally**: with `@skip`/`@include` on it, or on a fragment that encloses it,
generation fails with "... its `__typename` is selected with `@skip`/`@include`". A `__typename`
inside a fragment on the field's own type (plain grouping, e.g. `... on Character { __typename }`)
counts, as long as neither it nor the fragment is conditional.

A bare `__typename` is consumed by the decoder and is not generated as a field. Aliased (e.g.
`kind: __typename`), it is both the decoder key and a regular `String` field on the trait and every
case class — the way to keep the raw type name around, e.g. for the types folded into `Other`.

Fragments whose type condition is the field's own type or one of its supertypes are plain grouping
(e.g. for `@include`), not subtypes, and are flattened rather than turned into cases.

A subquery is spliced as the whole selection set of a field (`field $Subquery`); splicing one inside
a fragment (`... on Human $HumanFields`) is rejected. A subquery may itself be rooted at an
interface/union (`@GraphQLType("Character")`) and use fragments in its own selection, in which case
its `Data` is the sealed trait.

##### Subquery variables

A subquery that references GraphQL variables **must** declare them in a `type VariableDefs` member,
Expand Down Expand Up @@ -165,7 +207,7 @@ without declaring it reads as "requires nothing" to callers. That omission is an
subquery is validated (it needs `@GraphQLType` and a configured schema), so keep validation enabled
on projects that publish subqueries.

To splice a subquery into an operation *and* have the operation's variables checked against it, build
To splice a subquery into an operation _and_ have the operation's variables checked against it, build
the `document` with the `gql` interpolator (from `clue`) instead of `s`:

```scala
Expand Down Expand Up @@ -225,7 +267,7 @@ this is reported as a warning rather than silently skipped.

#### Example

``` scala
```scala
fetchClient.request(CharacterQuery)(CharacterQuery.Variables("0001"))
.forEach(println).unsafeRunSync()

Expand All @@ -240,17 +282,18 @@ A `SpanKind.Client` span is emitted per HTTP request or subscription.
W3C trace context is automatically propagated to the server:

- **HTTP requests**: the current span's `traceparent` (and `tracestate`) is injected into outgoing
request headers, thus client spans can propagate to the server
request headers, thus client spans can propagate to the server
- **WebSocket requests**: `traceparent` is included in the `extensions` field, so the server can
create child reading `extensions.traceparent`.
create child reading `extensions.traceparent`.

Some span attributes recorded automatically:
* `clue.version`
* `http.request.method`
* `graphql.operation.type`
* `graphql.operation.name`
* `graphql.document`
* `clue.response.hasData`

- `clue.version`
- `http.request.method`
- `graphql.operation.type`
- `graphql.operation.name`
- `graphql.document`
- `clue.response.hasData`

And on errors `clue.response.hasErrors`, `clue.response.errorCount`, `clue.response.errors`.

Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
lazy val V = _root_.scalafix.sbt.BuildInfo

ThisBuild / tlBaseVersion := "0.58"
ThisBuild / tlBaseVersion := "0.59"
Global / onChangedBuildSource := ReloadOnSourceChanges

// The CI matrix covers Scala 3 only, which builds `sbt-clue` for sbt 2.x. Run the scripted tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ interface Character {
appearsIn: [Episode!]
}

type Human implements Character {
interface Pilot {
vehicle: String
}

type Human implements Character & Pilot {
id: String!
name: String
friends: [Character!]
more_friends: [Character!]
appearsIn: [Episode!]
homePlanet: String
vehicle: String
}

type Droid implements Character {
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/LucumaQuery.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ trait LucumaQuery extends GraphQLOperation[LucumaODB] {
id
name
tracking {
__typename
... on Sidereal {
epoch
}
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/LucumaQuery2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ trait LucumaQuery2 extends GraphQLOperation[LucumaODB] {
id
name
tracking {
__typename
... on Sidereal {
epoch
}
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/LucumaQuery3.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ trait LucumaQuery3 extends GraphQLOperation[LucumaODB] {
nodes {
id
observationTarget {
__typename
... on Target {
target_id: id
target_name: name
Expand Down
34 changes: 34 additions & 0 deletions gen/input/src/main/scala/test/StarWarsAliasedTypename.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

// Checks that an ALIASED `__typename` (`kind: __typename`) is used as the decoder
// discriminator key, AND is also generated as a regular field (`kind: String`) on the trait
// and every instance.
@GraphQL // assert: GraphQLGen
trait StarWarsAliasedTypename extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$ep: Episode!) {
hero(episode: $$ep) {
kind: __typename
name
... on Human {
homePlanet
}
... on Droid {
primaryFunction
}
}
}
"""
}
// format: on
30 changes: 30 additions & 0 deletions gen/input/src/main/scala/test/StarWarsAliasedTypenameOther.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

// Combines an ALIASED `__typename` discriminator with a fallback `Other` instance (`Droid` is
// left uncovered): `kind` must still be generated as a regular field on `Other` too.
@GraphQL
trait StarWarsAliasedTypenameOther extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$ep: Episode!) {
hero(episode: $$ep) {
kind: __typename
name
... on Human {
homePlanet
}
}
}
"""
}
// format: on
28 changes: 28 additions & 0 deletions gen/input/src/main/scala/test/StarWarsHumanSubquery.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLSubquery
import clue.annotation.GraphQL
import clue.annotation.GraphQLType

@GraphQL
@GraphQLType("Human")
abstract class StarWarsHumanSubquery extends GraphQLSubquery[StarWars] {

override val subquery = gql"""
{
homePlanet
}
"""
}

@clue.annotation.GraphQLStub
object StarWarsHumanSubquery
// format: on
31 changes: 31 additions & 0 deletions gen/input/src/main/scala/test/StarWarsInterfaceSubtype.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

// A fragment on an interface (`Pilot`, implemented only by `Human`): `__typename` in a response is
// always the concrete object type name (`Human`), never the interface name, so the decoder must
// map every concrete implementor to the `Pilot` instance, not match on `"Pilot"` itself.
@GraphQL
trait StarWarsInterfaceSubtype extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$ep: Episode!) {
hero(episode: $$ep) {
__typename
name
... on Pilot {
vehicle
}
}
}
"""
}
// format: on
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/StarWarsNestedSubquery.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ abstract class StarWarsNestedSubquery extends GraphQLSubquery[StarWars] {

override val subquery = gql"""
{
__typename
id
name
... on Human {
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/StarWarsQuery.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ trait StarWarsQuery extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$charId: ID!) {
character(id: $$charId) {
__typename
id
name
... on Human {
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/StarWarsQuery2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ object Wrapper /* gql: extends Something */ {
trait StarWarsQuery2 extends GraphQLOperation[StarWars] {
override val document = gql"""
fragment fields on Character {
__typename
id
name
... on Human {
Expand Down
1 change: 1 addition & 0 deletions gen/input/src/main/scala/test/StarWarsQuery3.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ trait StarWarsQuery3 extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$charId: ID!) {
character(id: $$charId) {
__typename
id
name
... on Human {
Expand Down
31 changes: 31 additions & 0 deletions gen/input/src/main/scala/test/StarWarsSingleSubtype.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

// A single fragment on a subtype (`Human`), leaving `Droid` uncovered: `Character` has exactly
// two implementors, so this must still generate a fallback `Other` instance (no special-casing
// for "just one variant").
@GraphQL
trait StarWarsSingleSubtype extends GraphQLOperation[StarWars] {
override val document = gql"""
query ($$ep: Episode!) {
hero(episode: $$ep) {
__typename
name
... on Human {
homePlanet
}
}
}
"""
}
// format: on
Loading
0