Group schema + auto populate for mongoose

This commit is contained in:
Alexandre Bodin 2019-07-04 15:27:27 +02:00
parent 2753176d89
commit 5f29e81556
4 changed files with 578 additions and 560 deletions

View File

@ -119,7 +119,8 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
// Add every relationships to the loaded model for Bookshelf.
// Basic attributes don't need this-- only relations.
_.forEach(definition.attributes, (details, name) => {
Object.keys(definition.attributes).forEach(name => {
const details = definition.attributes[name];
if (details.type !== undefined) {
return;
}
@ -433,7 +434,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
attrs[key] =
definition.attributes[key].repeatable === true
? groups
: _.first(groups);
: _.first(groups) || null;
}
});

View File

@ -43,6 +43,7 @@ module.exports = function(strapi) {
.map(async connectionName => {
const connection = connections[connectionName];
const instance = new Mongoose();
_.defaults(connection.settings, strapi.config.hook.settings.mongoose);
const {
@ -65,9 +66,6 @@ module.exports = function(strapi) {
// Connect to mongo database
const connectOptions = {};
const options = {
useFindAndModify: false,
};
if (!_.isEmpty(username)) {
connectOptions.user = username;
@ -86,8 +84,6 @@ module.exports = function(strapi) {
connectOptions.dbName = database;
connectOptions.useCreateIndex = true;
options.debug = debug === true || debug === 'true';
try {
/* FIXME: for now, mongoose doesn't support srv auth except the way including user/pass in URI.
* https://github.com/Automattic/mongoose/issues/6881 */
@ -117,7 +113,8 @@ module.exports = function(strapi) {
require(initFunctionPath)(instance, connection);
}
Object.keys(options, key => instance.set(key, options[key]));
instance.set('debug', debug === true || debug === 'true');
instance.set('useFindAndModify', false);
const ctx = {
instance,

View File

@ -2,41 +2,107 @@
const _ = require('lodash');
const mongoose = require('mongoose');
const mongooseUtils = require('mongoose/lib/utils');
const utilsModels = require('strapi-utils').models;
const utils = require('./utils/');
const utils = require('./utils');
const relations = require('./relations');
module.exports = ({ models, target, plugin = false }, ctx) => {
const { instance } = ctx;
const loadedAttributes = _.after(_.size(models), () => {
_.forEach(models, (definition, model) => {
try {
let collection =
strapi.config.hook.settings.mongoose.collections[
mongooseUtils.toCollectionName(definition.globalName)
];
// Parse every authenticated model.
Object.keys(models).map(model => {
const definition = models[model];
definition.orm = 'mongoose';
definition.associations = [];
definition.globalName = _.upperFirst(_.camelCase(definition.globalId));
definition.loadedModel = {};
// Set the default values to model settings.
_.defaults(definition, {
primaryKey: '_id',
});
if (!plugin) {
global[definition.globalName] = {};
}
const groupAttributes = Object.keys(definition.attributes).filter(
key => definition.attributes[key].type === 'group'
);
const scalarAttributes = Object.keys(definition.attributes).filter(key => {
const { type } = definition.attributes[key];
return type !== undefined && type !== null && type !== 'group';
});
const relationalAttributes = Object.keys(definition.attributes).filter(
key => {
const { type } = definition.attributes[key];
return type === undefined;
}
);
// handle gorup attrs
if (groupAttributes.length > 0) {
// create join morph collection thingy
groupAttributes.forEach(name => {
definition.loadedModel[name] = [
{
kind: String,
ref: {
type: mongoose.Schema.Types.ObjectId,
refPath: `${name}.kind`,
},
},
];
});
}
// handle scalar attrs
scalarAttributes.forEach(name => {
const attr = definition.attributes[name];
definition.loadedModel[name] = {
...attr,
type: utils(instance).convertType(attr.type),
};
});
// handle relational attrs
relationalAttributes.forEach(name => {
buildRelation({
definition,
model,
instance,
name,
attribute: definition.attributes[name],
});
});
const schema = new instance.Schema(
_.omitBy(definition.loadedModel, ({ type }) => type === 'virtual')
);
// Initialize lifecycle callbacks.
const preLifecycle = {
validate: 'beforeCreate',
find: 'beforeFetchAll',
findOne: 'beforeFetch',
findOneAndUpdate: 'beforeUpdate',
findOneAndRemove: 'beforeDestroy',
remove: 'beforeDestroy',
update: 'beforeUpdate',
updateOne: 'beforeUpdate',
find: 'beforeFetchAll',
findOne: 'beforeFetch',
save: 'beforeSave',
};
const findLifecycles = [
'find',
'findOne',
'findOneAndUpdate',
'findOneAndRemove',
];
/*
Override populate path for polymorphic association.
It allows us to make Upload.find().populate('related')
@ -44,77 +110,24 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
*/
const morphAssociations = definition.associations.filter(
association =>
association.nature.toLowerCase().indexOf('morph') !== -1
association => association.nature.toLowerCase().indexOf('morph') !== -1
);
if (morphAssociations.length > 0) {
morphAssociations.forEach(association => {
Object.keys(preLifecycle)
.filter(key => key.indexOf('find') !== -1)
.forEach(key => {
collection.schema.pre(key, function(next) {
if (
this._mongooseOptions.populate &&
this._mongooseOptions.populate[association.alias]
) {
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[
association.alias
].match = {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
};
// Select last related to an entity.
this._mongooseOptions.populate[
association.alias
].options = {
sort: '-createdAt',
};
} else {
this._mongooseOptions.populate[
association.alias
].path = `${association.alias}.ref`;
}
} else {
if (!this._mongooseOptions.populate) {
this._mongooseOptions.populate = {};
}
// Images are not displayed in populated data.
// We automatically populate morph relations.
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[association.alias] = {
path: association.alias,
match: {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
},
options: {
sort: '-createdAt',
},
select: undefined,
model: undefined,
_docs: {},
};
}
}
next();
const populateFn = createOnFetchPopulateFn({
groupAttributes,
morphAssociations,
definition,
});
});
});
}
_.forEach(preLifecycle, (fn, key) => {
findLifecycles.forEach(key => {
schema.pre(key, populateFn);
});
Object.keys(preLifecycle).forEach(key => {
const fn = preLifecycle[key];
if (_.isFunction(target[model.toLowerCase()][fn])) {
collection.schema.pre(key, function(next) {
schema.pre(key, function(next) {
target[model.toLowerCase()]
[fn](this)
.then(next)
@ -136,9 +149,11 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
// Mongoose doesn't allow post 'remove' event on model.
// See https://github.com/Automattic/mongoose/issues/3054
_.forEach(postLifecycle, (fn, key) => {
Object.keys(postLifecycle).forEach(key => {
const fn = postLifecycle[key];
if (_.isFunction(target[model.toLowerCase()][fn])) {
collection.schema.post(key, function(doc, next) {
schema.post(key, function(doc, next) {
target[model.toLowerCase()]
[fn](this, doc)
.then(next)
@ -156,7 +171,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
return model.type === 'virtual';
}),
(value, key) => {
collection.schema.virtual(key.replace('_v', ''), {
schema.virtual(key.replace('_v', ''), {
ref: value.ref,
localField: '_id',
foreignField: value.via,
@ -175,9 +190,9 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
? _.get(definition, 'options.timestamps[1]')
: 'updatedAt',
};
collection.schema.set('timestamps', timestamps);
schema.set('timestamps', timestamps);
} else {
collection.schema.set(
schema.set(
'timestamps',
_.get(definition, 'options.timestamps') === true
);
@ -189,7 +204,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
: false
);
}
collection.schema.set(
schema.set(
'minimize',
_.get(definition, 'options.minimize', false) === true
);
@ -197,7 +212,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
// Save all attributes (with timestamps)
target[model].allAttributes = _.clone(definition.attributes);
collection.schema.options.toObject = collection.schema.options.toJSON = {
schema.options.toObject = schema.options.toJSON = {
virtuals: true,
transform: function(doc, returned) {
// Remover $numberDecimal nested property.
@ -221,21 +236,32 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
break;
case 'manyMorphToMany':
case 'manyMorphToOne':
returned[association.alias] = returned[
association.alias
].map(obj => obj.ref);
returned[association.alias] = returned[association.alias].map(
obj => obj.ref
);
break;
default:
}
}
});
groupAttributes.forEach(name => {
const attribute = definition.attributes[name];
if (Array.isArray(returned[name])) {
const groups = returned[name].map(el => el.ref);
// Reformat data by bypassing the many-to-many relationship.
returned[name] =
attribute.repeatable === true ? groups : _.first(groups) || null;
}
});
},
};
// Instantiate model.
const Model = instance.model(
definition.globalId,
collection.schema,
schema,
definition.collectionName
);
@ -249,94 +275,87 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
// Push attributes to be aware of model schema.
target[model]._attributes = definition.attributes;
target[model].updateRelations = relations.update;
} catch (err) {
strapi.log.error('Impossible to register the `' + model + '` model.');
strapi.log.error(err);
strapi.stop();
});
};
const createOnFetchPopulateFn = ({
morphAssociations,
groupAttributes,
definition,
}) => {
return function(next) {
morphAssociations.forEach(association => {
if (
this._mongooseOptions.populate &&
this._mongooseOptions.populate[association.alias]
) {
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[association.alias].match = {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
};
// Select last related to an entity.
this._mongooseOptions.populate[association.alias].options = {
sort: '-createdAt',
};
} else {
this._mongooseOptions.populate[
association.alias
].path = `${association.alias}.ref`;
}
} else {
if (!this._mongooseOptions.populate) {
this._mongooseOptions.populate = {};
}
// Images are not displayed in populated data.
// We automatically populate morph relations.
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[association.alias] = {
path: association.alias,
match: {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
},
options: {
sort: '-createdAt',
},
select: undefined,
model: undefined,
_docs: {},
};
}
}
});
groupAttributes.forEach(name => {
if (
this._mongooseOptions.populate &&
this._mongooseOptions.populate[name]
) {
this._mongooseOptions.populate[name].path = `${name}.ref`;
} else {
this._mongooseOptions.populate[name] = {
path: `${name}.ref`,
_docs: {},
};
}
});
// Parse every authenticated model.
_.forEach(models, (definition, model) => {
definition.globalName = _.upperFirst(_.camelCase(definition.globalId));
next();
};
};
// Make sure the model has a connection.
// If not, use the default connection.
if (_.isEmpty(definition.connection)) {
definition.connection =
strapi.config.currentEnvironment.database.defaultConnection;
}
// Make sure this connection exists.
if (!_.has(strapi.config.connections, definition.connection)) {
strapi.log.error(
'The connection `' +
definition.connection +
'` specified in the `' +
model +
'` model does not exist.'
);
strapi.stop();
}
// Add some informations about ORM & client connection
definition.orm = 'mongoose';
definition.client = _.get(
strapi.config.connections[definition.connection],
'client'
);
definition.associations = [];
// Register the final model for Mongoose.
definition.loadedModel = _.cloneDeep(definition.attributes);
// Initialize the global variable with the
// capitalized model name.
if (!plugin) {
global[definition.globalName] = {};
}
if (_.isEmpty(definition.attributes)) {
// Generate empty schema
_.set(
strapi.config.hook.settings.mongoose,
'collections.' +
mongooseUtils.toCollectionName(definition.globalName) +
'.schema',
new instance.Schema({})
);
return loadedAttributes();
}
// Call this callback function after we are done parsing
// all attributes for relationships-- see below.
const done = _.after(_.size(definition.attributes), () => {
// Generate schema without virtual populate
const schema = new instance.Schema(
_.omitBy(definition.loadedModel, model => {
return model.type === 'virtual';
})
);
_.set(
strapi.config.hook.settings.mongoose,
'collections.' +
mongooseUtils.toCollectionName(definition.globalName) +
'.schema',
schema
);
loadedAttributes();
});
// Add every relationships to the loaded model for Bookshelf.
// Basic attributes don't need this-- only relations.
_.forEach(definition.attributes, (details, name) => {
const buildRelation = ({ definition, model, instance, attribute, name }) => {
const verbose =
_.get(
utilsModels.getNature(details, name, undefined, model.toLowerCase()),
utilsModels.getNature(attribute, name, undefined, model.toLowerCase()),
'verbose'
) || '';
@ -344,21 +363,15 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
utilsModels.defineAssociations(
model.toLowerCase(),
definition,
details,
attribute,
name
);
if (_.isEmpty(verbose)) {
definition.loadedModel[name].type = utils(instance).convertType(
details.type
);
}
switch (verbose) {
case 'hasOne': {
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.model].globalId
: strapi.models[details.model].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.model].globalId
: strapi.models[attribute.model].globalId;
definition.loadedModel[name] = {
type: instance.Schema.Types.ObjectId,
@ -370,9 +383,9 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
const FK = _.find(definition.associations, {
alias: name,
});
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.collection].globalId
: strapi.models[details.collection].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.collection].globalId
: strapi.models[attribute.collection].globalId;
if (FK) {
definition.loadedModel[name] = {
@ -383,7 +396,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
};
// Set this info to be able to see if this field is a real database's field.
details.isVirtual = true;
attribute.isVirtual = true;
} else {
definition.loadedModel[name] = [
{
@ -398,9 +411,9 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
const FK = _.find(definition.associations, {
alias: name,
});
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.model].globalId
: strapi.models[details.model].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.model].globalId
: strapi.models[attribute.model].globalId;
if (
FK &&
@ -417,7 +430,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
};
// Set this info to be able to see if this field is a real database's field.
details.isVirtual = true;
attribute.isVirtual = true;
} else {
definition.loadedModel[name] = {
type: instance.Schema.Types.ObjectId,
@ -431,12 +444,12 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
const FK = _.find(definition.associations, {
alias: name,
});
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.collection].globalId
: strapi.models[details.collection].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.collection].globalId
: strapi.models[attribute.collection].globalId;
// One-side of the relationship has to be a virtual field to be bidirectional.
if ((FK && _.isUndefined(FK.via)) || details.dominant !== true) {
if ((FK && _.isUndefined(FK.via)) || attribute.dominant !== true) {
definition.loadedModel[name] = {
type: 'virtual',
ref,
@ -444,7 +457,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
};
// Set this info to be able to see if this field is a real database's field.
details.isVirtual = true;
attribute.isVirtual = true;
} else {
definition.loadedModel[name] = [
{
@ -459,9 +472,9 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
const FK = _.find(definition.associations, {
alias: name,
});
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.model].globalId
: strapi.models[details.model].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.model].globalId
: strapi.models[attribute.model].globalId;
definition.loadedModel[name] = {
type: 'virtual',
@ -471,16 +484,16 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
};
// Set this info to be able to see if this field is a real database's field.
details.isVirtual = true;
attribute.isVirtual = true;
break;
}
case 'morphMany': {
const FK = _.find(definition.associations, {
alias: name,
});
const ref = details.plugin
? strapi.plugins[details.plugin].models[details.collection].globalId
: strapi.models[details.collection].globalId;
const ref = attribute.plugin
? strapi.plugins[attribute.plugin].models[attribute.collection].globalId
: strapi.models[attribute.collection].globalId;
definition.loadedModel[name] = {
type: 'virtual',
@ -489,13 +502,13 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
};
// Set this info to be able to see if this field is a real database's field.
details.isVirtual = true;
attribute.isVirtual = true;
break;
}
case 'belongsToMorph': {
definition.loadedModel[name] = {
kind: String,
[details.filter]: String,
[attribute.filter]: String,
ref: {
type: instance.Schema.Types.ObjectId,
refPath: `${name}.kind`,
@ -507,7 +520,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
definition.loadedModel[name] = [
{
kind: String,
[details.filter]: String,
[attribute.filter]: String,
ref: {
type: instance.Schema.Types.ObjectId,
refPath: `${name}.kind`,
@ -519,8 +532,4 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
default:
break;
}
done();
});
});
};

View File

@ -8,8 +8,14 @@ const Mongoose = require('mongoose');
*/
module.exports = (mongoose = Mongoose) => {
mongoose.Schema.Types.Decimal = require('mongoose-float').loadType(mongoose, 2);
mongoose.Schema.Types.Float = require('mongoose-float').loadType(mongoose, 20);
mongoose.Schema.Types.Decimal = require('mongoose-float').loadType(
mongoose,
2
);
mongoose.Schema.Types.Float = require('mongoose-float').loadType(
mongoose,
20
);
/**
* Convert MongoDB ID to the stringify version as GraphQL throws an error if not.
@ -20,8 +26,7 @@ module.exports = (mongoose = Mongoose) => {
return this.toString();
};
const utils = {
convertType: mongooseType => {
const convertType = mongooseType => {
switch (mongooseType.toLowerCase()) {
case 'array':
return Array;
@ -52,16 +57,11 @@ module.exports = (mongoose = Mongoose) => {
case 'text':
return 'String';
default:
return undefined;
}
},
valueToId: value => {
if (utils.isMongoId(value)) {
return mongoose.Types.ObjectId(value);
}
};
return value;
},
isMongoId: value => {
const isMongoId = value => {
if (value instanceof mongoose.Types.ObjectId) {
return true;
}
@ -74,8 +74,19 @@ module.exports = (mongoose = Mongoose) => {
// it returns for instance true for any integer value
const hexadecimal = /^[0-9A-F]+$/i;
return hexadecimal.test(value) && value.length === 24;
},
};
return utils;
const valueToId = value => {
if (isMongoId(value)) {
return mongoose.Types.ObjectId(value);
}
return value;
};
return {
convertType,
valueToId,
isMongoId,
};
};