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

Merge selections in a set into ordered map of fields #92

Merged
merged 19 commits into from
Jan 29, 2017
Merged

Conversation

jml
Copy link
Collaborator

@jml jml commented Jan 28, 2017

Fixes #59

This makes the job of the resolver much simpler. It doesn't have to worry about inline fragments or whatever, it only deals with ordered maps of fields.

Unfortunately, it also makes the job of the validator more complex. Recursing through the whole query to merge fields is a bit involved. Defining some generic operations on OrderedMap made this much easier to think about.

This PR also introduces the notion of types into the validator. We need to know the type of the object we're building a selection set for, and we need to know the definitions of the type conditions. This (to me) means building and providing a value that represents the whole schema, and making sure each resolver has it. This in turn means passing a new value, allTypes down through each resolver. I think this is actually a perfect use case for ReaderT, and would be happy to change it.

It also means changing the top-level methods to only accept GraphQL Objects. This is fine, because that's all their allowed to take: https://facebook.github.io/graphql/#sec-Initial-types

Something that naturally arises from this work is that the difference between leafs and non-leafs becomes really obvious. We talked in person about different approaches, and for this PR I settled on changing the type of resolver to take Maybe (SelectionSet Value), where Nothing indicates a leaf query.

Testing is pretty shallow. I added one end-to-end test that shows off what this can do, but it's kind of limited. Some of the algorithmic & traversal code could probably do with property tests. Let me know which bits you trust least.

Some things on which I'd particularly like input:

  • The name of ExecutionField (and names of new functions & types in general)
  • Whether or not to use ReaderT
  • Ways to reduce the implementation complexity of groupByResponseKey

Copy link
Collaborator

@teh teh left a comment

Choose a reason for hiding this comment

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

This changes so much that I have trouble finding my way in. Continuing but some preliminary questions.

@@ -134,17 +133,24 @@ data Result a = Result [ResolverError] a deriving (Show, Functor, Eq)
aggregateResults :: [Result Value] -> Result Value
aggregateResults r = toValue <$> sequenceA r

throwE :: Applicative f => ResolverError -> f (Result Value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

is overloading the well-known throwE name a good idea?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We already do this in Validation.hs. Happy to rename, but totally bored of rewriting the same Result expression a dozen times over.

Copy link
Collaborator

Choose a reason for hiding this comment

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

ok, makes sense

instance Applicative Result where
pure v = Result [] v
(Result e1 f) <*> (Result e2 x) = Result (e1 <> e2) (f x)

ok :: Value -> Result Value
ok = pure

type AllTypes = Map Name TypeDefinition
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add a comment about AllTypes intent? Not super sure from the code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Something like:

-- | Dictionary of all types in the schema. A map of type names to type definitions.

?

case getSelectionSet of
Left err -> throwE err
Right ss -> do
handler <- mHandler
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you re-add the a comment along the lines of "First we run the handler function itself." ? Phrase how you want it but I find communicating that intent here quite important.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd much rather put in a comment saying why we run the handler first. Why do we run the handler first?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah right. The idea is that we want to run some monadic action that then determines what goes into the fields. E.g. we could be opening a database connection, or delegating to some other service.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sorry, am still struggling to figure out why this needs to be written down. It feels like writing "do a -> m b so we can pass b to the next thing".

Wrote this:

-- Run the handler so the field resolvers have access to the object.
-- This (and other places, including field resolvers) is where user
-- code can do things like look up something in a database.

-- XXX: Might be nicer to make AllTypes a ReaderT -- easier to ignore when
-- not wanted, and wouldn't have required me to change so many call sites.
-- OTOH, with all our type weirdness, I got bored trying to figure it out.
resolve :: AllTypes -> Handler m a -> Maybe (SelectionSetByType Value) -> m (Result 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 don't have an answer here but adding a new argument to HasResolver is not good from an API POV IMO. This now means that our users have to carry around this extra argument which is an implementation detail every time they implement HasResolver.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Some reflections. These are mostly unrelated to each other, just a bunch of points loosely on topic.

  • What's the resolve API for from a users PoV? I genuinely don't know. Knowing that seems the sine qua non in a discussion about usability.
  • This is essentially a callback API. It's pretty standard for callbacks to provide arguments that are unused in many cases. We already (before this PR) have many instances that don't use arguments.
  • Where callbacks don't provide arguments that are unused, it's because there are two different callback points. This would equate to two different type classes, or two different methods on this one.
  • AllTypes is something of a stand-in for a value that represents the entire global schema, not just the element that's being resolved. This makes it something more than just an implementation detail.
  • If a user did want to write a resolver that had access to the entire schema (as I did), what should they do?
  • ReaderT is a pretty good compromise here, IMO, since it can be safely ignored by those who don't need it. It also makes it harder to get it wrong by passing a different value into a child resolver (which we alas have to do)
  • With all the type magic going on, do you really think an extra argument is the critical usability point?
  • If we intend people to provide implementations of HasResolver, we should document the class and its methods, and say what the contract of the implementations is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thinking about it, a different solution would be to change Validation to require a type dictionary (and later, a validated schema), and then substitute the type conditions for the actual types.

Copy link
Collaborator

Choose a reason for hiding this comment

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

All interesting points!

The API (as envisioned by my so far) would be (in the simple case) that I have a new type, e.g.

data BusinessLogicThing = MoneyYes { amount :: Int } deriving Generic

and then

instance HasResolver BusinessLogicThing where
   resolve handler _ = map businessLogicThingToValue handler

The more complex (and probably more interesting) case is that users implement new combinators, e.g. ReadonlyObject or DynamicSchemaObject (the latter could e.g. figure out what fields it supports on the fly).

I think at this stage I agree with AllTypes being a stand-in for the full schema, and think we should keep it.

I'm also capitulating in terms of design, I really need some larger-project feedback now before making any other decisions.

Summary: :shipit:

Copy link
Collaborator

Choose a reason for hiding this comment

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

E.g. one thing that I imagine could be be quite useful is to delegate to a different graphql endpoint in the system, like DelegationObject, i.e. we would not know in advance what to do, and we'd need to re-bundle the query and ship it to the other place (maybe giving it a new name along the way).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Cool. I'll make AllTypes a newtype with only a lookup operation so as to make this a bit more obvious.

-- there's an alias, it's the alias, if not, it's the field name.
type ResponseKey = Name

-- | A field ready to be executed.
Copy link
Collaborator

Choose a reason for hiding this comment

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

executed == resolved?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure. You'd rather "resolved"?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

type ResponseKey = Name

-- | A field ready to be executed.
-- XXX: Reviewer, the 'Field' name has been freed up. Should I rename this to 'Field'.
Copy link
Collaborator

Choose a reason for hiding this comment

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

sure

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

OK, will do.

, arguments = arguments field1
, subSelectionSet = Just mergedSet
}
_ -> throwE (IncompatibleFields (name field1))
Copy link
Collaborator

Choose a reason for hiding this comment

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

this would be an implementaiton error, right? Maybe we should panic instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nope, it's what happens when someone tries:

{
  dog {
    barkVolume
  }
  dog
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah I see, how annoying! I wonder what a query language looks like that doesn't allow this .. probably doesn't have fragments!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well, it doesn't allow it, that's why we're catching it in validation. We just can't reasonably test the pairings without knowing the types, and we're (contra spec) not doing type-level validation atm. In the next major release (if we get there), I expect that the introspection work and pre-query type-checking will go hand-in-hand, much as the resolver & validation work did in this release.

newtype NonEmptyList a = NonEmptyList [a] deriving (Eq, Show, Functor, Foldable)

-- | A thing that defines types. Excludes definitions of input types.
class DefinesTypes t where
Copy link
Collaborator

Choose a reason for hiding this comment

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

No HasX naming schema?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I actually thought our naming schema was "predicate phrase", but sure. HasTypeDefinitions?

Copy link
Collaborator

Choose a reason for hiding this comment

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

ah right, not sure. We have a mix, can't really place it anywhere so probably fine

$ gg -h "^class " | sort | uniq
class BuildFieldResolver m fieldResolverType where
class Defaultable a where
class FromValue a where
class GenericAnnotatedInputType (f :: Type -> Type) where
class GenericEnumValues (f :: Type -> Type) where
class GenericFromValue (f :: Type -> Type) where
class GenericInputObjectFieldDefinitions (f :: Type -> Type) where
class GraphQLEnum a where
class GraphQLError e where
class HasAnnotatedInputType a where
class HasAnnotatedType a where
class HasArgumentDefinition a where
class HasFieldDefinition a where
class HasFieldDefinitions a where
class HasInterfaceDefinition a where
class HasInterfaceDefinitions a where
class HasName a where
class HasObjectDefinition a where
class HasResolver m a where
class RunFields m a where
class RunUnion m union objects where
class ToValue a where
class UnionTypeObjectTypeDefinitionList a where

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

BuildFieldResolver and UnionTypeObjectTypeDefinitionList really stand out in that list.

where
getSelectionSet = do
defn <- first SchemaError $ API.getDefinition @(API.Object typeName interfaces fields)
(SelectionSet ss') <- first ValidationError $ getSelectionSetForType (flip Map.lookup allTypes) defn selectionSet
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add a comment why we need the lookup in allTypes?

Copy link
Collaborator Author

@jml jml Jan 28, 2017

Choose a reason for hiding this comment

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

Something like...

-- Fields of a selection set may be behind "type conditions", due to inline fragments 
-- or the use of fragment spreads. These type conditions are represented in the 
-- schema by the name of a type (e.g. "Dog"). To determine which type conditions 
-- (and thus which fields) are relevant for this selection set, we need to look up the 
-- actual types they refer to, as interfaces (say) match objects differently than 
-- unions.
-- 
-- See <https://facebook.github.io/graphql/#sec-Field-Collection> for more details.

?

@jml
Copy link
Collaborator Author

jml commented Jan 28, 2017

This changes so much that I have trouble finding my way in. Continuing but some preliminary questions.

Thanks for making the effort. I realise it's pretty big, but (aside from the OrderedMap changes) I couldn't break it up into smaller pieces without either having chunks of unused code, or making changes that would seem to be without any justification.

jml added 18 commits January 29, 2017 16:07
Not actually used yet.

Adds new types `SelectionSetByResponseKey` and `ExecutionField`.

Also, remove unused language PRAGMA
* Rename `RealSelectionSet` to `SelectionSet`
* Stop exporting so much from Validation
* Create a dictionary of defined output types
* Use that type dictionary to resolve fields
* Test to show off some of the new functionality
* Remove many categories of error from Resolver, they just aren't possible now
Means that:

- validation needs the schema
- resolver doesn't
@jml jml merged commit dfcd95a into master Jan 29, 2017
@jml jml deleted the selection-set-2 branch January 29, 2017 16:31
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.

Enforce field uniqueness in selections
2 participants