graphql / graphql-spec

GraphQL is a query language and execution engine tied to any backend service.
https://spec.graphql.org
14.31k stars 1.12k forks source link

Extend Wrapping Types with additional primitive Tuple #904

Open nalchevanidze opened 2 years ago

nalchevanidze commented 2 years ago

GraphQL Tuples

At this stage, GraphQL does not provide tuples. This issue proposes to extend the GraphQL specification with an additional primitive type Tuple (of kind WRAPPER).

a GraphQL tuple (a₁,...,aₙ₋₁, type) is a list of fixed size n, where the first n-1 arguments can be either enum, scalar, or wrapper types, but the last argument can take any possible type. accordingly, the selection will be applied only to the last argument.

For example a API with the following schema for query { users { name } }

type User {
  name: String
  age: Int
}

type Query {
  users : [(ID!,User)]
}

will produce the response:

{
  "data": {
    "users": [
      ["jkgad", { "name": "Alex" }],
      ["t9t98", { "name": "John" }],
    ]
  }
}

this way we can support a Map in GraphQL.

Additional considerations of syntax, semantics, and introspection are presented in the morpheus-graphql-proposals

nalchevanidze commented 2 years ago

an additional advantage is that: since Tuple will be a WRAPPER, it can be used for both input and output types.

rivantsov commented 2 years ago

that looks really problematic to me. You suggest essentially a syntactic sugar, a shortcut that allows you to skip explicitly declaring a type with fields. Benefits are tiny I think. Defining an extra explicit return type is done on server when you define model, once. Writing queries - does not change anything. It is a server-side syntactic sugar, for stuff that you write once. Seems questionable benefit to me.

The auto-tuples work well in IDEs with intelli-sense, where editor and compiler infer the exact sub-type of tuple and follow thru with syntax checks down the line. But there is in fact an explicit type behind it, like in .net with generics: Tuple<string, int, int>; compiler follows thru as if you declared this type explicitly.

Now, for graphql server. The number of args and arg types are part of tuple type in your proposal? so that (int, string) is not assignment-compatible with (string, string, int) ? I assume the answer is yes, but then you will have a set of unnamed types (they are all tuples) which are mutually incompatible. On the server you have a bunch of quite complex subtypes that you can't even reference anywhere since they are unnamed, all tuples. For graphQL server the type system management becomes a challenge for sure. We don't have generics, but will have to track a bunch of 'specialized from generic' unnamed types. And if you use it in inputs, checking type match becomes way more complex. How about tuple-of-tuples and other combinations - would that be allowed? that opens another layer of complexity.

nalchevanidze commented 2 years ago

Hi @rivantsov, thanks for you remark!

Now, for graphql server. The number of args and arg types are part of tuple type in your proposal? so that (int, string) is not assignment-compatible with (string, string, int) ? I assume the answer is yes, but then you will have a set of unnamed types (they are all tuples) which are mutually incompatible.

i don't got this part. could you explain it in details with an example?

Generics and Complexity

We don't have generics

I think we have generic types with only parameter(called wrapping types).

type List<a> = [a]
type NonNull<a> = a!

My proposal uses the same pattern and allows only one parameter, which should be used as the last element of tupple. All other parameters are stored in tuppleArguments and are non-compound elements which simplifies the validation process. Since we use the same pattern and even the same introspection as for wrapping types, we don't really increase the complexity.

And if you use it in inputs, checking type match becomes way more complex.

i think checking input tupples would be less complicated then checking input object.

How about tuple-of-tuples and other combinations - would that be allowed?

hypothetical. yes. but only as a last parameter of the tuple (like in example).

(!ID, (Int!, User!))

however, I don't yet see a reason why someone would like to use it, since it becomes less readable at the certain level of nesting. To avoid cumbersome tuples, we can limit the number of elements (max 4) and the nesting level (max 3).

Benefits

Benefits are tiny I think

I disagree. Tuples and maps are a necessary feature and are supported in most languages, but GraphQL does not have them. Therefore, in languages that do support tuples, we have a problem representing them in a meaningful way.

Take as example. my Haskell library generates API based on native types. if we have type Map ID Int and Map ID Text (Map<ID,Int> and Map<ID,String> in Java) and we want to use them as input and output types, the library generates following concrate types to support them: [Input_Tuple_ID_Int],[Tuple_ID_Int],[Input_Tuple_ID_String],[Tuple_ID_String] .

input Input_Tuple_ID_Int = {
    key: ID!,
    value: Int!
}

type Tuple_ID_Int = {
    key: ID!,
    value: Int!
}

input Input_Tuple_ID_String = {
    key: ID!,
    value: Int!
}

type Tuple_ID_String = {
    key: ID!,
    value: Int!
}

however, i think that these types are redundant and and makes the schema cumbersome to read. they could simply be written with just two expressions [(ID!,Int!)] and [(ID!,String!)]. same way we don't define custom types like List_Int and List_String instead of [Int] and [String].

rivantsov commented 2 years ago

Note - as other language I use c# as 'other language' that has tuples and maps (dictionaries).

Tuples and maps are a necessary feature and are supported in most languages, but GraphQL does not have them.

Maps and tuples are fundamentally different in the context of this discussion. GraphQL does need map type I agree. Map is not a syntactic sugar, it is an actual concept that cannot be modeled/substituted with existing facilities. c# did have tuples as Tuple<,,,> generic types like forever; but only recently they added short syntax for packing/unpacking tuple values: (x, y) = funcReturningTuple(); - unpacks the tuple into variables. and this kinda counts as full tuples support in c#. It sounds like your proposal is similar, to add packing/unpacking values in tuples, and it is syntactic sugar as well. Your example with multiple extra tuple types generated by your API generator - I think the trouble is in representing Map object that is absent in GraphQL - map is modeled as a list of tuples, hence the tuple type for each map type. I think if we had a Map type in GraphQL, that would not be necessary, so I think your example is an argument for adding Map to GraphQL, not tuples. Doing Map thru adding tuples and whole complexities coming with this - not sure it's a good way. Why don't we talk about map directly?

My proposal uses the same pattern and allows only one parameter, which should be used as the last element of tuple. All other parameters are stored in tuppleArguments and are non-compound elements which simplifies the validation process.

This restriction on first (n-1) types, and only last one being any type - seems quite cumbersome to me. People learning GraphQL would say 'wow, what a strange tuple limitation' - comparing it to tuples in 'other languages' they already know.

I think we have generic types with only parameter(called wrapping types).

Wrapping types (list and non-null) - these are definitely not generics, like 'we already have simple generics'. Array in general is fundamental type in most languages, even those that do not have generics. And nonNull - well, technically speaking it's not type I think, this is a constraint on field containing type. For whatever reason, simplicity I guess, they decided to use a wrapping type to represent nonNull 'type'; but what I'm saying all this wrapping business has nothing to do with generics. Wrapping is a simple trick to represent a wrap type in introspection, and it does not reflect the 'nature' of the type I think.

About complexity of type system with proposed tuples. Let's say you list SDL doc for an API. It currently lists all custom types plus a few standard types, and it is only these types that you will meet in arguments and return types of functions. With proposed tuples, the function returning a tuple will in fact introduce a tuple type, like Tuple<int, string, string>, implicitly. This is a distinguished type since it is incompatible on assignment with other tuple like <string, int>. But it will not be listed in SDL explicitly. Internally tye type system should treat this tuple as a real type. This dichotomy (explicitly declared types vs actual types in schema) - brings extra complexity into the mental model of the set 'all types in my API'. I am afraid it will cause confusion in some cases, and will add complexity of managing types on the server. Yes, I know, we do not declare explicitly array types, but this is one straight forward case that is easy to grasp. Extending it tuples which is in fact a generic type - well, I don't know... Naming - the tuple types are unnamed, so for example, how to reference them in error messages, like Tuple<> is not compatible with Tuple<> ? And conversion rules - oh that will come also, especially for input tuples. We have a whole section in spec about automatic type conversions. For example for tuple <float, string>, is value (1, "abc") compatible? (note int to float conversion). Whole bunch of complications I think pops up.

So to sum it up, my suggestion - let's talk about Map, not tuples.

nalchevanidze commented 2 years ago

Parametrised Types

Wrapping types (list and non-null) - these are definitely not generics, like 'we already have simple generics'.

The term generic can have different meanings in different languages. Let's use the term parameterised type. both wrapping types: List and NonNull have type paramater which is modified by the type. so List and NonNull are parametrised types. special case is nonNull.

NonNull

NonNull type is actually the reverse of Maybe datatype in Haskell.

data Maybe a = Just a | Nothing

there is also equivalent of NonNull in typescript:

type NonNull<T> = T extends null ? never : T;

Type Declaration and Naming

But it will not be listed in SDL explicitly. Internally tye type system should treat this tuple as a real type. This dichotomy (explicitly declared types vs actual types in schema)

actually lookup function for types will have following interface:

lookupType:: TypeName -> [TypeParameter] -> Type

resulting type will already return the type where all type variables are resolved.

Naming - the tuple types are unnamed, so for example, how to reference them in error messages, like Tuple<> is not compatible with Tuple<> ?

name of the type will include its parameters like: Tuple<Int,String> is not equal to Tuple<Int,Int>

Conversion rules

For example for tuple <float, string>, is value (1, "abc") compatible?

input validation is actually no problem.

it is has same complexity as validating input MyType.

input MyType {
  __0: Float
  __1: String
}

Real problem

the real problem is output types. how we determine which fields are selected. for example:

type Query {
    map: [(Type1!,Type2!)!]!
}

how we can determine their fields?

This restriction on first (n-1) types, and only last one being any type - seems quite cumbersome to me.

this is one restriction to solve this problem. however you are right. it is cumbersome! i tried different approaches in my GQL like language iris. two of them are.

option1: select as a system fields __x:

map {
  __0 { x  }
  __1 { y }
}

the approach is cumbersome.

option2: select with type conditions:

{ 
   on ... Type1 { x }
   on ... Type2 { y }
}

is still cumbersome and gets complicated with nested tuples.

Conclusion

In the end, introducing tuples into the language is more complex than I thought and the most useful case is still the Map

Doing Map thru adding tuples and whole complexities coming with this - not sure it's a good way. Why don't we talk about map directly?

So let's talk about Map. I have found 2 different approaches to them. both have their advantages and disadvantages

nalchevanidze commented 2 years ago

Supporting Maps

requirements

Solution 1: custom type Map

type Query {
   users: Map<ID,User>
}

in this expression {users { name }}, selection name will be applied to user.

advantages:

disadvantages:

Solution 2: named Lists

This proposal is actually independent of the tuples or maps, but could be seen as a lightweight solution to this particular problem (see #914). actual implementation is already tested in my language iris

we could use named lists to define Map, which requires to its elements to have field with name key and check if this key is unique for the whole collection.

list Map

type Query {
  users: Map<Users>
}

pros: is lightweight

cons: we can't support maps Map<Int,Int>. and for each of them we are still forced to create custom Object.

nalchevanidze commented 2 years ago

P.S. Actually we could use JS destruction for tuples or maps (inspired by #888). for schema below we could use { users[ key, { name }] }. where if Key is object { users[ { key } , { name }] }.

type Query {
   users: [(Key,User!)!]!
}

then we don't need limitation that first element should be a scalar or enum.

glen-84 commented 2 years ago

Related: https://github.com/graphql/graphql-spec/issues/534.