graphql-compose / graphql-compose-mongoose

Mongoose model converter to GraphQL types with resolvers for graphql-compose https://github.com/nodkz/graphql-compose
MIT License
708 stars 94 forks source link

Mongoose virtuals #135

Open pawelotto opened 5 years ago

pawelotto commented 5 years ago

Any hints how I can convert mongoose virtuals into Type Composer ?

nodkz commented 5 years ago

Mongoose models do not contain what exactly types have virtuals fields. So you should add virtual fields manually to your GraphQL types.

Like so:

  const RecordTC = composeWithMongoose(Record);
  RecordTC.addFields({
    virtualField123: {
      type: 'String',
      description: 'Just say thay your model has virtual with some type',
    },
    otherVirtual: 'Int',
  });

See full test-case: https://github.com/graphql-compose/graphql-compose-mongoose/blob/a351ec1fbd508b2c64a95415a38560c36b9be8ea/src/__tests__/guthub_issues/135-test.js

ryanvanderpol commented 4 years ago

@nodkz thanks for this example -- but how does this work when the virtual field is on a subdocument? eg. I have a model, Post, which has an array of authors; each Author has a virtual field called fullName. How can I expose this virtual field?

chongma commented 4 years ago

@nodkz does this work with an array of virtuals? e.g.

RecordTC.addFields({
  virtualField123: {
    type: [virtualType456],
    description: 'Just say thay your model has virtual with some array type',
  },
  otherVirtual: 'Int',
});
nodkz commented 4 years ago

@ryanvanderpol you may extend any generated type even nested. Method getFieldTC(): ObjectTypeComposer | EnumTypeComposer| ScalarTypeComposer or getFieldOTC(): ObjectTypeComposer allows to get TypeComposer for any field and extend it as you wish:

RecordTC.getFieldOTC('someEmbeddedField').addFields({
  virtual1: ...,
  virtual2: ...
});

BTW getFieldTC & getFieldOTC under the hood unwraps types if they are wrapped by List or NonNull modificators.

nodkz commented 4 years ago

@chongma yep it should work with an array for type definition for virtual fields! And your example above absolutely legit.

chongma commented 4 years ago

it isn't returning anything. do i need to populate the virtuals somehow or is that done automatically?

const schema = new mongoose.Schema({   
    description: {
        type: String
    }
})
schema.virtual('subitems', {
    ref: 'Subitem',
    localField: '_id',
    foreignField: 'item'
})
mongoose.model('Item', schema)

Schema:

const SubitemTC = composeWithMongoose(Subitem, customizationOptions);
const ItemTC = composeWithMongoose(Item, customizationOptions);
ItemTC.addFields({
    subitems: {
        type: [SubitemTC],
        description: 'Sub items with a custom type',
    }
});
nodkz commented 4 years ago

Mongoose does not return any type for virtual fields. So graphql-compose-mongoose cannot create these fields automatically.

Moreover, you are using relations with other models. They are not populated automatically. So you need to create a field in TypeComposer manually with the resolve method:

const SubitemTC = composeWithMongoose(Subitem, customizationOptions);
const ItemTC = composeWithMongoose(Item, customizationOptions);

ItemTC.addFields({
    subitems: {
        type: ItemTC.NonNull.List,
        description: 'Sub items with a custom type',
        resolve: (source) => {
           return ItemModel.find({ _id: source.item }); 
        }
    }
});
chongma commented 4 years ago

it worked, but more like this

const SubitemTC = composeWithMongoose(Subitem, customizationOptions);
const ItemTC = composeWithMongoose(Item, customizationOptions);

ItemTC.addFields({
    subitems: {
        type: SubitemTC.NonNull.List,
        description: 'Sub items with a custom type',
        resolve: (source) => {
           return Subitem.find({ item: source._id }); 
        }
    }
});

unless i misunderstood and there is a way that performs better

nodkz commented 4 years ago

@chongma Agreed, my example contains errors. So your code snippet is correct👍

akrizs commented 4 years ago

So im having some issues with my mongoose + graphql-compose-mongoose, i have a firstName and a lastName field store in my database, i want to have a virtual that gives me fullName which i have only set to return `${this.firstName} ${this.lastname}` i am getting null or undefined when trying to only get the fullName element...could anyone point me in the right direction ? :)

Best wishes...

nodkz commented 4 years ago

@antonedvard you need to use projection param in field config:

UserTC.addFields({
    fullName: {
        type: 'String',
        resolve: (source) => `${source.firstName} ${source.lastname}`,
        projection: { firstName: 1, lastname: 1 }
    }
});

Graphql-compose-mongoose will automatically populate this fields from the database when just fullName is requested in the graphql query.

akrizs commented 4 years ago

@nodkz thanks for your quick reply! I obviously didn't just hang around and waited for a reply but I realized that since we are using .select() with the graphql-compose-mongoose I had to add some extra's to make my virtuals actually be delivered even if the dependency elements where not fetched before, i leave my solution here so that others can benifit from it:

//  M("User") === mongoose.model("User")

userSchema.virtual("fullName").get(async function (this: any) {
  let data: any;
  let str: string;

  if (this.name.$isEmpty()) {
    // If the name object is empty then fetch it from the db.
    data = await M("User").findOne({ _id: this._id }).select("name");
    // Set the fullName from first and last name.
    str = `${data.name.first} ${data.name.last}`;
    return str;
  } else {
    str = `${this.name.first} ${this.name.last}`;
    return str;
  }
});

This is obviously probably not the only way to go but it seems to be working as it should, I find this a central and a deep solution and great if graphql is not the only tool used to fetch key:values from db when populating virtuals.

There is actually already a posibility for an improvement there... if for example only the first is set then the this.name.$isEmpty() will return false, the string will therefore be "firstName undefined".

EDIT: I actually checked this, it worked just fine even if only the name {} was queried with graphql... feels like perhaps the virtuals are among the first things that are populated in the object before it is returned...

Hopes this pushes others in trouble in the right direction!

lukidoescode commented 3 years ago

May I join in?

I added a virtual setter to my UserSchemalike this:

UserSchema.virtual('password').set(function (password: string) {
  const salt = bcrypt.genSaltSync(10);
  this.passwordHash = {
    hash: bcrypt.hashSync(password, salt),
    setOn: Date.now(),
  };
});

I also added the field to the TypeComposer like this:

UserTC.getITC().addFields({
  password: {
    type: 'String',
    description: 'A new Password to be set.',
  },
});

I also added updateById to mutation like this:

schemaComposer.Mutation.addFields({
  userUpdateById: UserTC.getResolver('updateById'),
});

However, GraphQL Type UpdateByIdUserInput does not contain the password field:

Screenshot 2020-11-21 at 23 44 14

Is this expected behaviour? It seems like mutation resolvers do not respect the changes done to the InputTypeComposer. How do I get virtual setters to be recognised by mutation resolvers?

nodkz commented 3 years ago

@lukidoescode, you need to migrate on v9.0.0. Before resolvers were generated on composeWithMongoose() call. But from v9, resolvers are generated on-demand via the mongooseResolvers resolver factory.

So your code may be written in such a way:

UserSchema.virtual('password').set(function (password: string) {
  const salt = bcrypt.genSaltSync(10);
  this.passwordHash = {
    hash: bcrypt.hashSync(password, salt),
    setOn: Date.now(),
  };
});

const UserModel = mongoose.model('User', UserSchema);

const UserTC = composeMongoose(UserModel); // <--- NEW METHOD which generates only TC and ITC (old method name was composeWithMongoose);

UserTC.getITC().addFields({
  password: {
    type: 'String',
    description: 'A new Password to be set.',
  },
});

schemaComposer.Mutation.addFields({
  userUpdateById: UserTC.mongooseResolvers.updateById(), // <--- NEW resolver factory, it create resolver according to modified ITC
});

More details about changes in v9 you may find in this article https://github.com/graphql-compose/graphql-compose-mongoose/blob/master/docs/releases/9.0.0.md

lukidoescode commented 3 years ago

Thank you, that helped a whole lot! I already was on v9.0.0. However, I was still following the docs at https://graphql-compose.github.io/docs/plugins/plugin-mongoose.html.

nodkz commented 3 years ago

Thank you, that helped a whole lot! I already was on v9.0.0. However, I was still following the docs at https://graphql-compose.github.io/docs/plugins/plugin-mongoose.html.

Thanks for reporting @lukidoescode. I've just updated it.

riggedCoinflip commented 3 years ago

Hi, I am having problems with virtuals if the original field is not contained in the TC.

So, what is the real world application: I want the age of a User to be Public, while keeping his DateOfBirth private. The PublicTC is missing the dateOfBirth field because of it. However, it seems like calculating the age requires the dateOfBirth field to be present in the TC.

I am not sure if it is intended and how Mongoose handles it. If this is intended, how do I get the behaviour I want? I still want the DateOfBirth to be accessible for PrivateTC queries, so using a Mongoose getter on DateOfBirth to get the Age this way is discouraged.

Reproducible script:

const {SchemaComposer} = require("graphql-compose")
const mongoose = require("mongoose");
const {composeMongoose} = require("graphql-compose-mongoose");

const schemaComposer = new SchemaComposer();

const FooSchema = new mongoose.Schema({
    name: {
        type: String
    },
    dateOfBirth: {
        type: Date
    }
})

FooSchema.virtual("age").get(function () {
    console.log(this.dateOfBirth)

    if (!this.dateOfBirth) return -1 //default
    const ageDifMs = Date.now() - this.dateOfBirth
    const ageDate = new Date(ageDifMs); // miliseconds from epoch
    return Math.abs(ageDate.getUTCFullYear() - 1970);
})

const Foo = mongoose.model("Foo", FooSchema)

const FooTCPrivate = composeMongoose(Foo)
const FooTCPublic = composeMongoose(Foo, {
    name: "FooPrivate",
    removeFields: ["dateOfBirth"]
})

const ageForTC = {
    age: {
        type: "Int",
        description: 'Uses the virtual "age" that is calculated from DateOfBirth. Returns -1 if DateOfBirth is not set.',
    }
}
FooTCPrivate.addFields(ageForTC)
FooTCPublic.addFields(ageForTC)

//create test user
new Foo({name: "bar", dateOfBirth: "1980-01-01"}).save()

schemaComposer.Query.addFields({

    fooPublic: FooTCPublic.mongooseResolvers.findOne(),
    fooPrivate: FooTCPrivate.mongooseResolvers.findOne(),
});

module.exports = schemaComposer.buildSchema();
{
  fooPrivate (filter: {name: "bar"}) 
    {name dateOfBirth age}
}

Response:
{
  "data": {
    "fooPrivate": {
      "name": "bar",
      "dateOfBirth": "1980-01-01T00:00:00.000Z",
      "age": 41
    }
  }
}

console.log:
1980-01-01T00:00:00.000Z
{
  fooPublic (filter: {name: "bar"}) 
    {name age}
}

Response:
{
  "data": {
    "fooPublic": {
      "name": "bar",
      "age": -1
    }
  }
}

console.log:
undefined
nodkz commented 3 years ago

@riggedCoinflip you need to add projection property:

const ageForTC = {
    age: {
        type: "Int",
        description: 'Uses the virtual "age" that is calculated from DateOfBirth. Returns -1 if DateOfBirth is not set.',
        projection: { dateOfBirth: 1 }
    }
}