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

Plans for cppgraphqlgen 5.0 #311

Open
10 of 11 tasks
wravery opened this issue Sep 10, 2024 · 21 comments
Open
10 of 11 tasks

Plans for cppgraphqlgen 5.0 #311

wravery opened this issue Sep 10, 2024 · 21 comments
Labels
enhancement New feature or request

Comments

@wravery
Copy link
Contributor

wravery commented Sep 10, 2024

This is where I'm going to start tracking features for 5.0, the next major version of cppgraphqlgen. This will be a breaking change for existing consumers, so it's an opportunity to cleanup deprecated methods and simplify some of the APIs. I'm also going to adopt a few more C++20 and C++23 features if they are well enough supported on Windows, Linux, and macOS now.

This is using #172, the tracking issue for cppgraphqlgen 4.0, as a template and starting point.

I'll update this list as new features crop up and as I/we make progress on it. If there's something you'd like to see that I haven't included already, feel free to comment on this issue.

@wravery wravery added the enhancement New feature or request label Sep 10, 2024
@wravery wravery pinned this issue Sep 10, 2024
@wravery
Copy link
Contributor Author

wravery commented Sep 19, 2024

I'm planning on making a release candidate as soon as the CI build finishes. I haven't done any of the documentation updates yet, but if anyone wants to take it for a spin, 5.0.0 should be completely implemented in the next branch now.

@nqf
Copy link

nqf commented Sep 20, 2024

Sorry,I am not qualified to demand anything, but let me share my personal feelings, We compared cpp ,Golang and Rust, ,their(rust,Golang ) grphql framework makes it easier for a novice to start from scratch. Would you consider generating something similar, This is an example of Rust, which really makes it very easy for a beginner to get started, rust-graphql-example

The CPP framework requires us to write some checks ourselves, which is very long and difficult for beginners. In fact, many times we don't need to know the underlying details, we just want to write business logic
cppgraphqlgen-beast-http-server

@wravery
Copy link
Contributor Author

wravery commented Sep 20, 2024

There are some of those samples out there for cppgraphqlgen, in particular the "Star Wars" learning sample is implemented here: next/main. There are also some unrelated samples linked in the root README.

To be honest, I prefer working in Rust with async-graphql myself. In a situation where I'm just trying to implement a GraphQL service with the performance of a compiled native language, I'd reach for that first. What cppgraphqlgen is aimed at is more of a bridge to existing C++ projects. Similarly, I'd probably pick something else beside cppgraphqlgen or async-graphql if I were trying to connect logic in another language/stack.

The Boost.Beast HTTP server example serves a couple of purposes:

  1. It keeps everything in this project in C++, no need for additional language toolchains.
  2. It demonstrates using C++ coroutines from different libraries, including some of the rough edges.

I don't recommend using the Boost.Beast sample as a starting point for a new web service, though, unless you need to use 100% C++ in your project for some reason. Given my own preference for Rust, even if I needed to expose a cppgraphqlgen service through HTTP, I'd probably use a Rust web server and thinner GraphQL bindings to the cppgraphqlgen service, like in gqlmapi-rs.

@nqf
Copy link

nqf commented Sep 20, 2024

Thank you for your reply, Actually, our project is 100% cpp, so I would prefer cpp to have a graphql framework similar to rust‘s async-graphql, But in reality, there is no decent GraphQL framework in the CPP , Therefore, we ultimately chose to use Golang to implement the GraphQL server, But personally, But I sincerely hope that CPP also has a decent(simple + easy-to-use) GraphQL framework

@wravery
Copy link
Contributor Author

wravery commented Sep 20, 2024

Yeah, unfortunately C++ just can't do it as simply. I haven't used Golang or its GraphQL frameworks, but I am familiar with a couple of Rust GraphQL frameworks, and a lot of their ease-of-use comes from procedural macros. There are a few proposals out there for static reflection in C++ or meta-classes that could do something similar, but AFAIK those are many years away from becoming part of the standard.

I think static code generators are the closest we can get to hiding the ugly details from C++.

@nqf
Copy link

nqf commented Sep 20, 2024

Yeah, unfortunately C++ just can't do it as simply. I haven't used Golang or its GraphQL frameworks, but I am familiar with a couple of Rust GraphQL frameworks, and a lot of their ease-of-use comes from procedural macros. There are a few proposals out there for static reflection in C++ or meta-classes that could do something similar, but AFAIK those are many years away from becoming part of the standard.

I think static code generators are the closest we can get to hiding the ugly details from C++.

In fact, cpp can generate code like Golang and finally expose something similar to a callback function. We only need to implement business logic in the callback function

@nqf
Copy link

nqf commented Sep 20, 2024

Actually, I think cppgraphqlgen should be able to be packaged into something like this, But it may be necessary to write a code generation tool to generate the graphql_server class

graphql_server.setAcallback([]() ->asio::await{
co_return;
});
graphql_server.setBcallback([]() ->asio::await {
co_return;
});
graphql_server.handler("/graphql", "POST");

graphql_server.start("0.0.0.0:80");

@gitmodimo
Copy link

gitmodimo commented Sep 20, 2024

I have been using cppgraphqlgen in production code for about 2 years. Here are some of my thouths:

  1. cppgraphqlgen lacks extensibility. Concept similar to Apollo federation.
    I think it seems viable to allow joining two graphql::service::Request services as follows:
    Main schema
schema {
	query: Query
	mutation: Mutation
	subscription: Subscription
}

type Query
type Mutation
type Subscription

Extension schema

extend type Query{
	feature1: SomeFeature!
}
type SomeFeature{
	testValue: Int!
}

I my opinion it should be possible to generate two separate compilation units/libraries that could be literally added to each other at runtime. As in:

std::shared_ptr<graphql::service::Request> mainSchema=...;
std::shared_ptr<graphql::service::Request> feature1=...;
if(feature1active)
   *mainSchema+=*feature1;

Addition should expand TypeMap _operations and merge introspection infofmation.

  1. The library is unnecessarily slow because of few reasons:

A. Response is held in graphql::response::Value which is DOM representation that ususally converted to json using additional library. This could be shortcut with service taking visitor instead of returning response. In fact default visitor could be graphql::response::Value builder to keep backward compatibility, but it could be replaced by JsonBuilder based on for example RapidJSON.

B. There should be more fine grained control for subscriptions (especially NotifySubscribe and NotifyUnsubscribe). I think subscription should return a handle to allow external fine grained control.
Ex. In subscription that uses list field it may happen that list changes between NotifySubscribe and NotifyUnsubscribe. In such case NotifyUnsubscribe is useless in "cleaning up" Subscription event sources. I end up using external accounting of tracking the sources and removing then when subscription completes. I want to be able to skip NotifyUnsubscribe step and just remove from subscription list. I my use case I also always generate initial response for subscription (like in live queries) and thus i can use initial response generation to subscribe to all event sources (instead of NotifySubscribe).

C. The library always wraps objects and scalars in awaitable future (and then awaits them) even when result is returned by value. This causes ridiculous overhead when creating scalar arrays. So far i haven't found a use case for AwaitableObject/AwaitableScalar

Yeah, unfortunately C++ just can't do it as simply. I haven't used Golang or its GraphQL frameworks, but I am familiar with a couple of Rust GraphQL frameworks, and a lot of their ease-of-use comes from procedural macros. There are a few proposals out there for static reflection in C++ or meta-classes that could do something similar, but AFAIK those are many years away from becoming part of the standard.

I think static code generators are the closest we can get to hiding the ugly details from C++.

I agree. And also since all fields originate from schema there will always be a need for external tool to parse schema and generate fields. Static reflection can maybe used to reduce amount of generated code. I don't think there will ever be a compile time AST parser that does not kill the compiler.

EDIT:
4. My need for extensibility has led me to move entire generated part of code to external dll. It was actually possible because graphql dictates return value type and it possible to create models implicitly. My working concept. The only problem is with variant types since we do not know which variant is supposed to be created. I worked this around with the use of additional traits. As a result my entire solution is rid of generated headers. All except XXXSchema.h because I still need enums and input types. Generating Enums and Input types into separate header(separate from what is below this line ) would be useful (also maybe for this).

@wravery
Copy link
Contributor Author

wravery commented Sep 20, 2024

Actually, I think cppgraphqlgen should be able to be packaged into something like this, But it may be necessary to write a code generation tool to generate the graphql_server class

graphql_server.setAcallback([]() ->asio::await{
co_return;
});
graphql_server.setBcallback([]() ->asio::await {
co_return;
});
graphql_server.handler("/graphql", "POST");

graphql_server.start("0.0.0.0:80");

What you're describing sounds more like a web framework than what cppgraphqlgen does. I haven't tried it myself, but Oat++ sounds like it fits. If you (or anyone else) wanted to try combining them, I think both projects would benefit. I'd be happy to add another link to the README. 😃

@wravery
Copy link
Contributor Author

wravery commented Sep 20, 2024

I have been using cppgraphqlgen in production code for about 2 years. Here are some of my thouths:

Great feedback below, thanks!

  1. cppgraphqlgen lacks extensibility. Concept similar to Apollo federation.
    I think it seems viable to allow joining two graphql::service::Request services as follows:
    ...
    I my opinion it should be possible to generate two separate compilation units/libraries that could be literally added to each other at runtime.
    ...
    Addition should expand TypeMap _operations and merge introspection infofmation.

Interesting, yeah. I'm not sure if having schema extensions be handled differently makes sense, but given 2 separate schemas, it should be possible to merge them at the schema level. The only tricky bit, I think, would be figuring out how to dispatch the resolvers to the correct implementation in the merged service at runtime. It might be better to treat this as a new type of service that multiplexes other static services dynamically, including building a merged schema for validation and introspection.

  1. The library is unnecessarily slow because of few reasons:

A. Response is held in graphql::response::Value which is DOM representation that ususally converted to json using additional library. This could be shortcut with service taking visitor instead of returning response. In fact default visitor could be graphql::response::Value builder to keep backward compatibility, but it could be replaced by JsonBuilder based on for example RapidJSON.

As far as converting to JSON goes, there is a visitor pattern already when starting from a response::Value. The response::Writer does this. I'm imagining an API that takes a response::Writer (and maybe a separate visitor to accumulate errors) and passing that to the resolvers to recursively build the results in an alternate form that bypasses response::Value entirely. For instance, clientgen could generate a Writer for the expected Response types. Both of those have some interesting implications for a dynamic multiplexer.

B. There should be more fine grained control for subscriptions (especially NotifySubscribe and NotifyUnsubscribe). I think subscription should return a handle to allow external fine grained control. Ex. In subscription that uses list field it may happen that list changes between NotifySubscribe and NotifyUnsubscribe. In such case NotifyUnsubscribe is useless in "cleaning up" Subscription event sources. I end up using external accounting of tracking the sources and removing then when subscription completes. I want to be able to skip NotifyUnsubscribe step and just remove from subscription list. I my use case I also always generate initial response for subscription (like in live queries) and thus i can use initial response generation to subscribe to all event sources (instead of NotifySubscribe).

I think you're saying that the set of resources/listeners you are tracking to support the subscription might change dynamically, e.g., as items are added or removed from a collection. The approach I'd use for that would probably be to use external bookkeeping for those resources as you do now, but store a handle/ID to those external resources in the RequestState for the subscription. The handler for NotifySubscribe would initialize the external resources, when items are added or removed you can update it externally, and then on NotifyUnsubscribe you'd release everything using the handle to your external resources.

C. The library always wraps objects and scalars in awaitable future (and then awaits them) even when result is returned by value. This causes ridiculous overhead when creating scalar arrays. So far i haven't found a use case for AwaitableObject/AwaitableScalar

One of the changes in the next branch, fa15fb7, had a surprisingly big impact on the benchmark programs, at least when running in a debug build (about 20% more throughput using clang-18 or MSVC, coming from reduced time spent resolving requests). It would make sense if the benchmark used any fields with futures that report timeout, which would spawn another thread, but I don't think it does. I think everything should just be deferred rather than async, which should prevent it from suspending and resuming on another thread. My guess is that making it constexpr short-circuited more of the coroutine machinery at compile time.

I haven't checked a release build yet, but I'd be curious to know if this improves your scalar array scenario.

EDIT: 4. My need for extensibility has led me to move entire generated part of code to external dll. It was actually possible because graphql dictates return value type and it possible to create models implicitly. My working concept. The only problem is with variant types since we do not know which variant is supposed to be created. I worked this around with the use of additional traits. As a result my entire solution is rid of generated headers.

I'll have to dig into this some more, but I gather you specialize the GraphQLBuilder for your object types and create a dynamic service::Object implementation from that? Or have you modified other parts of GraphQLService.cpp or SchemaGenerator.cpp to do something more with expected return types, so they're no longer std::shared_ptr<object::Foo>?

All except XXXSchema.h because I still need enums and input types. Generating Enums and Input types into separate header(separate from what is below this line ) would be useful (also maybe for this).

Re: splitting the enums and input types into one or more separate headers, I figured I'd keep it simple for now and just include the ...Schema.h file. There's not that much other content in that header, and to use shared types you're almost certainly going to include it anyway.

@nqf
Copy link

nqf commented Sep 20, 2024

Actually, I think cppgraphqlgen should be able to be packaged into something like this, But it may be necessary to write a code generation tool to generate the graphql_server class

graphql_server.setAcallback([]() ->asio::await{
co_return;
});
graphql_server.setBcallback([]() ->asio::await {
co_return;
});
graphql_server.handler("/graphql", "POST");

graphql_server.start("0.0.0.0:80");

What you're describing sounds more like a web framework than what cppgraphqlgen does. I haven't tried it myself, but Oat++ sounds like it fits. If you (or anyone else) wanted to try combining them, I think both projects would benefit. I'd be happy to add another link to the README. 😃

We actually need an easy-to-use graphql service, Both rust and golang are easy to integrate with http servers,What I mean is that cppgraphqlgen doesn't easily fit into the cppweb framework,If this project could be like rust aync-graphql, I think it would be more popular

@gitmodimo
Copy link

gitmodimo commented Sep 21, 2024

Interesting, yeah. I'm not sure if having schema extensions be handled differently makes sense, but given 2 separate schemas, it should be possible to merge them at the schema level. The only tricky bit, I think, would be figuring out how to dispatch the resolvers to the correct implementation in the merged service at runtime. It might be better to treat this as a new type of service that multiplexes other static services dynamically, including building a merged schema for validation and introspection.

I think it is called Schema stitching. It seems like easiest route for runtime extensibility. It does not cover type extension but it can be emulated by replacing different static services dynamically.

As far as converting to JSON goes, there is a visitor pattern already when starting from a response::Value. The response::Writer does this.

yes and I reckon the response::Value building part can also be redesigned into visitor pattern.

I'm imagining an API that takes a response::Writer (and maybe a separate visitor to accumulate errors) and passing that to the resolvers to recursively build the results in an alternate form that bypasses response::Value entirely.

Exactly!

For instance, clientgen could generate a Writer for the expected Response types.

And the writer would build response::Value or JSON or even act directly on visited values depending on user provider visitor.

Both of those have some interesting implications for a dynamic multiplexer.

I think it all boils down to proper SAX style writer API

One of the changes in the next branch, fa15fb7, had a surprisingly big impact on the benchmark programs, at least when running in a debug build (about 20% more throughput using clang-18 or MSVC, coming from reduced time spent resolving requests). It would make sense if the benchmark used any fields with futures that report timeout, which would spawn another thread, but I don't think it does. I think everything should just be deferred rather than async, which should prevent it from suspending and resuming on another thread. My guess is that making it constexpr short-circuited more of the coroutine machinery at compile time.

I will definitely check this out. With my custom changes i did nou have time to port them to newer library verion

I'll have to dig into this some more, but I gather you specialize the GraphQLBuilder for your object types and create a dynamic service::Object implementation from that? Or have you modified other parts of GraphQLService.cpp or SchemaGenerator.cpp to do something more with expected return types, so they're no longer std::shared_ptr<object::Foo>?

No there is no specialiation of GraphQLBuilder and no other changes AFAIR. The AwaitableObject contrustor handles 3 cases:
1. U&& value is the generated object model (just like in current library implementation)
2. U&& value is not generated model but can be used to create one. The type of the object model is known as T.
3. Nr 2 but future

The GraphQLBuilder handles building of the model T given U. It handles:

  1. Nullable as optional and shared_ptr
  2. All sorts of vectors recursively
  3. Unions - This is the tricky one because given T we can only know Union type not specific model type to create. This is where I use additional trait that maps user class Foo to object::Foo. Ex:
    Graphql:
union UnionTypeAB = TypeA | TypeB

Field accessor returns graphql::service::Union<TypeA, TypeB>
And with custom mapping trait defined the GraphQLBuilder can build desired object model

template <>
struct GraphQLUnion<graphql::service::Union<TypeA, TypeB>, graphql::GQL::object::UnionTypeAB> : std::true_type
{
  using model_map = type_map<pair<TypeA, graphql::GQL::object::TypeA>,
                             pair<TypeB, graphql::GQL::object::TypeB>>;
};

I purposely decided to use external GraphQLUnion trait class instead of creating mapping inside TypeA/TypeB classes to be able to compile all user code without including any of generated class headers. That gives me the ability to generate multiple different schemas, reuse the objects and even adjust union mappings where necessary.
The only header that I use in user code is XXXSchema.h cause I need definitions of the inputs and enums. And the ugly part is that I also need the implementations of Input types in XXXSchema.cpp to link my user code. And due to XXXSchema.cpp implements also Operations it pulls in all the generated headers.
If input types and enums were in separate header i could reuse the same types header for multiple different schemas as long as they all use subset of "full schema" types.

At this moment my build flow looks like this:

  1. generate schema with all features enabled
  2. use fully featured XXXSchema.h in project build without any actual graphqlservice.
  3. generate schema for every subset of features
  4. for each subset feature schema build dll with graphqlservice using object implementations of 2. Each subset can have individual union mapping defined inn their compilation unit. Type mapping is handled by graphql and concepts are verified during dll compilation.

This solution would work nicely if only there we something equivalent to schemagen --shared-types and schemagen --only-types
This would allow to separately develop different features in separate schema files (assuming no naming collisions). Each feature would generate its own "types.h" header and use it.
Then as a final step merge all the schmea-parts that are desired and generate specialized service including desired feature-set.
My dream is to be able to do it in runtime, but for now it is next best thing.

It does not fully give extensibility but it is somewhere in between.

Re: splitting the enums and input types into one or more separate headers, I figured I'd keep it simple for now and just include the ...Schema.h file. There's not that much other content in that header, and to use shared types you're almost certainly going to include it anyway.

I am including it. The problem is it pulls all other headers from cpp file.

@gitmodimo
Copy link

I very much like the work you did here. I need to migrate to next branch ASAP and test the changes in my use case.
I like how you managed to implement visitor and maintain asynchronous resolving. Maybe both visitors should be available in library and let user decide whether to use asynchronous visitor or sequencing one.
I think for real live situation the benchmark should include sample transport layer implementation(like conversion toJson and fromJson). My gut feeling is that in most cases serializing visitor building JSON directly will be faster than asynchronous.

I also like the new stitching ability. Can the same approach can be used to stitch nested fields? It now looks like only query/mutation/subscription can be stitched.

@wravery
Copy link
Contributor Author

wravery commented Oct 21, 2024

I very much like the work you did here. I need to migrate to next branch ASAP and test the changes in my use case.

Thanks! I'm planning on doing a pilot migration of https://github.com/microsoft/gqlmapi as well, and that might flush out some issues. Having more coverage with your project will help avoid needing to make another version right after the release.

I like how you managed to implement visitor and maintain asynchronous resolving. Maybe both visitors should be available in library and let user decide whether to use asynchronous visitor or sequencing one.

Using an empty await_async should behave this way. Building a ValueTokenStream just defers the calls to the visitor until everything is in the right order.

I think for real live situation the benchmark should include sample transport layer implementation(like conversion toJson and fromJson). My gut feeling is that in most cases serializing visitor building JSON directly will be faster than asynchronous.

There are 2 JSON style visitors now, and I'm considering collapsing them into one, using the ValueVisitor instead of Writer since it preserves greater fidelity for things like enum values. I just need to rework the default implementation of Writer in toJson to do that.

At that point, I'll probably also add a visit method to ResolverResult so there's a way to use the visitors with a complete service request document for transport and not just the data field.

I also like the new stitching ability. Can the same approach can be used to stitch nested fields? It now looks like only query/mutation/subscription can be stitched.

I haven't built anything that does this yet, but it should be possible. The resolver that returns one of the type-erased service::Object objects would need to create one from each schema, then stitch the sub-objects with Object::StitchObject and return that.

It requires modification of one of the sub-schema's resolvers too make it aware of this, so all I've done so far is to use the preexisting mock today and learn services to verify that I could query non-overlapping fields from both.

BTW, in case of overlaps, the first schema (the one where you call stitch) or Object (where you call StitchObject) takes priority. However, the merged ResolverMap retains the original this captures, so, there's no additional dispatch overhead to get to the original resolvers. This means you can layer multiple stitched objects or schemas without additional runtime overhead during query execution aside from the increased size of the ResolverMap, which should scale at O(log(n)) with binary search lookups.

@wravery
Copy link
Contributor Author

wravery commented Oct 22, 2024

One more caveat about schema stitching: since the include guards will collide on types with the same name, the cleanest/simplest way to handle that is probably to import the generated C++20 modules instead of including the headers when creating the partial Object from each sub-schema.

When mixing includes and headers, always do all the includes first and then switch to imports in the main (i.e., .cpp) file. Don't try to import modules from a header because that easily breaks the ordering. The only way that can be safe is if it's the last header you include and it follows the rules about doing all of its own includes before it does the first import, which couples that header to the order it's included, and it makes it incompatible with any other headers that do this.

@gitmodimo
Copy link

One more caveat about schema stitching: since the include guards will collide on types with the same name, the cleanest/simplest way to handle that is probably to import the generated C++20 modules instead of including the headers when creating the partial Object from each sub-schema.

I think relying on modules and imposing strict including rules will limit portability and ease of use. Can't we just add namespace to include guard? And maybe also to filenames to prevent confusing filename duplicates? Then only requirement for stitching will be to use different namespaces for stitching parts.

@gitmodimo
Copy link

With stitching I see 3 fields that need individual care:

  1. Object stitching
    Proof of concept is already in samples. I think it should be allowed to stitch object at any level not only root. Maybe the library could accept type-erased resolver in place of any object?
  2. Schema stitching
    Schema should support addition of new types, as well as, type extensions. This may require adding some kind of type erased "extendObject" to be generated from schema.
    I am talking about a case equivalent to current stitching sample with today schema define extend type Query... insted of type Query....
  3. Enum Stitching
    This is the most cumbersome part. I cannot think of any nice way to extend ENUM. For this reason I avoid extending enums. Maybe it should be allowed to return string in place of enum since they are stored as such later on? Plus maybe validate the string against schema?

@wravery
Copy link
Contributor Author

wravery commented Oct 24, 2024

With stitching I see 3 fields that need individual care:

  1. Object stitching
    Proof of concept is already in samples. I think it should be allowed to stitch object at any level not only root. Maybe the library could accept type-erased resolver in place of any object?

Since the type-erased objects are all inherited from Object, and the logic in GraphQLService.cpp is all written in terms of that base-type, it's fairly easy to combine type-erased objects in a resolver using Object::StichObject (same as Request::stitch does for the root objects).

The tricky part with nested resolvers is, where do you get the instance of a type-erased object from the other schema? Root objects are easy, they're fixed at service instantiation, but there's a path of resolvers (some of which may not exist in both schemas) leading to the returned object in a nested resolver. That's what I think blocks auto-stitching, e.g., somewhere in AwaitableObject. At some point while resolving a request and calling into the field resolvers, the service would need to somehow call the same resolver on both objects and stitch the results, assuming both are returned.

  1. Schema stitching
    Schema should support addition of new types, as well as, type extensions. This may require adding some kind of type erased "extendObject" to be generated from schema.
    I am talking about a case equivalent to current stitching sample with today schema define extend type Query... insted of type Query....

Type extensions are parsed the same way as regular type declarations. Technically, it probably shouldn't accept an extension without a base declaration somewhere, but I don't remember if I ever tried that. So, if you want to use the extend type Query... syntax, it should be possible to just build a schema from the extension and stitch it together the same way. Of course, if you're using extension syntax, you could also work out a way to concatenate the schema definition files and parse them into a single static schema to begin with.

  1. Enum Stitching
    This is the most cumbersome part. I cannot think of any nice way to extend ENUM. For this reason I avoid extending enums. Maybe it should be allowed to return string in place of enum since they are stored as such later on? Plus maybe validate the string against schema?

The way it's meant to work currently is that the schema will merge the enum values, so it'll pass validation. When you call a resolver in either schema with the same enum type, it'll pass it through the ModifiedArgument/Argument types that were built with the shared types for the target resolver's sub-schema. If you use a value it didn't know about, it'll reject the argument with an error. When returning values, it'll use a string (with the ModifiedResult/Result from the sub-schema of the resolver) and it'll map the integer enum values from the sub-schema to the same string as before. Fields from each sub-schema should allow all of the values they knew about, mapped to their own integer values in case of overlap, as arguments, and they can return any of the values that they contributed or share in the merged enum.

Aside from introspection queries against the stitched sample, I haven't fully tested this, so LMK if you're seeing it break.

@gitmodimo
Copy link

gitmodimo commented Oct 31, 2024

The tricky part with nested resolvers is, where do you get the instance of a type-erased object from the other schema? Root objects are easy, they're fixed at service instantiation, but there's a path of resolvers (some of which may not exist in both schemas) leading to the returned object in a nested resolver.

I was thinking some kind of extensible ObjectModel Factory with tag dispatch. Pseudocode sketch:

Base schema:

type Query{
  test: Test!
}

type Test{
  foo: Int!
}

Extend schema:

extend type Query{
  newType: NewType!
}
type NewType{
  baz: String!
}
extend type Test{
  bar: Float!
}

Generated from concatenation of all schema parts:

//Generate all input types, enums
//Generate tags for all types
struct Query{};
struct Test{};
struct NewType{};

Generated for Base:


namespace graphql::Base{
  template <typename Model_t>
  struct ModelFactory{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {}//empty resolver
    }
  }

 template <>
  struct ModelFactory<Test>{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {"foo"}//Resolver for base Test object
    }
  }

 template <>
  struct ModelFactory<Query>{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {"test"}//Resolver for base Query object
    }
  }
}

Generated for Extend:

namespace graphql::Extend{
  template <typename Model_t>
  struct ModelFactory{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {}//empty resolver
    }
  }

 template <>
  struct ModelFactory<Query>{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {"baz"}//Resolver for extend part of Query object
    }
  }

 template <>
  struct ModelFactory<NewType>{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {"baz"}//Resolver for NewType object
    }
  }

 template <>
  struct ModelFactory<Test>{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return {"bar"}//Resolver for extend part of Test object
    }
  }
}

User implementation:

struct Test{
  int getFoo();
  float getBar();
}

struct NewType{
  std::string getBaz();
}

struct Query{
  std::shared_ptr<Test> getTest();
  std::shared_ptr<NewType> getNewType();
}

An then add some template template magic:

  template <typename Model_t>
  struct StitchedFactory{
    template<typename Object_t> resolver Create(std::shared_ptr<Object_t> obj){
       return graphql::Base::ModelFactory<Model_t>::Create(obj).stitch(graphql::Extend::ModelFactory<Model_t>::Create(obj))
    }
  }

graphql::service<graphql::Base::ModelFactory> baseResover;
graphql::service<StitchedFactory> extendedResover;

The idea requires more work, but I think it shows the idea. Also I think tag dispatch would also elegantly solve Unions and their extension - user would return a std::variant<std::pair<TypeTag,std::shared_ptr<Object_t>,...> and resolver would Create adequate model. I am aware that following such an approach would be total table flip and library overhaul, but in my opinion It would substantially expand its capabilities. User could even overload StitchedFactory::Create on per Type basis and perform additional runtime logic when needed. Tell me what you think. If you find potential in such approach I am very willing to put some more work into it.

For backwards compatibility ModelFactory could also have specialization for type erased Object.

Of course, if you're using extension syntax, you could also work out a way to concatenate the schema definition files and parse them into a single static schema to begin with.

This what I am doing right now. Take example above. Given an "extension schema part" I create dll for Base schema and one dll for Base+Extend schema. Then at runtime I select desired dll and use it as my resolver. This however does not scale as I need to create every possible combination with multiple "extension schema part"s. Also to achieve that I had to modify the library to move all generated types into dlls - "user" code does not instantiate graphql::Base::Object::Test nor graphql::BaseExtended::Object::Test. User code returns std::shared_ptr<Test> and factory within dll instantiates one depending on which dll is loaded.

@gitmodimo
Copy link

gitmodimo commented Nov 5, 2024

I finally got some time to dig into my performance issues. Again great work with ValueTokenStream implementation. It doesn't directly help in my particular use case (big array) , but it is relatively simple to speed everything up with:

  1. Skip creation of response::Value in toJson conversion
  2. Create conversion shortcut for arrays of non-awaitable scalars

I run Today benchmarks on my PC on release build. Results in requests/second:

Unmodified convert

  bigArray no Array
responseToJson 743,204 10416,6
resolverResultToJson 827,417 11039,5
ToJsonSpeedup +11,33% +5,98%

Shortcut for std::vector of non-awaitable scalars.

  bigArray no Array
responseToJson 2258,23 10892,1
resolverResultToJson 2668,08 11712,5
ToJsonSpeedup +18,15% +7,53%

Convert speedup

  bigArray no Array
responseToJson +203,85% +4,56%
resolverResultToJson +222,46% +6,10%

The same shortcut could be implemented for multidimensional arrays. Probably the code could be simplified if there were some kind of ValueToken traits. Next I will try to migrate my project to next branch.

@gitmodimo
Copy link

I have concluded my transition to next branch. I am vary happy with the result. SharedTypes and value visitor simplified everything sybstantially. There are few additional changes that could be merged if you think they could be useful in general. Please have a look at:
next...gitmodimo:cppgraphqlgen:next
I can create PR for features you see fit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants