Skip to content
This repository has been archived by the owner on Jul 25, 2024. It is now read-only.

Schema DSL #15

Closed
1player opened this issue Oct 10, 2015 · 19 comments
Closed

Schema DSL #15

1player opened this issue Oct 10, 2015 · 19 comments

Comments

@1player
Copy link
Contributor

1player commented Oct 10, 2015

I've been lurking in this repo for a while and I've tried to come up with some kind of DSL to describe a schema at a high level. Has anybody already given some thought about this?

I've tried to replicate the Star Wars example from the reference graphql repository from Facebook (https://github.com/graphql/graphql-js/tree/master/src/__tests__), this is what I've come up with:

defmodule Character do
  use GraphQL.ObjectInterface

  field :id, null: false
  field :name
  field :friends, type: List.of(Character)
  field :appearsIn, type: List.of(Episode)
end

defmodule Human do
  use GraphQL.Object, deriving: Character

  field :homePlanet
end

defmodule Droid do
  use GraphQL.Object, deriving: Character

  field :primaryFunction
end

defmodule Schema do
  use GraphQL.Schema

  field :hero, type: Character do
    argument :episode, description: "foo"

    resolve %{episode: episode} do
      getHero(episode)
    end

    resolve do
      getHero(1000)
    end
  end

  field :human, type: Human do
    argument :id, description: "id of the human", null: false

    resolve %{id: id} do
      getHuman(id)
    end
  end

  field :droid, type: Droid do
    argument :id, description: "id of the droid", null: false

    resolve %{id: id} do
      getDroid(id)
    end
  end



  @humans [ 
    "1000": %Human{
      id: "1000",
      name: "Luke Skywalker",
      friends: ["1002", "1003", "2000", "2001"],
      appearsIn: [4, 5, 6],
      homePlanet: "Tatooine",
    },
    "1001": %Human{
      id: "1001",
      name: "Darth Vader",
      friends: [ "1004" ],
      appearsIn: [ 4, 5, 6 ],
      homePlanet: "Tatooine",
    },
    [ ... ]
  ]

  @droids [ ... ]

  defp getHero(5), do: @humans["1000"]
  defp getHero(_), do: @droids["2001"]

  defp getHuman(id), do: @humans[id]

  defp getDroid(id), do: @droids[id]
end

The Schema module defines the schema with its field. Each field has some arguments and one or more resolve functions, depending on whether some arguments are nullable.
Fields and arguments accept a "type" option, and nullability is expressed with a "null: false" option.

Interfaces are defined by using GraphQL.ObjectInterface, with a similar DSL to define the fields, while objects deriving the interface specify doing so when "using" GraphQL.Object (see Human and Droid).

Modules importing GraphQL.Object behave very similarly to structs, and inherit all the fields defined in the parent interface, if any.

That's just a proof of concept but I'd like to hear some comments and if there's any idea somebody's written down for a DSL.

Disclaimer: I'm quite new with Elixir, there surely are syntax errors in the POC code above.

@joshprice
Copy link
Member

Hi @1player, thanks so much for writing this up and starting this discussion!

There has definitely been some thinking around this already, but it is probably one of the areas where there is a bit of confusion in the spec and the reference implementation.

@peburrows, @freshtonic and I all separately assumed that one would specify the schema using the type system. Much like https://github.com/joshprice/graphql-elixir/blob/master/test/graphql_parser_schema_kitchen_sink_test.exs or https://github.com/peburrows/plot/blob/master/test/fixtures/schema/user-and-book.gql

@peburrows solution was along the lines of what I was thinking which was to decouple the schema and type definitions from the method used to resolve the data. I think Phil has nailed this idea with his use of Protocols to separate out these concepts and this needs further exploration. See https://github.com/peburrows/plot#query-resolution

That said, I think there is merit to the approach you've gone with not just because it's how the reference implementation does it. It's simple, easy to read, it's just Elixir code, you can keep resolvers along with the schema, squint a bit and it looks like ecto, etc.

Although my preference is to go with using the schema DSL, I think it makes sense to enable both ways of defining a schema, and if there ends up being more consensus in the spec and other implementations then pick one way or the other.

Thoughts?

@freshtonic
Copy link
Contributor

@joshprice What I would like to see is the ability to decouple the resolve blocks from the schema i.e. keep the what (the schema) separate from the how (the resolving). However, for convenience I'd still like the option to define the resolve blocks inline with the schema DSL. So far I like the syntax @1player has gone with - it's very tasteful!

@joshprice
Copy link
Member

Agreed it looks like a great starting point

@peburrows
Copy link
Contributor

In my mind, the ability to decouple the schema definition from how things are resolved seems important.

@peburrows
Copy link
Contributor

(I prematurely submitted my previous comment 😢 Let me try that again.)

In my mind, the ability to decouple the schema definition from how things are resolved seems important.

However, I do appreciate the elegance of DSL @1player outlined above. It seems to me it would be useful to provide both options for schema definition, but maybe that just makes for unnecessary complexity here in the library (for the record, I still don't understand why more implementations don't allow people to specify schema using the type system — it's there for a reason, and schema definitions seems like one of those reasons...).

@1player
Copy link
Contributor Author

1player commented Oct 13, 2015

I like the idea of using protocols for resolution, although I'm not sure sold on @peburrows solution which puts all the resolution code in a defimpl Plot.Resolution, for: Plot.Object block.

I'd like to defimpl each object separately, with the possibility of having a generic resolve function in the body of the schema which overrides per-object resolution. For example:

defmodule Character, do: ...snip...
defmodule Human, do: ...snip...

# per-object resolution
defimpl GraphQL.Resolve, for: Human do
  def resolve(%{id: id}) do
    getHuman(id)
  end
end

defmodule Schema do
  [...]

  field :hero, type: Character do
    # generic resolution, overriding a possible GraphQL.Resolve implementation
    # on the Character Object
    resolve %{episode: episode} do
      getHero(episode)
    end
    [...]
end

I actually don't know if we need per-object and generic resolution, but two different schemas may want to resolve the same object differently -- although I'm not sure how many projects will have more than a schema, or if that would even come up in practice.

Makes any sense?

@joshprice
Copy link
Member

Agree with the following:

  • Decoupling of schema and resolution seems like a reasonable goal
  • I'm also not sure why other impl's are not using the type system to specify schema (accidental or intentional for a good reason - I don't know)
  • Decoupling of the schema/resolution does have the drawback of not keeping everything defined together which is nice for readability as the reader doesn't need to hunt for the resolver definition
  • Supporting both options does increase library complexity

Rather than speculate, I think the best course of action is to go with what the reference and other implementations have done (ie what @1player proposes) while investigating the alternatives.

@danielberkompas
Copy link

I just recently read the GraphQL spec, so I'm new, but it seems to me like using the existing type documentation would be best. Adding an Elixir layer over top of it adds a lot of complexity and requires more mental work to understand. Personally, I would rather have the schema look like it does in the GraphQL spec, even if it means that I have to hunt down the resolvers.

This saves the mental effort and confusion of trying to figure out "How do I convert this type schema from the GraphQL docs into the correct Elixir modules?" and also all the effort of documenting how the type system maps to the Elixir module system.

Just my two cents.

@bruce
Copy link
Contributor

bruce commented Oct 16, 2015

One thing to mention about using the type system: as far as I can tell, it does not support adding descriptions (eg, as is done in graphql-js' GraphQLObjectType's fields). We may need to use something else to provide additional metadata to support, eg, introspection.

@danielberkompas
Copy link

@bruce That is a very good point. In fact, that could explain why all the other implementations have used a DSL.

@freshtonic
Copy link
Contributor

Given this extra information regarding descriptions, I'm leaning towards a DSL approach. However, if deemed valuable, we could also make a tool to translate a native GraphQL schema definition into our own particular DSL. Thoughts?

@1player
Copy link
Contributor Author

1player commented Oct 28, 2015

Some things I've noticed working on an implementation of this feature:

  • GraphQL.Object is actually the root of all other "types": An Object is actually a struct, and Object fields can either be mutable simple values, or complex, read-only values if a resolve function is provided. Example:
defmodule Person do
  use GraphQL.Object

  field :name
  field :age do
    resolve, do: 28
  end
end

### MAPS TO ###

defmodule Person do
  defstruct [:name]

  def age, do: 28
end

name is a simple value that can be read from and written to, as if it were a struct field, while age is a read-only field.

  • A GraphQL.Schema is a GraphQL.Object with mutations.
  • A GraphQL.ObjectInterface (or Interface in GraphQL parlance) is a GraphQL.Object whose fields cannot be accessed directly but only by deriving it into a straight Object.

This is an arbitrary limitation of GraphQL IMO because as per spec objects can only implement Interfaces, and I don't see why an Object can't implement/derive another Object. We could be cool and permit that in this library, and collapse the object into an interface an introspection type, but that's for the future, if we'll ever go that route. For example, this could in theory be viable:

defmodule Person do
  use GraphQL.Object
  field :name
end

defmodule Employee do
  use GraphQL.Object, deriving: Person
  field :salary
end

represented as

interface PersonInterface { name: String }
type Person implements PersonInterface {}
type Employee implements PersonInterface { salary: String }

@cesarandreu
Copy link

Have you looked at graphql-ruby? I played around with a few hours and I found it was very pleasant to hook up with a Rails app. They have a demo app.

It might serve as a source of inspiration.

@AdamBrodzinski
Copy link
Member

I really quite like how readable the DSL approach is. I do agree with @danielberkompas that it could cause problems with figuring out what's going on / debugging because it's so abstracted.

Here's an approach i've gone with in JavaScript that just cuts down the noise and verbosity of the GraphQL exports. The main takeaway here is that it's the same as using the normal GraphQL exports but you're passing in the keys for the type field as needed (such as description).

Anyhow thought i'd throw this in, just in case is sparks any ideas for Elixir use cases.

Query = {};
Mutation = {};
Types = {};
import {object, string, date, list} from 'graphql-helpers'

Types.Post = object('Post', {
  id: string(),
  createdAt: date(),
  body: string({desc: "The main part of a blog post"}),
  title: string(),
  comments: list(Types.Comment),
})

These functions are basically just taking in options, building up the type map and returning it, similar to:

const GraphHelpers = {
  string(opts = {}) {
    var type;
    if (opts.required) {
      type = {type: new GraphQLNonNull(GraphQLString)};
    } else {
      type = {type: GraphQLString };
    }
    return merge(opts, type)
  },
}

And here is the rest of the code (semi-related). The rest focuses on building up a Query and Mutation namespace and each 'chunk' is organized in a separate file (as opposed to the examples which have everything in one 'tree').

These are all glued together in the root query by passing Mutations and Types into the schema endpoint.

// allow users to fetch a single post or all posts they own:

Query.usersPosts = {
  type: listType(Types.Post),
  description: "All posts that a user wrote",
  args: {
    userId: string({required: true}),
    limit: integer(),
  },
  resolve(root, args) {
    return Posts.find({userId: args.userId}, {limit: args.limit})
  }
};

Mutation.createPost = {
  type: Types.Post,
  args: {
    description: string({required: true}),
    title: string({required: true}),
  },
  resolve: (root, args) => {
    args.createdAt = new Date();
    const id = Posts.insert(args);
    return Posts.findOne(id);
  }
};

@dre1080
Copy link

dre1080 commented Mar 2, 2016

I think this project and Absinthe should merge or atleast implement their DSL. Absinthe has the best DSL of all the elixir graphql implementations so far.

For example, this is Absinthe:

defmodule MyApp.Schema do

  use Absinthe.Schema

  # Example data
  @items %{
    "foo" => %{id: "foo", name: "Foo"},
    "bar" => %{id: "bar", name: "Bar"}
  }

  @desc "An item"
  object :item do
    field :id, :id
    field :name, :string
  end

  query do
    field :item, type: :item do
      arg :id, non_null(:id)

      resolve fn %{id: item_id}, _ ->
        {:ok, @items[item_id]}
      end
    end
  end

end

@joshprice
Copy link
Member

Closing as we'll be focussing on supporting schemas written in GraphQL IDL. See the following links for more detail:

It's possible that we may end up implementing this before 1.0 but it's not currently planned.

@sntran
Copy link

sntran commented Jun 23, 2016

I will be the bad guy here and say that I prefer the current way of defining schema. This allows me not to depend on creating modules for each types in the schema. One of the apps I am working on has dynamic types, which are created and destroyed at run time. The current method allows me to change the schema any time.

If I want DSL, I will go with Absinthe. Or at least allow both ways.

@joshprice
Copy link
Member

Good to know Son, dynamic types are an interesting use case that we'll
continue to support. Is there more you can share about your project and
what you're doing with GraphQL?

On Fri, Jun 24, 2016 at 2:07 AM, Son Tran-Nguyen [email protected]
wrote:

I will be the bad guy here and say that I prefer the current way of
defining schema. This allows me not to depend on creating modules for each
types in the schema. One of the apps I am working on has dynamic types,
which are created and destroyed at run time. The current method allows me
to change the schema any time.

If I want DSL, I will go with Absinthe. Or at least allow both ways.


You are receiving this because you modified the open/close state.
Reply to this email directly, view it on GitHub
#15 (comment),
or mute the thread
https://github.com/notifications/unsubscribe/AAAkW5nPy2pJRRksL0QwJTvnra3cr97Hks5qOq9SgaJpZM4GMe0v
.

@sntran
Copy link

sntran commented Jun 24, 2016

Sure.

My app takes a type definition list in JSON format, without resolvers.
From that list, the app creates various queries and mutations such as
"xByField", "createX", "updateX", "deleteX", where X is a type in the list.

Later on, that JSON list can change, in which types can be added or
removed. MyTestSchema.schema can simply read that and return new
schema with new queries and mutations.

With modules, I would need to rewrite and recompile them.

Of course, I would need to use a new plug on top of GraphQL.Plug.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants