nils

I'm getting: PHP Fatal error: Call to undefined method SilverStripe\\GraphQL\\Manager::createFromConfig()

wmk

yes, add it as a git repo in your composer.json and grab that branch

nils

how do I test this? https://github.com/silverstripe/silverstripe-graphql/pull/202

Show 2 attachment(s)
unclecheese

Problem

The GraphQL schema is generated on the fly for every request. Depending on how populated your schema is, this can become quite expensive and makes scaling to large projects implausible. <https://github.com/silverstripe/silverstripe-graphql/issues/192#issuecomment-443972793|Benchmarks show> that a response of 30-40 seconds wouldn't be atypical for a decent sized project.

What this does

This pull request introduces a number of low-level architectural and API changes to the graphql module which afford it layer than can persist the generated schema as a side effect of a build task and subsequently fetched in its fully inflated state on demand with minimal latency. With the benefit of lazy-loaded types, response times scale independently from the density of the schema, and are abated only by common and manageable bottlenecks of the request cycle (e.g. ORM performance). <https://github.com/silverstripe/silverstripe-graphql/issues/192#issuecomment-449199812|Benchmarks show> that a schema with more than 1,000 types (100 dataobjects) produces a readOne response in under 100ms on a moderately outfitted SS Platform virtual stack.

Major API Changes

• Schema must be built using a dev/ task instead of in the request cycle. • Decoupling from the webonyx/graphql-php library. Type creators and scaffolders no longer return GraphQL\Type\Definition\Type, but rather our own implementation of graphql type abstractions. • Resolvers can no longer be anonymous functions. Use static callables, e.g. [My::class, 'someFunc'] or, for dynamic functions, an implementation of ClosureFactoryInterface to build closures lazily. • Manager now has a SchemaStorageInterface dependency. • Middleware callback signature now takes an abstract Schema object instead of GraphQL\Type\Definition\Schema.

How it works (high level)

The solution uses code generation to store a monolithic PHP class in a temporary folder that is later fetched, included and given to the schema as a <https://webonyx.github.io/graphql-php/type-system/schema/#lazy-loading-of-types|lazy lookup for types>.

This has a major footprint on developer ergonomics. Developers will now have to run a task to generate a new schema every time it changes. To generate the schema, run /dev/schema/[SCHEMA-KEY].

Schema caching is compulsory. There is no backward compatibility for dynamically created and cached schemas. This breakage is completely defensible citing that status quo performs so poorly. It should be viewed as a flaw in the system more than an API worth protecting.

How it works (technical) Current request cycle (status quo)

[ HTTPRequest ]
    ↓
    ↓
[ Controller ]
    ↓
    ↓
[ Manager ]  ← ← [ GraphQL Types ] ← ←  [ Scaffolding / Type Creators ]
    ↓
    ↓
[ Manager::query() ]
    ↓
    ↓
[ GraphQL:execute() ]
    ↓
    ↓
[ ExecutionResult ]
    ↓
    ↓
[ HTTPResponse ]

This pull request introduces architectural changes that add layers of abstraction between all these points and decouple everything.

New request cycle GET /dev/schema/[schema-key]

[ Manager::regenerate() ]
    ↓
    ↓
[ Manager ]  ← ← [ Type abstractions ] ← ←  [ Scaffolding / Type Creators ]
    ↓
    ↓
[ SchemaStorageInterface::persist() ]
    ↓
    ↓
[ TypeRegistryEncoderInterface::encode() ]
    ↓
    ↓
[ /tmp/cached-schema.php ]

GET /graphql/?query=...

[ HTTPRequest ]
    ↓
    ↓
[ Controller ]
    ↓
    ↓
[ Manager ]
    ↓
    ↓
[ SchemaStorageInterface::load() ]
    ↓
    ↓
[ Abstract schema ]  ← ← [ TypeRegistryInterface ]
    ↓
    ↓
[ SchemaHandlerInterface ] 
    ↓
    ↓
[ SchemaHandlerInterface::query() ]
    ↓
    ↓
[ QueryResultInterface ]
    ↓
    ↓
[ HTTPResponse ]

Everything is now decoupled and agnostic of the graphql-php library. In theory, you'd only need a new implementation of the encoders and the schema storage service to export your schema to another library, or even another platform, like Node.

Abstractions

One major API change is that scaffolders and type creators can no longer return concrete graphql-php types. Instead, the entire system uses its own library of schema primitives, e.g. Enum, Union, Argument, and FieldCollection (for objects), which are essentially value objects with fluent APIs that friendly to the encoder, accepting only those properties which encode easily and enforce internal rules such as the use of ClosureFactoryInterface instead of anonymous functions.

Additionally, in type creators, fields should no longer be returned as plain arrays, but rather use the Field abstraction. Backward compatibility has been added via Field::createFromConfig(array $config).

Referential types

One of the challenges with scaffolders writing directly to graphql-php types is that the types have to be referentially equal across the whole schema. This introduced all kinds of race conditions and confusing workarounds using lazily executed functions. By working with our own set of abstract types, we can avoid that and make it the job of the encoder to build our references.

The TypeReference class allows you to build a reference to a type by name only, not instance, e.g. -&gt;setType(TypeReference::create('File')) will encode to $this-&gt;getType('File') and TypeReference::create('ID') will encode to Type::id().

For ergonomics, shortcuts are provided to explicitly use internal types, e.g. InternalType::id().

New interfaces (partial list) New boilerplate for a Manager instance

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Manager.default:
    class: SilverStripe\GraphQL\Manager
    constructor:
      schemaKey: default
      schemaStore: '%$SilverStripe\GraphQL\Schema\SchemaStorageInterface.default'
    properties:
      Middlewares:
        CSRFMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.csrf'
        HTTPMethodMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.httpMethod'
  SilverStripe\GraphQL\Schema\SchemaStorageInterface.default:
    class: SilverStripe\GraphQL\Schema\Encoding\CodeGenerationSchemaStore
    constructor:
      registryEncoder: '%$SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeRegistryEncoderInterface.default'
  SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeRegistryEncoderInterface.default:
    class: SilverStripe\GraphQL\GraphQLPHP\Encoders\SchemaEncoder
    constructor:
      identifier: default
      encoderRegistry: '%$SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeEncoderRegistryInterface'
  SilverStripe\GraphQL\Schema\SchemaHandlerInterface:
    class: SilverStripe\GraphQL\GraphQLPHP\SchemaHandler

Questions Do we really need a homespun library of type abstractions?

Because we're relying on code generation, we have to define our own rules on the composition of a type. If we were to allow the scaffolders to simply export graphql-php types, we would have to blacklist a lot of their API surface (e.g. astNode, mapFn and resolver as a Closure). It seems inappropriate to use graphql-php types as value objects that are simply exported to encoders that encode them right back.

Can't the schema cache just invalidate itself?

Probably. The schema build should be keyed to the config sha, and dev/build should build the schema when it changes. There are still a number of questions open on how to create the best developer experience. A new endpoint to remember and the conditions that make it a requirement is a nontrivial burden to developers.

Is the generated schema a black box? What if I want to load my own custom types in?

You can side load your own raw graphql-php types into the type registry using Manager::setRegistryExtensions(TypeRegistryInterface[]). This creates a failover system in the type registry, much like ViewableData::customise(), where if the type isn't found, it looks it up in the custom extension.


SilverStripe\GraphQL\Manager.default:
  properties:
    registryExtensions:…
unclecheese

Problem

The GraphQL schema is generated on the fly for every request. Depending on how populated your schema is, this can become quite expensive and makes scaling to large projects implausible. <https://github.com/silverstripe/silverstripe-graphql/issues/192#issuecomment-443972793|Benchmarks show> that a response of 30-40 seconds wouldn't be atypical for a decent sized project.

What this does

This pull request introduces a number of low-level architectural and API changes to the graphql module which afford it layer than can persist the generated schema as a side effect of a build task and subsequently fetched in its fully inflated state on demand with minimal latency. With the benefit of lazy-loaded types, response times scale independently from the density of the schema, and are abated only by common and manageable bottlenecks of the request cycle (e.g. ORM performance). <https://github.com/silverstripe/silverstripe-graphql/issues/192#issuecomment-449199812|Benchmarks show> that a schema with more than 1,000 types (100 dataobjects) produces a readOne response in under 100ms on a moderately outfitted SS Platform virtual stack.

Major API Changes

• Schema must be built using a dev/ task instead of in the request cycle. • Decoupling from the webonyx/graphql-php library. Type creators and scaffolders no longer return GraphQL\Type\Definition\Type, but rather our own implementation of graphql type abstractions. • Resolvers can no longer be anonymous functions. Use static callables, e.g. [My::class, 'someFunc'] or, for dynamic functions, an implementation of ClosureFactoryInterface to build closures lazily. • Manager now has a SchemaStorageInterface dependency. • Middleware callback signature now takes an abstract Schema object instead of GraphQL\Type\Definition\Schema.

How it works (high level)

The solution uses code generation to store a monolithic PHP class in a temporary folder that is later fetched, included and given to the schema as a <https://webonyx.github.io/graphql-php/type-system/schema/#lazy-loading-of-types|lazy lookup for types>.

This has a major footprint on developer ergonomics. Developers will now have to run a task to generate a new schema every time it changes. To generate the schema, run /dev/schema/[SCHEMA-KEY].

Schema caching is compulsory. There is no backward compatibility for dynamically created and cached schemas. This breakage is completely defensible citing that status quo performs so poorly. It should be viewed as a flaw in the system more than an API worth protecting.

How it works (technical) Current request cycle (status quo)

[ HTTPRequest ]
    ↓
    ↓
[ Controller ]
    ↓
    ↓
[ Manager ]  ← ← [ GraphQL Types ] ← ←  [ Scaffolding / Type Creators ]
    ↓
    ↓
[ Manager::query() ]
    ↓
    ↓
[ GraphQL:execute() ]
    ↓
    ↓
[ ExecutionResult ]
    ↓
    ↓
[ HTTPResponse ]

This pull request introduces architectural changes that add layers of abstraction between all these points and decouple everything.

New request cycle GET /dev/schema/[schema-key]

[ Manager::regenerate() ]
    ↓
    ↓
[ Manager ]  ← ← [ Type abstractions ] ← ←  [ Scaffolding / Type Creators ]
    ↓
    ↓
[ SchemaStorageInterface::persist() ]
    ↓
    ↓
[ TypeRegistryEncoderInterface::encode() ]
    ↓
    ↓
[ /tmp/cached-schema.php ]

GET /graphql/?query=...

[ HTTPRequest ]
    ↓
    ↓
[ Controller ]
    ↓
    ↓
[ Manager ]
    ↓
    ↓
[ SchemaStorageInterface::load() ]
    ↓
    ↓
[ Abstract schema ]  ← ← [ TypeRegistryInterface ]
    ↓
    ↓
[ SchemaHandlerInterface ] 
    ↓
    ↓
[ SchemaHandlerInterface::query() ]
    ↓
    ↓
[ QueryResultInterface ]
    ↓
    ↓
[ HTTPResponse ]

Everything is now decoupled and agnostic of the graphql-php library. In theory, you'd only need a new implementation of the encoders and the schema storage service to export your schema to another library, or even another platform, like Node.

Abstractions

One major API change is that scaffolders and type creators can no longer return concrete graphql-php types. Instead, the entire system uses its own library of schema primitives, e.g. Enum, Union, Argument, and FieldCollection (for objects), which are essentially value objects with fluent APIs that friendly to the encoder, accepting only those properties which encode easily and enforce internal rules such as the use of ClosureFactoryInterface instead of anonymous functions.

Additionally, in type creators, fields should no longer be returned as plain arrays, but rather use the Field abstraction. Backward compatibility has been added via Field::createFromConfig(array $config).

Referential types

One of the challenges with scaffolders writing directly to graphql-php types is that the types have to be referentially equal across the whole schema. This introduced all kinds of race conditions and confusing workarounds using lazily executed functions. By working with our own set of abstract types, we can avoid that and make it the job of the encoder to build our references.

The TypeReference class allows you to build a reference to a type by name only, not instance, e.g. -&gt;setType(TypeReference::create('File')) will encode to $this-&gt;getType('File') and TypeReference::create('ID') will encode to Type::id().

For ergonomics, shortcuts are provided to explicitly use internal types, e.g. InternalType::id().

New interfaces (partial list) New boilerplate for a Manager instance

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Manager.default:
    class: SilverStripe\GraphQL\Manager
    constructor:
      schemaKey: default
      schemaStore: '%$SilverStripe\GraphQL\Schema\SchemaStorageInterface.default'
    properties:
      Middlewares:
        CSRFMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.csrf'
        HTTPMethodMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.httpMethod'
  SilverStripe\GraphQL\Schema\SchemaStorageInterface.default:
    class: SilverStripe\GraphQL\Schema\Encoding\CodeGenerationSchemaStore
    constructor:
      registryEncoder: '%$SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeRegistryEncoderInterface.default'
  SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeRegistryEncoderInterface.default:
    class: SilverStripe\GraphQL\GraphQLPHP\Encoders\SchemaEncoder
    constructor:
      identifier: default
      encoderRegistry: '%$SilverStripe\GraphQL\Schema\Encoding\Interfaces\TypeEncoderRegistryInterface'
  SilverStripe\GraphQL\Schema\SchemaHandlerInterface:
    class: SilverStripe\GraphQL\GraphQLPHP\SchemaHandler

Questions Do we really need a homespun library of type abstractions?

Because we're relying on code generation, we have to define our own rules on the composition of a type. If we were to allow the scaffolders to simply export graphql-php types, we would have to blacklist a lot of their API surface (e.g. astNode, mapFn and resolver as a Closure). It seems inappropriate to use graphql-php types as value objects that are simply exported to encoders that encode them right back.

Can't the schema cache just invalidate itself?

Probably. The schema build should be keyed to the config sha, and dev/build should build the schema when it changes. There are still a number of questions open on how to create the best developer experience. A new endpoint to remember and the conditions that make it a requirement is a nontrivial burden to developers.

Is the generated schema a black box? What if I want to load my own custom types in?

You can side load your own raw graphql-php types into the type registry using Manager::setRegistryExtensions(TypeRegistryInterface[]). This creates a failover system in the type registry, much like ViewableData::customise(), where if the type isn't found, it looks it up in the custom extension.


SilverStripe\GraphQL\Manager.default:
  properties:
    registryExtensions:…
Hide attachment content
serge

well, then I'd check with xdebug if SilverStripe\GraphQL\Manager gets the middleware you are adding with the configs. if it's not there, makes it a configuration issue. if it's there, would be a bug I think.

👍 (2)