gql-dart / ferry

Stream-based strongly typed GraphQL client for Dart
https://ferrygraphql.com/
MIT License
602 stars 116 forks source link

Serializers using wrong generated Var type causing LinkException #488

Closed krejko closed 1 year ago

krejko commented 1 year ago

I have two different mutation queries:

recipe.update.mutation.graphql

mutation Mutation($uuid: String!, $displayName: String) {
  updateRecipe(uuid: $uuid, displayName: $displayName) {
    createdAt
    deleteAt
    displayName
    modifiedAt
    uuid
  }
}

recipe.undelete.mutation.graphql

mutation Mutation($uuid: String!) {
  undeleteRecipe(uuid: $uuid) {
    createdAt
    deleteAt
    displayName
    modifiedAt
    uuid
  }
}

schema.graphql

type Mutation {
  createRecipe(displayName: String, ownerUuid: String!): Recipe!
  updateRecipe(uuid: String!, displayName: String, ownerUuid: String): Recipe!
  undeleteRecipe(uuid: String!): Recipe!
}

type Recipe {
  uuid: String!
  displayName: String
  ownerUuid: String
  createdAt: DateTime
  modifiedAt: DateTime
  deleteAt: DateTime
  users: User
}

When I try to use my update mutation, I am getting the following error:

errors LinkException(Expected a value of type 'GMutationVars', but got one of type '_$36GMutationVars', dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 266:49       throw_
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 99:3         castError
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/operations.dart 485:10   cast
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/classes.dart 638:14      as_C
packages/frontend/graphql/generated/recipe.undelete.mutation.var.gql.g.dart 19:70  serialize
packages/built_value/src/built_json_serializers.dart 102:25                        [_serialize]
packages/built_value/src/built_json_serializers.dart 69:18                         serialize
packages/built_value/src/built_json_serializers.dart 53:12                         serializeWith
packages/frontend/graphql/generated/recipe.update.mutation.var.gql.dart 21:53      toJson
packages/frontend/graphql/generated/recipe.update.mutation.req.gql.dart 41:25      get execRequest
packages/ferry/src/gql_typed_link.dart 23:36                                       request
packages/ferry_exec/src/typed_link.dart 134:45                                     <fn>
packages/ferry/src/optimistic_typed_link.dart 16:20                                request
packages/ferry_exec/src/typed_link.dart 134:45                                     <fn>
packages/ferry_exec/src/typed_link.dart 134:60                                     request
packages/ferry/src/fetch_policy_typed_link.dart 71:14                              request
packages/ferry_exec/src/typed_link.dart 134:45                                     <fn>
packages/ferry/src/add_typename_typed_link.dart 16:14                              request
packages/ferry_exec/src/typed_link.dart 134:45                                     <fn>
packages/ferry/src/request_controller_typed_link.dart 54:22                        <fn>
packages/rxdart/src/transformers/switch_map.dart 17:29                             onData
dart-sdk/lib/async/zone.dart 1593:10                                               runUnaryGuarded
dart-sdk/lib/async/stream_impl.dart 339:11                                         [_sendData]
dart-sdk/lib/async/stream_impl.dart 271:7                                          [_add]
dart-sdk/lib/async/broadcast_stream_controller.dart 377:24                         [_sendData]
dart-sdk/lib/async/broadcast_stream_controller.dart 244:5                          add
packages/rxdart/src/transformers/do.dart 40:10                                     onData
dart-sdk/lib/async/zone.dart 1593:10                                               runUnaryGuarded
dart-sdk/lib/async/stream_impl.dart 339:11                                         [_sendData]
dart-sdk/lib/async/stream_impl.dart 271:7                                          [_add]
dart-sdk/lib/async/stream_pipe.dart 123:11                                         [_add]
dart-sdk/lib/async/stream_pipe.dart 195:11                                         [_handleData]
dart-sdk/lib/async/stream_pipe.dart 153:13                                         [_handleData]
dart-sdk/lib/async/zone.dart 1593:10                                               runUnaryGuarded
dart-sdk/lib/async/stream_impl.dart 339:11                                         [_sendData]
dart-sdk/lib/async/stream_impl.dart 271:7                                          [_add]
dart-sdk/lib/async/stream_transformers.dart 63:11                                  [_add]
dart-sdk/lib/async/stream_transformers.dart 13:11                                  add
packages/rxdart/src/transformers/where_type.dart 11:19                             add
dart-sdk/lib/async/stream_transformers.dart 111:24                                 [_handleData]
dart-sdk/lib/async/zone.dart 1593:10                                               runUnaryGuarded
dart-sdk/lib/async/stream_impl.dart 339:11                                         [_sendData]
dart-sdk/lib/async/stream_impl.dart 515:13                                         perform
dart-sdk/lib/async/stream_impl.dart 620:10                                         handleNext
dart-sdk/lib/async/stream_impl.dart 591:7                                          callback
dart-sdk/lib/async/schedule_microtask.dart 40:11                                   _microtaskLoop
dart-sdk/lib/async/schedule_microtask.dart 49:5                                    _startMicrotaskLoop
dart-sdk/lib/_internal/js_dev_runtime/patch/async_patch.dart 166:15                <fn>
)

It would appear as though when the generated file is trying to serialize my update mutation, it is using the serializer generated for the undelete mutation.

What I think is the relevant excerpt from above:

packages/frontend/graphql/generated/recipe.undelete.mutation.var.gql.g.dart 19:70  serialize
packages/built_value/src/built_json_serializers.dart 102:25                        [_serialize]
packages/built_value/src/built_json_serializers.dart 69:18                         serialize
packages/built_value/src/built_json_serializers.dart 53:12                         serializeWith
packages/frontend/graphql/generated/recipe.update.mutation.var.gql.dart 21:53      toJson
packages/frontend/graphql/generated/recipe.update.mutation.req.gql.dart 41:25      get execRequest

I can see that the generation library is creating two different GMutationVars classes, one in recipe.undelete.mutation.var.gql.g.dart and another in recipe.update.mutation.var.gql.g.dart. However, only the GMutationVars from the undelete class is being registered in the serialzers.gql.dart file:

// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint

import 'package:built_collection/built_collection.dart';
import 'package:built_value/serializer.dart';
import 'package:built_value/standard_json_plugin.dart' show StandardJsonPlugin;
import 'package:ferry_exec/ferry_exec.dart';
import 'package:frontend/graphql/generated/recipe.create.mutation.data.gql.dart'
    show GMutationData_createRecipe;
import 'package:frontend/graphql/generated/recipe.undelete.mutation.data.gql.dart'
    show GMutationData, GMutationData_undeleteRecipe;
import 'package:frontend/graphql/generated/recipe.undelete.mutation.req.gql.dart'
    show GMutationReq;
import 'package:frontend/graphql/generated/recipe.undelete.mutation.var.gql.dart'
    show GMutationVars;
import 'package:frontend/graphql/generated/recipe.update.mutation.data.gql.dart'
    show GMutationData_updateRecipe;
part 'serializers.gql.g.dart';

final SerializersBuilder _serializersBuilder = _$serializers.toBuilder()
  ..add(OperationSerializer())
  ..addPlugin(StandardJsonPlugin());
@SerializersFor([
  GMutationData,
  GMutationData_createRecipe,
  GMutationData_undeleteRecipe,
  GMutationData_updateRecipe,
  GMutationVars,
])
final Serializers serializers = _serializersBuilder.build();

Is there any recommendation on how to avoid this error? Maybe I am generating my files incorrectly? Or is this a bug in the generation script? Any advice would be greatly appreciated. Thanks!

knaeckeKami commented 1 year ago

Do you actually use the same operation name (e.g. "Mutation" ) for different mutations? I suspect that this might cause issues

krejko commented 1 year ago

Some additional context, I have explicitly called the Mutation request from the update request generated files:

import 'package:frontend/graphql/generated/recipe.update.mutation.req.gql.dart'
    as update_req;
import 'package:frontend/graphql/generated/recipe.update.mutation.data.gql.dart'
    as update_data;
import 'package:frontend/graphql/generated/recipe.update.mutation.var.gql.dart'
    as update_var

  update() {
    var update = update_req.GMutationReq((b) => b..vars.uuid = uuid);
    client.request(update).listen(
        (OperationResponse<update_data.GMutationData, update_var.GMutationVars>
            res) {
      if (res.hasErrors) {
        print('errors ${res.linkException}');
      }
      print("update ${res.data}");
    });
  }
krejko commented 1 year ago

Do you actually use the same operation name (e.g. "Mutation" ) for different mutations? I suspect that this might cause issues

@knaeckeKami I could be wrong, but I believe I have followed the recommended naming conventions outlined by the Apollo server guidelines: https://www.apollographql.com/docs/apollo-server/data/resolvers

It was my understanding that the Type was Query or Mutation and the operation or 'resolver' names were createRecipe, undeleteRecipe and updateRecipe. Is this not correct?

If there is another way of structuring the resolvers that works better for the generator, could you please provide a link to an example? Thanks for quick response!

krejko commented 1 year ago

Are you suggesting I create a new type for each operation? For example a CreateRecipeMutation separately from a UpdateRecipeMutation?

krejko commented 1 year ago

Separately, is there a reason the generation library knows to add the _operation-name to the data types but not the Var types?

  GMutationData_createRecipe,
  GMutationData_undeleteRecipe,
  GMutationData_updateRecipe,

It seems if it named both the Var and Data generated types consistently then this wouldn't be an issue. Such as:

  GMutationVar_createRecipe,
  GMutationVar_undeleteRecipe,
  GMutationVar_updateRecipe,

Of course, I am likely ignorant of the reasons it's not already done this way. Any thoughts on if or why this would be a bad idea?

knaeckeKami commented 1 year ago

I'm just talking about the operation name


 mutation Mutation($uuid: String!, $displayName: String) {
           ^-- this here
  updateRecipe(uuid: $uuid, displayName: $displayName) {
    createdAt
    deleteAt
    displayName
    modifiedAt
    uuid
  }
}

mutation Mutation($uuid: String!) {
         ^- and this
  undeleteRecipe(uuid: $uuid) {
    createdAt
    deleteAt
    displayName
    modifiedAt
    uuid
  }
}

ferry generates classes from these operation names, I did not think about users using the same operation names for different operations.

I think this might cause some issues in the generated code.

Does it work if you give these mutations different operation names?

You can give each operation a name of your choosing - for example "UndeleteRecipe" will cause ferry do generate a GUndeleteRecipeReq class.

krejko commented 1 year ago

I can give it a shot and see what happens.

For additional reference, It would seem as though GraphQL.org also recommends using the same one Mutation type for multiple different operations: https://graphql.org/graphql-js/mutations-and-input-types/

Relevant excerpt from link above:

  type Mutation {
    createMessage(input: MessageInput): Message
    updateMessage(id: ID!, input: MessageInput): Message
  }
knaeckeKami commented 1 year ago

Yes, the types can be the same, but the operation names are orthogonal to the types. See https://graphql.org/learn/queries/#operation-name

The operation name is a meaningful and explicit name for your operation. It is only required in multi-operation documents, but its use is encouraged because it is very helpful for debugging and server-side logging. When something goes wrong (you see errors either in your network logs, or in the logs of your GraphQL server) it is easier to identify a query in your codebase by name instead of trying to decipher the contents. Think of this just like a function name in your favorite programming language. For example, in JavaScript we can easily work only with anonymous functions, but when we give a function a name, it's easier to track it down, debug our code, and log when it's called. In the same way, GraphQL query and mutation names, along with fragment names, can be a useful debugging tool on the server side to identify different GraphQL requests.

krejko commented 1 year ago

@knaeckeKami Thanks again for all the support. I didn't realize the names you had pointed out could be anything. I was using the Apollo Server sandbox to generate these queries and incorrectly assumed that the names they had provided were the required names and that they came from my schema. This appears to not be the case. When I renamed the values you pointed out in the queries and regenerated everything I no longer encounter the error. Thanks again for you patience!

Also, I looked for a digital tip jar to support the project with how quick and hand-on you've been at answering my questions. However, I was unable to locate one. Please point me in the right direction if such a link exists. Thanks again!

knaeckeKami commented 1 year ago

You encouraged me to add a Sponsors button to my Github profile ;)