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

[WIP] - Feature: Discriminator Support on composeMongoose #340

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from

Conversation

jhbuchanan45
Copy link

@jhbuchanan45 jhbuchanan45 commented May 26, 2021

I've putting some work into getting discriminator support for the composeMongoose function.
Currently my approach is loosely based on the old system, (extending ObjectTypeComposer and building TCs which reflect the mongoose schema more closely) but it is enabled through composeMongooseOpts rather than a different compose function.
There's also theoretical support for nested discriminator fields with another option, but I'm not as confident in those working, especially on the input types.

type ComposeMongooseOpts = {
...
/**
* Support discriminators on base models
*/
includeBaseDiscriminators?: boolean;
/**
* EXPERIMENTAL - Support discriminated fields on nested fields
* May not work as expected on input types for mutations
*/
includeNestedDiscriminators?: boolean;
}

Changes to Existing Code

The following are changes to the current composeMongoose code which are not exclusive to this feature, none of them should be breaking, certainly all existing tests are passing:

  • Using instanceof instead of constructor.name checking ObjectTypeComposer for resolvers in 643bae0.

  • In src/fieldsConverter.ts the options are now passed through any type conversion function which could be a discriminator type (embedded, embedded array, etc.) for supporting nested discriminator behaviour.

  • In d586798 I also changed most of the resolvers to use tc.getTypeName() instead of directly passing tc for type, since that points to the correct TC internally, and allows it to be overridden for the EDiscriminatorTypeComposer to point to the InterfaceTC.

Implementation Overview

The EDiscriminatorTypeComposer class, (E being for Enhanced because DTC is already taken by the old system) which extends the ObjectTypeComposer class, (requiring minimal typing changes in the existing code) contains methods for building and using the discriminator system, and has overrides so field modification works as expected.

class EDiscriminatorTypeComposer<TSource, TContext> extends ObjectTypeComposer<TSource, TContext> {
  discriminatorKey: string = '';
  discrimTCs: {
      [key: string]: ... ObjectTypeComposer<any, TContext>;
    } = {};
  BaseTC: ObjectTypeComposer<TSource, TContext>;
  DInputObject: ObjectTypeComposer<TSource, TContext>;
  DInterface: InterfaceTypeComposer<TSource, TContext>;
  opts: ComposeMongooseDiscriminatorsOpts<TContext> = {};
  DKeyETC?: EnumTypeComposer<TContext>;
...
}

Representing the discriminated model/schema:

  • Output Type: DInterface is used, which contains resolvers to each of the sub-types inside discrimTCs and the fields from the base (shared) schema.
  • Input Type: All getter methods for this TC (Including on DInterface) should point toward DInputObject's InputTC, which contains an amalgamation* of all of the fields on the discrimTCs.

*(Since GraphQL currently only supports Interface types on outputs, this rudimentary solution should allow all valid input objects to be accepted, with mongoose responsible for validating the exact discriminated type, and is necessary for nested discriminators to be supported for input)

To allow eDTCs to be used throughout the current code with minimal changes, I switched the resolvers in d586798 to use tc.getTypeName(), since I could override it inside the eDTC class to point to DInterface without making any other changes to the resolver logic specific to the eDTC class:

getTypeName(): string {
   return this.DInterface.getTypeName();
}

Overriding OTC 'Set' methods

Currently the following 'set' methods (and derivatives) should be supported so they change the correct fields/properties on each component of the eDTC:

  • setField()
  • setExtensions()
  • setDescription()
  • removeField()
  • removeOtherFields()
  • reorderFields()

In addition, I intend to support these methods, but I haven't implemented them yet:

  • makeFieldNonNull()
  • makeFieldNullable()
  • makeFieldPlural()
  • makeFieldNonPlural()
  • deprecate()
  • set/removeResolver()
  • addRelation()
  • set/removeInterface() ?

There are also a bunch of methods like these which I don't entirely understand the purpose of yet, like those relating to fieldArgs, (are these for input types?) and some which I don't think need a special case for discriminators, but they could be changed on request.

To Do

  • Implement currently missing 'set' methods
  • Enum type on discriminatorKey field for valid types
  • Examples and tips for documentation
  • More testing, especially with integration and writing/reading from an actual database using options like filters and sorts etc.
  • Changes in 643bae0 need made and PRed into graphql-compose-connection and graphql-compose-pagination repos

Somewhere along the line I think this addresses #326 since it includes the discriminatorKey field when discriminators are enabled (also currently a custom dKey is required since fields beginning with '__', importantly here including '__t', are stripped from the field list)

Conclusion

While I think this is is decent solution for supporting discriminators with the new composeMongoose function while requiring minimal changes to existing code, I'm still not overly happy with the way it works, several key parts feel much too 'hacky' like overriding getTypeName() and reassigning the InputTC functions. While they work at the minute, I suspect they may be susceptible to breaking from minor changes either in the other code in this plugin, or in graphql-compose.
The input types are also far from ideal, but hopefully this can be revisited when/if graphql introduce input Interface types. As best as I could find this is currently being discussed, and with a spec for it agreed in their repo
Having said all this, I'm still not really sure what form a better implementation would take, but hopefully this can server to kickstart further discussion on the subject. I've also spent a good amount of time looking at the older implementation of DTCs as part of working on this, so I should be able to answer questions about that.

Finally, this is my first PR, so please point out anything stupid I've done.

@nodkz
Copy link
Member

nodkz commented May 27, 2021

Wow, wOw, woW! 🤯
Incredible idea 👍

Now I'm working under graphql-compose v9 where will be refactored directives. It does NOT now affect your PR it affects only my free time. I'll return today evening or tomorrow for reviewing your PR and giving recommendations. Sorry for the small delay from my side.

@codecov-commenter
Copy link

codecov-commenter commented May 27, 2021

Codecov Report

Merging #340 (425fbb4) into master (8bb278e) will increase coverage by 0.30%.
The diff coverage is 97.83%.

❗ Current head 425fbb4 differs from pull request most recent head 0167cb2. Consider uploading reports for the commit 0167cb2 to get more accurate results
Impacted file tree graph

@@            Coverage Diff             @@
##           master     #340      +/-   ##
==========================================
+ Coverage   92.84%   93.15%   +0.30%     
==========================================
  Files          57       59       +2     
  Lines        2195     2337     +142     
  Branches      673      689      +16     
==========================================
+ Hits         2038     2177     +139     
- Misses        155      157       +2     
- Partials        2        3       +1     
Impacted Files Coverage Δ
src/resolvers/connection.ts 95.23% <ø> (-0.22%) ⬇️
src/resolvers/pagination.ts 100.00% <ø> (ø)
src/fieldsConverter.ts 92.62% <96.00%> (-0.17%) ⬇️
...hancedDiscriminators/eDiscriminatorTypeComposer.ts 97.60% <97.60%> (ø)
src/composeMongoose.ts 97.14% <100.00%> (+1.30%) ⬆️
src/discriminators/prepareBaseResolvers.ts 100.00% <100.00%> (ø)
src/enhancedDiscriminators/index.ts 100.00% <100.00%> (ø)
src/index.ts 100.00% <100.00%> (ø)
src/resolvers/count.ts 81.81% <100.00%> (+0.86%) ⬆️
src/resolvers/createMany.ts 95.55% <100.00%> (+0.10%) ⬆️
... and 30 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8bb278e...0167cb2. Read the comment docs.

Copy link
Member

@nodkz nodkz left a comment

Choose a reason for hiding this comment

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

I'm still watching the logic of EnhancedDiscriminators and return back tomorrow.
I forgot a lot of things about Discriminators and need more time for remembering.

@@ -58,15 +58,15 @@ export function findMany<TSource = any, TContext = any, TDoc extends Document =
throw new Error('First arg for Resolver findMany() should be instance of Mongoose Model.');
}

if (!tc || tc.constructor.name !== 'ObjectTypeComposer') {
if (!tc || !(tc instanceof ObjectTypeComposer)) {
Copy link
Member

Choose a reason for hiding this comment

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

Good catch! This is a very old hack before I started to use peerDependencies for graphql-compose.
I use to use this when I had multiple graphql-compose packages in node_modules. But for now, there is no need in constructor.name check.

throw new Error('Second arg for Resolver findMany() should be instance of ObjectTypeComposer.');
}

const aliases = prepareNestedAliases(model.schema);
const aliasesReverse = prepareAliasesReverse(model.schema);

return tc.schemaComposer.createResolver<TSource, TArgs>({
type: tc.NonNull.List.NonNull,
type: tc.schemaComposer.getAnyTC(tc.getTypeName()).NonNull.List.NonNull,
Copy link
Member

Choose a reason for hiding this comment

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

Maybe better to change '[' + tc.getTypeName() + '!]!' if you want to override type in future.

But I'm still unsure that use type as a string is a good idea. Maybe we just allow passing Interfaces to all resolvers:

export function findMany<TSource = any, TContext = any, TDoc extends Document = any>(
  model: Model<TDoc>,
-  tc: ObjectTypeComposer<TDoc, TContext>,
+  tc: ObjectTypeComposer<TDoc, TContext> | InterfaceTypeComposer<TDoc, TContext>,
  opts?: FindManyResolverOpts
):

Resolvers in graphql-compose === FieldConfigs` in graphql.
So we can generate as many Resolvers as we want. And maybe we just need generate a new resolver for every Discriminator type?

Copy link
Author

@jhbuchanan45 jhbuchanan45 May 28, 2021

Choose a reason for hiding this comment

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

That would definitely be a much cleaner change for now.

I definitely agree on the changing the typing to allow interface TCs to resolvers as a long term solution, especially since it seems that interfaces don't cause issues when 'hacked' in as a string type currently.
My original intention with these changes was to avoid messing with existing code and types as much as possible to avoid accidentally breaking anything, though until I read through what the old DTCs did I was thinking something like this would be needed anyway.

Passing in an Interface TC directly to the resolver (and presumably letting it be passed through composeMongoose() and convertModelToGraphQL() as needed) means we should be able to cut down on code inside eDTC for dealing with the interface. Perhaps EDTC should extend InterfaceTypeComposer() instead of OTC in that case, and we could just have an Object TC as a property for creating and manipulating the custom input type. (like how we have now as eDTC.DInputObject)

Currently for top level model discriminators each sub-model being created using composeMongoose() instead of convertModelToGraphQL() so they have a set of mongoose resolvers generated. (since they have a full mongoose model already stored on the main model which can be used by the resolvers)

// foreach discrimTC
const discrimTC =
        discrimType instanceof Schema
          ? convertModelToGraphQL(
              { schema: discrimType },
              this.getTypeName() + key,
              schemaComposer,
              { ...this.opts, name: this.getTypeName() + key }
            )
          : composeMongoose(discrimType as Model<any>, { ...this.opts, name: this.getTypeName() + key });

These can then be accessed and used by calling getDisciminatorTCs() which ensures resolvers are present for better typing when importing and using it

  getDiscriminatorTCs(): {
    [key: string]: ObjectTypeComposer<any, TContext> & {
      mongooseResolvers: GenerateResolverType<any, TContext>;
    };
  } {
    // check if mongooseResolvers are present (assume on one = on all)
    if ((this.discrimTCs[Object.keys(this.discrimTCs)[0]] as any).mongooseResolvers) {
      return this.discrimTCs as any;
    } else {
      return {};
    }
  }

This is roughly how I've been using it in my test setup:

...
const composeOpts: ComposeMongooseOpts = {
  includeBaseDiscriminators: true,
  includeNestedDiscriminators: true,
};

export const PClass = mongoose.model('ClassGraph', pClass);
const ExtraClass = PClass.discriminator('extraToken', classSecondSchema);
export const ClassTC = composeMongoose(PClass, composeOpts);

const ExtraClassTC = (ClassTC as EDiscriminatorTypeComposer<any, any>).getDiscriminatorTCs()
  ?.extraToken;
export { ExtraClassTC };

in a 'routes.js' file then:

schemaComposer.Query.addFields({
  classById: ClassTC.mongooseResolvers.findById(),
  extraClassById: ExtraClassTC.mongooseResolvers.findById()
}

I'd like to get rid of the need to use the (... as EDTC) if discriminators are enabled in the options to use EDTC only props/methods, not sure if there is a way to do that in typescript. (Edit: discriminators enabled in options is not going to be a valid way to check for EDTC typing)

Copy link
Author

Choose a reason for hiding this comment

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

Maybe better to change '[' + tc.getTypeName() + '!]!' if you want to override type in future.

But I'm still unsure that use type as a string is a good idea. Maybe we just allow passing Interfaces to all resolvers:

export function findMany<TSource = any, TContext = any, TDoc extends Document = any>(
  model: Model<TDoc>,
-  tc: ObjectTypeComposer<TDoc, TContext>,
+  tc: ObjectTypeComposer<TDoc, TContext> | InterfaceTypeComposer<TDoc, TContext>,
  opts?: FindManyResolverOpts
):

Resolvers in graphql-compose === FieldConfigs` in graphql.
So we can generate as many Resolvers as we want. And maybe we just need generate a new resolver for every Discriminator type?

@nodkz Is there any progress on allowing InterfaceTCs directly in resolvers, or should I continue with this PR as is?

Copy link
Member

Choose a reason for hiding this comment

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

@jhbuchanan45 I've just updated master which allows passing InterfaceTCs to resolvers. Please take a look.

Copy link
Author

Choose a reason for hiding this comment

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

@nodkz I've pushed an updated version of the eDTC which now extends the InterfaceTypeComposer and is passed directly. It's definitely a neater solution, and it was a lot less work to allow IFTCs as well as OTCs than I had expected.

Cheers for making those changes to the resolvers, I had mistakenly assumed that it would need changes in graphql-compose.

@nodkz nodkz mentioned this pull request Jul 8, 2021
@fdelucchijr
Copy link

Is there any update in this PR? Any way the community can help | continue the work?

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.

None yet

4 participants