diff --git a/examples/getstarted/api/articles/models/Articles.settings.json b/examples/getstarted/api/articles/models/Articles.settings.json index 611c3cece3..2ebba35d66 100644 --- a/examples/getstarted/api/articles/models/Articles.settings.json +++ b/examples/getstarted/api/articles/models/Articles.settings.json @@ -28,6 +28,14 @@ "model": "file", "via": "related", "plugin": "upload" + }, + "ingredients": { + "type": "group", + "groups": ["ingredients"], + "required": true, + "repeatable": true, + "min": 2, + "max": 3 } } } diff --git a/examples/getstarted/exports/graphql/schema.graphql b/examples/getstarted/exports/graphql/schema.graphql index f6791c920b..667b98a638 100644 --- a/examples/getstarted/exports/graphql/schema.graphql +++ b/examples/getstarted/exports/graphql/schema.graphql @@ -5,6 +5,7 @@ type Articles { content2: String title: String image: UploadFile + ingredients: String! posts(sort: String, limit: Int, start: Int, where: JSON): [Post] } @@ -13,6 +14,7 @@ input ArticlesInput { posts: [ID] title: String image: ID + ingredients: String! } input createArticlesInput { @@ -89,6 +91,7 @@ input editArticlesInput { posts: [ID] title: String image: ID + ingredients: String } input editFileInput { diff --git a/examples/getstarted/groups/Ingredients.json b/examples/getstarted/groups/Ingredients.json new file mode 100644 index 0000000000..37e7ca7406 --- /dev/null +++ b/examples/getstarted/groups/Ingredients.json @@ -0,0 +1,18 @@ +{ + "collectionName": "group_ingredients", + "attributes": { + "name": { + "type": "string", + "required": true + }, + "quantity": { + "type": "float", + "required": true + }, + "picture": { + "model": "file", + "via": "related", + "plugin": "upload" + } + } +} diff --git a/packages/strapi-hook-bookshelf/lib/__tests__/create-defaults.test.js b/packages/strapi-hook-bookshelf/lib/__tests__/create-defaults.test.js new file mode 100644 index 0000000000..8c7ec847b8 --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/__tests__/create-defaults.test.js @@ -0,0 +1,34 @@ +const createDefaults = require('../create-defaults'); + +test('createDefaults', () => { + const input = { + field: { + type: 'text', + default: 'someVal', + }, + relation: { + model: 'model', + via: 'field', + default: null, // should be in the defaults + }, + groupes: { + type: 'group', + default: 'azdaz', + }, + boolField: { + type: 'boolean', + default: false, + }, + azdaz: { + type: 'invalidType', + default: 'azdaz', + }, + }; + + const expected = { + boolField: false, + field: 'someVal', + }; + + expect(createDefaults(input)).toEqual(expected); +}); diff --git a/packages/strapi-hook-bookshelf/lib/__tests__/create-table.test.js b/packages/strapi-hook-bookshelf/lib/__tests__/create-table.test.js new file mode 100644 index 0000000000..f2475b42e1 --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/__tests__/create-table.test.js @@ -0,0 +1,37 @@ +const knex = require('knex'); +const createTable = require('../create-table'); + +let con; + +describe('Create Table', () => { + beforeAll(() => { + con = knex({ + client: 'sqlite', + connection: { + filename: './test.sqlite', + }, + useNullAsDefault: true, + }); + }); + + test('That works', () => { + return createTable( + { + collectionName: 'something', + attributes: { + id: { + type: 'specificType', + specificType: 'serial primary key', + }, + title: { + type: 'string', + required: true, // not nullable + unique: true, // or [args] + default: 'hello', + }, + }, + }, + { knex: con, client: 'pg' } + ); + }); +}); diff --git a/packages/strapi-hook-bookshelf/lib/buildDatabaseSchema.js b/packages/strapi-hook-bookshelf/lib/buildDatabaseSchema.js index c9be76a8dc..bf4288206b 100644 --- a/packages/strapi-hook-bookshelf/lib/buildDatabaseSchema.js +++ b/packages/strapi-hook-bookshelf/lib/buildDatabaseSchema.js @@ -9,10 +9,17 @@ module.exports = async ({ connection, model, }) => { + const { hasTimestamps } = loadedModel; + + let [createAtCol, updatedAtCol] = ['created_at', 'updated_at']; + if (Array.isArray(hasTimestamps)) { + [createAtCol, updatedAtCol] = hasTimestamps; + } + const quote = definition.client === 'pg' ? '"' : '`'; // Equilize database tables - const handler = async (table, attributes) => { + const createOrUpdateTable = async (table, attributes) => { const tableExists = await ORM.knex.schema.hasTable(table); // Apply field type of attributes definition @@ -98,13 +105,13 @@ module.exports = async ({ }; const createTable = async table => { - const defaultAttributeDifinitions = { + const defaultAttributeDefinitions = { mysql: ['id INT AUTO_INCREMENT NOT NULL PRIMARY KEY'], pg: ['id SERIAL NOT NULL PRIMARY KEY'], sqlite3: ['id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL'], }; - let idAttributeBuilder = defaultAttributeDifinitions[definition.client]; + let idAttributeBuilder = defaultAttributeDefinitions[definition.client]; if (definition.primaryKeyType === 'uuid' && definition.client === 'pg') { idAttributeBuilder = [ @@ -132,144 +139,137 @@ module.exports = async ({ await createTable(table); await generateIndexes(table, attributes); await storeTable(table, attributes); - } else { - const columns = Object.keys(attributes); + return; + } - // Fetch existing column - const columnsExist = await Promise.all( - columns.map(attribute => ORM.knex.schema.hasColumn(table, attribute)) - ); + const columns = Object.keys(attributes); - const columnsToAdd = {}; + // Fetch existing column + const columnsExist = await Promise.all( + columns.map(attribute => ORM.knex.schema.hasColumn(table, attribute)) + ); - // Get columns to add - columnsExist.forEach((columnExist, index) => { - const attribute = attributes[columns[index]]; + const columnsToAdd = {}; - if (!columnExist) { - columnsToAdd[columns[index]] = attribute; - } + // Get columns to add + columnsExist.forEach((columnExist, index) => { + const attribute = attributes[columns[index]]; + + if (!columnExist) { + columnsToAdd[columns[index]] = attribute; + } + }); + + // Generate indexes for new attributes. + await generateIndexes(table, columnsToAdd); + + // Generate and execute query to add missing column + if (Object.keys(columnsToAdd).length > 0) { + await ORM.knex.schema.table(table, tbl => { + Object.keys(columnsToAdd).forEach(key => { + const attribute = columnsToAdd[key]; + const type = getType({ + definition, + attribute, + name: key, + tableExists, + }); + + if (type) { + const col = tbl.specificType(key, type); + if (attribute.required && definition.client !== 'sqlite3') { + col.notNullable(); + } + } + }); }); + } - // Generate indexes for new attributes. - await generateIndexes(table, columnsToAdd); + let previousAttributes; + try { + previousAttributes = JSON.parse( + (await StrapiConfigs.forge({ + key: `db_model_${table}`, + }).fetch()).toJSON().value + ); + } catch (err) { + await storeTable(table, attributes); + previousAttributes = JSON.parse( + (await StrapiConfigs.forge({ + key: `db_model_${table}`, + }).fetch()).toJSON().value + ); + } - // Generate and execute query to add missing column - if (Object.keys(columnsToAdd).length > 0) { - await ORM.knex.schema.table(table, tbl => { - Object.keys(columnsToAdd).forEach(key => { - const attribute = columnsToAdd[key]; - const type = getType({ - definition, - attribute, - name: key, - tableExists, - }); + if (JSON.stringify(previousAttributes) === JSON.stringify(attributes)) + return; - if (type) { - const col = tbl.specificType(key, type); - if (attribute.required && definition.client !== 'sqlite3') { - col.notNullable(); - } - } - }); - }); - } + if (definition.client === 'sqlite3') { + const tmpTable = `tmp_${table}`; + await createTable(tmpTable); - let previousAttributes; try { - previousAttributes = JSON.parse( - (await StrapiConfigs.forge({ - key: `db_model_${table}`, - }).fetch()).toJSON().value + const attrs = Object.keys(attributes).filter(attribute => + getType({ + definition, + attribute: attributes[attribute], + name: attribute, + }) ); + + await ORM.knex.raw(`INSERT INTO ?? (${attrs.join(', ')}) ??`, [ + tmpTable, + ORM.knex.select(attrs).from(table), + ]); } catch (err) { - await storeTable(table, attributes); - previousAttributes = JSON.parse( - (await StrapiConfigs.forge({ - key: `db_model_${table}`, - }).fetch()).toJSON().value - ); + strapi.log.error('Migration failed'); + strapi.log.error(err); + + await ORM.knex.schema.dropTableIfExists(tmpTable); + return false; } - if (JSON.stringify(previousAttributes) === JSON.stringify(attributes)) - return; + await ORM.knex.schema.dropTableIfExists(table); + await ORM.knex.schema.renameTable(tmpTable, table); - if (definition.client === 'sqlite3') { - const tmpTable = `tmp_${table}`; - await createTable(tmpTable); + await generateIndexes(table, attributes); + } else { + await ORM.knex.schema.alterTable(table, tbl => { + columns.forEach(key => { + if ( + JSON.stringify(previousAttributes[key]) === + JSON.stringify(attributes[key]) + ) + return; - try { - const attrs = Object.keys(attributes).filter(attribute => - getType({ - definition, - attribute: attributes[attribute], - name: attribute, - }) - ); - - await ORM.knex.raw(`INSERT INTO ?? (${attrs.join(', ')}) ??`, [ - tmpTable, - ORM.knex.select(attrs).from(table), - ]); - } catch (err) { - strapi.log.error('Migration failed'); - strapi.log.error(err); - - await ORM.knex.schema.dropTableIfExists(tmpTable); - return false; - } - - await ORM.knex.schema.dropTableIfExists(table); - await ORM.knex.schema.renameTable(tmpTable, table); - - await generateIndexes(table, attributes); - } else { - await ORM.knex.schema.alterTable(table, tbl => { - columns.forEach(key => { - if ( - JSON.stringify(previousAttributes[key]) === - JSON.stringify(attributes[key]) - ) - return; - - const attribute = attributes[key]; - const type = getType({ - definition, - attribute, - name: key, - tableExists, - }); - - if (type) { - let col = tbl.specificType(key, type); - if (attribute.required) { - col = col.notNullable(); - } - col.alter(); - } + const attribute = attributes[key]; + const type = getType({ + definition, + attribute, + name: key, + tableExists, }); + + if (type) { + let col = tbl.specificType(key, type); + if (attribute.required) { + col = col.notNullable(); + } + col.alter(); + } }); - } + }); await storeTable(table, attributes); } }; // Add created_at and updated_at field if timestamp option is true - if (loadedModel.hasTimestamps) { - definition.attributes[ - _.isString(loadedModel.hasTimestamps[0]) - ? loadedModel.hasTimestamps[0] - : 'created_at' - ] = { + if (hasTimestamps) { + definition.attributes[createAtCol] = { type: 'timestamp', }; - definition.attributes[ - _.isString(loadedModel.hasTimestamps[1]) - ? loadedModel.hasTimestamps[1] - : 'updated_at' - ] = { + definition.attributes[updatedAtCol] = { type: 'timestampUpdate', }; } @@ -279,7 +279,7 @@ module.exports = async ({ // Equilize tables if (connection.options && connection.options.autoMigration !== false) { - await handler(loadedModel.tableName, definition.attributes); + await createOrUpdateTable(loadedModel.tableName, definition.attributes); } // Equilize polymorphic releations @@ -304,7 +304,7 @@ module.exports = async ({ }; if (connection.options && connection.options.autoMigration !== false) { - await handler(`${loadedModel.tableName}_morph`, attributes); + await createOrUpdateTable(`${loadedModel.tableName}_morph`, attributes); } } @@ -339,22 +339,14 @@ module.exports = async ({ manyRelation ); - await handler(table, attributes); + await createOrUpdateTable(table, attributes); } } // Remove from attributes (auto handled by bookshlef and not displayed on ctb) - if (loadedModel.hasTimestamps) { - delete definition.attributes[ - _.isString(loadedModel.hasTimestamps[0]) - ? loadedModel.hasTimestamps[0] - : 'created_at' - ]; - delete definition.attributes[ - _.isString(loadedModel.hasTimestamps[1]) - ? loadedModel.hasTimestamps[1] - : 'updated_at' - ]; + if (hasTimestamps) { + delete definition.attributes[createAtCol]; + delete definition.attributes[updatedAtCol]; } }; @@ -412,6 +404,9 @@ const getType = ({ definition, attribute, name, tableExists = false }) => { case 'pg': return 'timestamp with time zone'; case 'sqlite3': + if (tableExists) { + return 'timestamp DEFAULT NULL'; + } return 'timestamp DEFAULT CURRENT_TIMESTAMP'; default: return 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'; diff --git a/packages/strapi-hook-bookshelf/lib/create-defaults.js b/packages/strapi-hook-bookshelf/lib/create-defaults.js new file mode 100644 index 0000000000..a09dd62661 --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/create-defaults.js @@ -0,0 +1,20 @@ +'use strict'; + +const { typesArray } = require('./types'); + +/** + * return a map of default values + * @param {*} attributes + */ +function createDefaults(attributes) { + return Object.keys(attributes).reduce((acc, key) => { + const { type, default: defaultVal } = attributes[key]; + if (typesArray.includes(type) && defaultVal !== undefined) { + acc[key] = defaultVal; + } + + return acc; + }, {}); +} + +module.exports = createDefaults; diff --git a/packages/strapi-hook-bookshelf/lib/create-table.js b/packages/strapi-hook-bookshelf/lib/create-table.js new file mode 100644 index 0000000000..ab57061329 --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/create-table.js @@ -0,0 +1,99 @@ +const createTable = ({ collectionName, attributes }, { knex, client }) => { + return knex.schema.createTable(collectionName, table => { + const createCol = createColumn({ table, client }); + // create every columns + Object.keys(attributes).forEach(name => { + const attribute = attributes[name]; + const { unique, default: defaultValue, required } = attribute; + + const col = createCol({ name, ...attribute }); + + // apply required + if (required === true) col.notNullable(); + // apply defaultValue + if (defaultValue !== undefined) col.defaultTo(knex.raw(defaultValue)); + // apply unique constraint + if (unique === true) table.unique(name); + }); + }); +}; + +const createColumn = ({ table, client }) => attribute => { + const { name, type } = attribute; + + switch (type) { + case 'specificType': + return table.specificType(name, attribute.specificType); + case 'uuid': + return table.uuid(name); + case 'text': + return table.text(name); + // return client === 'pg' ? 'text' : 'longtext'; + case 'json': + return table.jsonb(name); + // return client === 'pg' ? 'jsonb' : 'longtext'; + case 'enumeration': + case 'string': + case 'password': + case 'email': + // TODO: add optional length; + return table.string(name); + // return 'varchar(255)'; + case 'integer': + // TODO: support unsigned + return table.integer(name); + // return client === 'pg' ? 'integer' : 'int'; + case 'biginteger': + return table.bigInteger(name); + // return client === 'pg' ? 'bigint' : 'bigint(53)'; + case 'float': + // TODO: support precision and scale + return table.float(name); + // return client === 'pg' ? 'double precision' : 'double'; + case 'decimal': + // TODO: support precision and scale + // return 'decimal(10,2)'; + return table.decimal(name); + case 'date': + return table.date(name); + case 'time': + // TODO: support precision for mysql + return table.time(name); + case 'datetime': + // support precision + return table.datetime(name); + case 'timestamp': + // TODO: support precision for mysql and useTz + return table.timestamp(name); + // if (client === 'pg') { + // return 'timestamp with time zone'; + // } else if (client === 'sqlite3' && tableExists) { + // return 'timestamp DEFAULT NULL'; + // } + // return 'timestamp DEFAULT CURRENT_TIMESTAMP'; + case 'timestampUpdate': + switch (client) { + case 'pg': + return table.specificType(name, 'timestamp with time zone'); + case 'sqlite3': + return table.specificType( + name, + 'timestamp DEFAULT CURRENT_TIMESTAMP' + ); + default: + return table.specificType( + name, + 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' + ); + } + case 'binary': + // TODO: handle length for mysql + return table.binary(name); + case 'boolean': + return table.boolean(name); + default: + throw new Error(`Unsupported type ${type} for attrbiute ${name}`); + } +}; + +module.exports = createTable; diff --git a/packages/strapi-hook-bookshelf/lib/get-query-params.js b/packages/strapi-hook-bookshelf/lib/get-query-params.js new file mode 100644 index 0000000000..9e8ba8f48d --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/get-query-params.js @@ -0,0 +1,96 @@ +'use strict'; +const _ = require('lodash'); + +module.exports = (value, type, key) => { + const result = {}; + + switch (type) { + case '=': + result.key = `where.${key}`; + result.value = { + symbol: '=', + value, + }; + break; + case '_ne': + result.key = `where.${key}`; + result.value = { + symbol: '!=', + value, + }; + break; + case '_lt': + result.key = `where.${key}`; + result.value = { + symbol: '<', + value, + }; + break; + case '_gt': + result.key = `where.${key}`; + result.value = { + symbol: '>', + value, + }; + break; + case '_lte': + result.key = `where.${key}`; + result.value = { + symbol: '<=', + value, + }; + break; + case '_gte': + result.key = `where.${key}`; + result.value = { + symbol: '>=', + value, + }; + break; + case '_sort': + result.key = 'sort'; + result.value = { + key, + order: value.toUpperCase(), + }; + break; + case '_start': + result.key = 'start'; + result.value = parseFloat(value); + break; + case '_limit': + result.key = 'limit'; + result.value = parseFloat(value); + break; + case '_populate': + result.key = 'populate'; + result.value = value; + break; + case '_contains': + case '_containss': + result.key = `where.${key}`; + result.value = { + symbol: 'like', + value: `%${value}%`, + }; + break; + case '_in': + result.key = `where.${key}`; + result.value = { + symbol: 'IN', + value: _.castArray(value), + }; + break; + case '_nin': + result.key = `where.${key}`; + result.value = { + symbol: 'NOT IN', + value: _.castArray(value), + }; + break; + default: + return undefined; + } + + return result; +}; diff --git a/packages/strapi-hook-bookshelf/lib/index.js b/packages/strapi-hook-bookshelf/lib/index.js index a4543efff3..9a32c88bfb 100644 --- a/packages/strapi-hook-bookshelf/lib/index.js +++ b/packages/strapi-hook-bookshelf/lib/index.js @@ -6,987 +6,145 @@ // Core const path = require('path'); - +const fs = require('fs'); // Public node modules. const _ = require('lodash'); const bookshelf = require('bookshelf'); -const pluralize = require('pluralize'); - -// Strapi helpers for models. -const utilsModels = require('strapi-utils').models; // Local helpers. -const utils = require('./utils/'); const relations = require('./relations'); const buildQuery = require('./buildQuery'); -const buildDatabaseSchema = require('./buildDatabaseSchema'); - -const PIVOT_PREFIX = '_pivot_'; -const GLOBALS = {}; - -const getDatabaseName = connection => { - const dbName = _.get(connection.settings, 'database'); - const dbSchema = _.get(connection.settings, 'schema', 'public'); - switch (_.get(connection.settings, 'client')) { - case 'sqlite3': - return 'main'; - case 'pg': - return `${dbName}.${dbSchema}`; - case 'mysql': - return dbName; - default: - return dbName; - } -}; +const mountModels = require('./mount-models'); +const getQueryParams = require('./get-query-params'); +const createDefaults = require('./create-defaults'); /** * Bookshelf hook */ -module.exports = function(strapi) { - const hook = _.merge( - { - /** - * Default options - */ +/** + * Default options + */ - defaults: { - defaultConnection: 'default', - host: 'localhost', - }, - - /** - * Initialize the hook - */ - - initialize: async cb => { - const connections = _.pickBy(strapi.config.connections, { - connector: 'strapi-hook-bookshelf', - }); - - const databaseUpdates = []; - - _.forEach(connections, (connection, connectionName) => { - // Apply defaults - _.defaults( - connection.settings, - strapi.config.hook.settings.bookshelf - ); - - // Create Bookshelf instance for this connection. - const ORM = new bookshelf(strapi.connections[connectionName]); - - try { - // Require `config/functions/bookshelf.js` file to customize connection. - require(path.resolve( - strapi.config.appPath, - 'config', - 'functions', - 'bookshelf.js' - ))(ORM, connection); - } catch (err) { - // This is not an error if the file is not found. - } - - // Load plugins - if (_.get(connection, 'options.plugins', true) !== false) { - ORM.plugin('visibility'); - ORM.plugin('pagination'); - } - - const mountModels = (models, target, plugin = false) => { - // Parse every authenticated model. - _.forEach(models, (definition, model) => { - definition.globalName = _.upperFirst( - _.camelCase(definition.globalId) - ); - definition.associations = []; - - // Define local GLOBALS to expose every models in this file. - GLOBALS[definition.globalId] = {}; - - // Add some informations about ORM & client connection & tableName - definition.orm = 'bookshelf'; - definition.databaseName = getDatabaseName(connection); - definition.client = _.get(connection.settings, 'client'); - _.defaults(definition, { - primaryKey: 'id', - primaryKeyType: _.get( - definition, - 'options.idAttributeType', - 'integer' - ), - }); - - // Use default timestamp column names if value is `true` - if (_.get(definition, 'options.timestamps', false) === true) { - _.set(definition, 'options.timestamps', [ - 'created_at', - 'updated_at', - ]); - } - // Use false for values other than `Boolean` or `Array` - if ( - !_.isArray(_.get(definition, 'options.timestamps')) && - !_.isBoolean(_.get(definition, 'options.timestamps')) - ) { - _.set(definition, 'options.timestamps', false); - } - - // Register the final model for Bookshelf. - const loadedModel = _.assign( - { - tableName: definition.collectionName, - hasTimestamps: _.get(definition, 'options.timestamps', false), - idAttribute: _.get(definition, 'options.idAttribute', 'id'), - associations: [], - defaults: Object.keys(definition.attributes).reduce( - (acc, current) => { - if ( - definition.attributes[current].type && - definition.attributes[current].default - ) { - acc[current] = definition.attributes[current].default; - } - - return acc; - }, - {} - ), - }, - definition.options - ); - - if (_.isString(_.get(connection, 'options.pivot_prefix'))) { - loadedModel.toJSON = function(options = {}) { - const { shallow = false, omitPivot = false } = options; - const attributes = this.serialize(options); - - if (!shallow) { - const pivot = - this.pivot && !omitPivot && this.pivot.attributes; - - // Remove pivot attributes with prefix. - _.keys(pivot).forEach( - key => delete attributes[`${PIVOT_PREFIX}${key}`] - ); - - // Add pivot attributes without prefix. - const pivotAttributes = _.mapKeys( - pivot, - (value, key) => `${connection.options.pivot_prefix}${key}` - ); - - return Object.assign({}, attributes, pivotAttributes); - } - - return attributes; - }; - } - - // Initialize the global variable with the - // capitalized model name. - if (!plugin) { - global[definition.globalName] = {}; - } - - // Call this callback function after we are done parsing - // all attributes for relationships-- see below. - const done = _.after(_.size(definition.attributes), () => { - try { - // External function to map key that has been updated with `columnName` - const mapper = (params = {}) => { - if ( - definition.client === 'mysql' || - definition.client === 'sqlite3' - ) { - Object.keys(params).map(key => { - const attr = definition.attributes[key] || {}; - - if (attr.type === 'json') { - params[key] = JSON.stringify(params[key]); - } - }); - } - - return _.mapKeys(params, (value, key) => { - const attr = definition.attributes[key] || {}; - - return _.isPlainObject(attr) && - _.isString(attr['columnName']) - ? attr['columnName'] - : key; - }); - }; - - // Update serialize to reformat data for polymorphic associations. - loadedModel.serialize = function(options) { - const attrs = _.clone(this.attributes); - - if (options && options.shallow) { - return attrs; - } - - const relations = this.relations; - - // Extract association except polymorphic. - const associations = definition.associations.filter( - association => - association.nature.toLowerCase().indexOf('morph') === -1 - ); - // Extract polymorphic association. - const polymorphicAssociations = definition.associations.filter( - association => - association.nature.toLowerCase().indexOf('morph') !== -1 - ); - - polymorphicAssociations.map(association => { - // Retrieve relation Bookshelf object. - const relation = relations[association.alias]; - - if (relation) { - // Extract raw JSON data. - attrs[association.alias] = relation.toJSON - ? relation.toJSON(options) - : relation; - - // Retrieve opposite model. - const model = association.plugin - ? strapi.plugins[association.plugin].models[ - association.collection || association.model - ] - : strapi.models[ - association.collection || association.model - ]; - - // Reformat data by bypassing the many-to-many relationship. - switch (association.nature) { - case 'oneToManyMorph': - attrs[association.alias] = - attrs[association.alias][model.collectionName]; - break; - case 'manyToManyMorph': - attrs[association.alias] = attrs[ - association.alias - ].map(rel => rel[model.collectionName]); - break; - case 'oneMorphToOne': - attrs[association.alias] = - attrs[association.alias].related; - break; - case 'manyMorphToOne': - case 'manyMorphToMany': - attrs[association.alias] = attrs[ - association.alias - ].map(obj => obj.related); - break; - default: - } - } - }); - - associations.map(association => { - const relation = relations[association.alias]; - - if (relation) { - // Extract raw JSON data. - attrs[association.alias] = relation.toJSON - ? relation.toJSON(options) - : relation; - } - }); - - return attrs; - }; - - // Initialize lifecycle callbacks. - loadedModel.initialize = function() { - const lifecycle = { - creating: 'beforeCreate', - created: 'afterCreate', - destroying: 'beforeDestroy', - destroyed: 'afterDestroy', - updating: 'beforeUpdate', - updated: 'afterUpdate', - fetching: 'beforeFetch', - 'fetching:collection': 'beforeFetchAll', - fetched: 'afterFetch', - 'fetched:collection': 'afterFetchAll', - saving: 'beforeSave', - saved: 'afterSave', - }; - - _.forEach(lifecycle, (fn, key) => { - if (_.isFunction(target[model.toLowerCase()][fn])) { - this.on(key, target[model.toLowerCase()][fn]); - } - }); - - const findModelByAssoc = ({ assoc }) => { - return assoc.plugin - ? strapi.plugins[assoc.plugin].models[ - assoc.collection || assoc.model - ] - : strapi.models[assoc.collection || assoc.model]; - }; - - const isPolymorphic = ({ assoc }) => { - return assoc.nature.toLowerCase().indexOf('morph') !== -1; - }; - - const formatPolymorphicPopulate = ({ - assoc, - path, - prefix = '', - }) => { - if (_.isString(path) && path === assoc.via) { - return `related.${assoc.via}`; - } else if (_.isString(path) && path === assoc.alias) { - // MorphTo side. - if (assoc.related) { - return `${prefix}${assoc.alias}.related`; - } - - // oneToMorph or manyToMorph side. - // Retrieve collection name because we are using it to build our hidden model. - const model = findModelByAssoc({ assoc }); - - return { - [`${prefix}${assoc.alias}.${ - model.collectionName - }`]: function(query) { - query.orderBy('created_at', 'desc'); - }, - }; - } - }; - - // Update withRelated level to bypass many-to-many association for polymorphic relationshiips. - // Apply only during fetching. - this.on( - 'fetching fetching:collection', - (instance, attrs, options) => { - if (_.isArray(options.withRelated)) { - options.withRelated = options.withRelated - .map(path => { - const assoc = definition.associations.find( - assoc => - assoc.alias === path || assoc.via === path - ); - - if (assoc && isPolymorphic({ assoc })) { - return formatPolymorphicPopulate({ - assoc, - path, - }); - } - - let extraAssocs = []; - if (assoc) { - const assocModel = findModelByAssoc({ assoc }); - - extraAssocs = assocModel.associations - .filter(assoc => isPolymorphic({ assoc })) - .map(assoc => - formatPolymorphicPopulate({ - assoc, - path: assoc.alias, - prefix: `${path}.`, - }) - ); - } - - return [path, ...extraAssocs]; - }) - .reduce((acc, paths) => acc.concat(paths), []); - } - - return _.isFunction( - target[model.toLowerCase()]['beforeFetchAll'] - ) - ? target[model.toLowerCase()]['beforeFetchAll'] - : Promise.resolve(); - } - ); - - //eslint-disable-next-line - this.on('saving', (instance, attrs, options) => { - instance.attributes = mapper(instance.attributes); - attrs = mapper(attrs); - - return _.isFunction( - target[model.toLowerCase()]['beforeSave'] - ) - ? target[model.toLowerCase()]['beforeSave'] - : Promise.resolve(); - }); - - // Convert to JSON format stringify json for mysql database - if ( - definition.client === 'mysql' || - definition.client === 'sqlite3' - ) { - const events = [ - { - name: 'saved', - target: 'afterSave', - }, - { - name: 'fetched', - target: 'afterFetch', - }, - { - name: 'fetched:collection', - target: 'afterFetchAll', - }, - ]; - - const jsonFormatter = attributes => { - Object.keys(attributes).map(key => { - const attr = definition.attributes[key] || {}; - - if (attr.type === 'json') { - attributes[key] = JSON.parse(attributes[key]); - } - }); - }; - - events.forEach(event => { - let fn; - - if (event.name.indexOf('collection') !== -1) { - fn = instance => - instance.models.map(entry => { - jsonFormatter(entry.attributes); - }); - } else { - fn = instance => jsonFormatter(instance.attributes); - } - - this.on(event.name, instance => { - fn(instance); - - return _.isFunction( - target[model.toLowerCase()][event.target] - ) - ? target[model.toLowerCase()][event.target] - : Promise.resolve(); - }); - }); - } - }; - - loadedModel.hidden = _.keys( - _.keyBy( - _.filter(definition.attributes, (value, key) => { - if ( - value.hasOwnProperty('columnName') && - !_.isEmpty(value.columnName) && - value.columnName !== key - ) { - return true; - } - }), - 'columnName' - ) - ); - - GLOBALS[definition.globalId] = ORM.Model.extend(loadedModel); - - if (!plugin) { - // Only expose as real global variable the models which - // are not scoped in a plugin. - global[definition.globalId] = GLOBALS[definition.globalId]; - } - - // Expose ORM functions through the `strapi.models[xxx]` - // or `strapi.plugins[xxx].models[yyy]` object. - target[model] = _.assign( - GLOBALS[definition.globalId], - target[model] - ); - - // Push attributes to be aware of model schema. - target[model]._attributes = definition.attributes; - target[model].updateRelations = relations.update; - - databaseUpdates.push( - buildDatabaseSchema({ - ORM, - definition, - loadedModel, - connection, - model: target[model], - }) - ); - } catch (err) { - strapi.log.error( - `Impossible to register the '${model}' model.` - ); - strapi.log.error(err); - strapi.stop(); - } - }); - - // Add every relationships to the loaded model for Bookshelf. - // Basic attributes don't need this-- only relations. - _.forEach(definition.attributes, (details, name) => { - const verbose = - _.get( - utilsModels.getNature( - details, - name, - undefined, - model.toLowerCase() - ), - 'verbose' - ) || ''; - - // Build associations key - utilsModels.defineAssociations( - model.toLowerCase(), - definition, - details, - name - ); - - let globalId; - const globalName = details.model || details.collection || ''; - - // Exclude polymorphic association. - if (globalName !== '*') { - globalId = details.plugin - ? _.get( - strapi.plugins, - `${ - details.plugin - }.models.${globalName.toLowerCase()}.globalId` - ) - : _.get( - strapi.models, - `${globalName.toLowerCase()}.globalId` - ); - } - - switch (verbose) { - case 'hasOne': { - const FK = details.plugin - ? _.findKey( - strapi.plugins[details.plugin].models[details.model] - .attributes, - details => { - if ( - details.hasOwnProperty('model') && - details.model === model && - details.hasOwnProperty('via') && - details.via === name - ) { - return details; - } - } - ) - : _.findKey( - strapi.models[details.model].attributes, - details => { - if ( - details.hasOwnProperty('model') && - details.model === model && - details.hasOwnProperty('via') && - details.via === name - ) { - return details; - } - } - ); - - const columnName = details.plugin - ? _.get( - strapi.plugins, - `${details.plugin}.models.${ - details.model - }.attributes.${FK}.columnName`, - FK - ) - : _.get( - strapi.models, - `${details.model}.attributes.${FK}.columnName`, - FK - ); - - loadedModel[name] = function() { - return this.hasOne(GLOBALS[globalId], columnName); - }; - break; - } - case 'hasMany': { - const columnName = details.plugin - ? _.get( - strapi.plugins, - `${ - details.plugin - }.models.${globalId.toLowerCase()}.attributes.${ - details.via - }.columnName`, - details.via - ) - : _.get( - strapi.models[globalId.toLowerCase()].attributes, - `${details.via}.columnName`, - details.via - ); - - // Set this info to be able to see if this field is a real database's field. - details.isVirtual = true; - - loadedModel[name] = function() { - return this.hasMany(GLOBALS[globalId], columnName); - }; - break; - } - case 'belongsTo': { - loadedModel[name] = function() { - return this.belongsTo( - GLOBALS[globalId], - _.get(details, 'columnName', name) - ); - }; - break; - } - case 'belongsToMany': { - const collection = details.plugin - ? strapi.plugins[details.plugin].models[ - details.collection - ] - : strapi.models[details.collection]; - - const collectionName = - _.get(details, 'collectionName') || - utilsModels.getCollectionName( - collection.attributes[details.via], - details - ); - - const relationship = collection.attributes[details.via]; - - // Force singular foreign key - relationship.attribute = pluralize.singular( - relationship.collection - ); - details.attribute = pluralize.singular(details.collection); - - // Define PK column - details.column = utils.getPK(model, strapi.models); - relationship.column = utils.getPK( - details.collection, - strapi.models - ); - - // Sometimes the many-to-many relationships - // is on the same keys on the same models (ex: `friends` key in model `User`) - if ( - `${details.attribute}_${details.column}` === - `${relationship.attribute}_${relationship.column}` - ) { - relationship.attribute = pluralize.singular(details.via); - } - - // Set this info to be able to see if this field is a real database's field. - details.isVirtual = true; - - loadedModel[name] = function() { - if ( - _.isArray(_.get(details, 'withPivot')) && - !_.isEmpty(details.withPivot) - ) { - return this.belongsToMany( - GLOBALS[globalId], - collectionName, - `${relationship.attribute}_${relationship.column}`, - `${details.attribute}_${details.column}` - ).withPivot(details.withPivot); - } - - return this.belongsToMany( - GLOBALS[globalId], - collectionName, - `${relationship.attribute}_${relationship.column}`, - `${details.attribute}_${details.column}` - ); - }; - break; - } - case 'morphOne': { - const model = details.plugin - ? strapi.plugins[details.plugin].models[details.model] - : strapi.models[details.model]; - - const globalId = `${model.collectionName}_morph`; - - loadedModel[name] = function() { - return this.morphOne( - GLOBALS[globalId], - details.via, - `${definition.collectionName}` - ).query(qb => { - qb.where( - _.get( - model, - `attributes.${details.via}.filter`, - 'field' - ), - name - ); - }); - }; - break; - } - case 'morphMany': { - const collection = details.plugin - ? strapi.plugins[details.plugin].models[ - details.collection - ] - : strapi.models[details.collection]; - - const globalId = `${collection.collectionName}_morph`; - - loadedModel[name] = function() { - return this.morphMany( - GLOBALS[globalId], - details.via, - `${definition.collectionName}` - ).query(qb => { - qb.where( - _.get( - collection, - `attributes.${details.via}.filter`, - 'field' - ), - name - ); - }); - }; - break; - } - case 'belongsToMorph': - case 'belongsToManyMorph': { - const association = definition.associations.find( - association => association.alias === name - ); - - const morphValues = association.related.map(id => { - let models = Object.values(strapi.models).filter( - model => model.globalId === id - ); - - if (models.length === 0) { - models = Object.keys(strapi.plugins).reduce( - (acc, current) => { - const models = Object.values( - strapi.plugins[current].models - ).filter(model => model.globalId === id); - - if (acc.length === 0 && models.length > 0) { - acc = models; - } - - return acc; - }, - [] - ); - } - - if (models.length === 0) { - strapi.log.error( - `Impossible to register the '${model}' model.` - ); - strapi.log.error( - 'The collection name cannot be found for the morphTo method.' - ); - strapi.stop(); - } - - return models[0].collectionName; - }); - - // Define new model. - const options = { - tableName: `${definition.collectionName}_morph`, - [definition.collectionName]: function() { - return this.belongsTo( - GLOBALS[definition.globalId], - `${definition.collectionName}_id` - ); - }, - related: function() { - return this.morphTo( - name, - ...association.related.map((id, index) => [ - GLOBALS[id], - morphValues[index], - ]) - ); - }, - }; - - GLOBALS[options.tableName] = ORM.Model.extend(options); - - // Set polymorphic table name to the main model. - target[model].morph = GLOBALS[options.tableName]; - - // Hack Bookshelf to create a many-to-many polymorphic association. - // Upload has many Upload_morph that morph to different model. - loadedModel[name] = function() { - if (verbose === 'belongsToMorph') { - return this.hasOne( - GLOBALS[options.tableName], - `${definition.collectionName}_id` - ); - } - - return this.hasMany( - GLOBALS[options.tableName], - `${definition.collectionName}_id` - ); - }; - break; - } - default: { - break; - } - } - done(); - }); - - if (_.isEmpty(definition.attributes)) { - done(); - } - }); - }; - - // Mount `./api` models. - mountModels( - _.pickBy(strapi.models, { connection: connectionName }), - strapi.models - ); - - // Mount `./admin` models. - mountModels( - _.pickBy(strapi.admin.models, { connection: connectionName }), - strapi.admin.models - ); - - // Mount `./plugins` models. - _.forEach(strapi.plugins, (plugin, name) => { - mountModels( - _.pickBy(strapi.plugins[name].models, { - connection: connectionName, - }), - plugin.models, - name - ); - }); - }); - - return Promise.all(databaseUpdates).then(() => cb(), cb); - }, - - getQueryParams: (value, type, key) => { - const result = {}; - - switch (type) { - case '=': - result.key = `where.${key}`; - result.value = { - symbol: '=', - value, - }; - break; - case '_ne': - result.key = `where.${key}`; - result.value = { - symbol: '!=', - value, - }; - break; - case '_lt': - result.key = `where.${key}`; - result.value = { - symbol: '<', - value, - }; - break; - case '_gt': - result.key = `where.${key}`; - result.value = { - symbol: '>', - value, - }; - break; - case '_lte': - result.key = `where.${key}`; - result.value = { - symbol: '<=', - value, - }; - break; - case '_gte': - result.key = `where.${key}`; - result.value = { - symbol: '>=', - value, - }; - break; - case '_sort': - result.key = 'sort'; - result.value = { - key, - order: value.toUpperCase(), - }; - break; - case '_start': - result.key = 'start'; - result.value = parseFloat(value); - break; - case '_limit': - result.key = 'limit'; - result.value = parseFloat(value); - break; - case '_populate': - result.key = 'populate'; - result.value = value; - break; - case '_contains': - case '_containss': - result.key = `where.${key}`; - result.value = { - symbol: 'like', - value: `%${value}%`, - }; - break; - case '_in': - result.key = `where.${key}`; - result.value = { - symbol: 'IN', - value: _.castArray(value), - }; - break; - case '_nin': - result.key = `where.${key}`; - result.value = { - symbol: 'NOT IN', - value: _.castArray(value), - }; - break; - default: - return undefined; - } - - return result; - }, - buildQuery, - }, - relations - ); - - return hook; +const defaults = { + defaultConnection: 'default', + host: 'localhost', +}; + +module.exports = function(strapi) { + function initialize(cb) { + const GLOBALS = {}; + + const connections = _.pickBy( + strapi.config.connections, + ({ connector }) => connector === 'strapi-hook-bookshelf' + ); + + const connectionsPromises = Object.keys(connections).map(connectionName => { + const connection = connections[connectionName]; + _.defaults(connection.settings, strapi.config.hook.settings.bookshelf); + + // Create Bookshelf instance for this connection. + const ORM = new bookshelf(strapi.connections[connectionName]); + + const initFunctionPath = path.resolve( + strapi.config.appPath, + 'config', + 'functions', + 'bookshelf.js' + ); + + if (fs.existsSync(initFunctionPath)) { + // Require `config/functions/bookshelf.js` file to customize connection. + require(initFunctionPath)(ORM, connection); + } + + // Load plugins + if (_.get(connection, 'options.plugins', true) !== false) { + ORM.plugin('visibility'); + ORM.plugin('pagination'); + } + + const ctx = { + GLOBALS, + connection, + ORM, + }; + + return Promise.all([ + mountGroups(connectionName, ctx), + mountApis(connectionName, ctx), + mountAdmin(connectionName, ctx), + mountPlugins(connectionName, ctx), + ]); + }); + + return Promise.all(connectionsPromises).then(() => cb(), err => cb(err)); + } + + function mountGroups(connectionName, ctx) { + const options = { + models: _.pickBy( + strapi.groups, + ({ connection }) => connection === connectionName + ), + target: strapi.groups, + plugin: false, + }; + + return mountModels(options, ctx); + } + + function mountApis(connectionName, ctx) { + const options = { + models: _.pickBy( + strapi.models, + ({ connection }) => connection === connectionName + ), + target: strapi.models, + plugin: false, + }; + + return mountModels(options, ctx); + } + + function mountAdmin(connectionName, ctx) { + const options = { + models: _.pickBy( + strapi.admin.models, + ({ connection }) => connection === connectionName + ), + target: strapi.admin.models, + plugin: false, + }; + + return mountModels(options, ctx); + } + + function mountPlugins(connectionName, ctx) { + return Promise.all( + Object.keys(strapi.plugins).map(name => { + const plugin = strapi.plugins[name]; + return mountModels( + { + models: _.pickBy( + plugin.models, + ({ connection }) => connection === connectionName + ), + target: plugin.models, + plugin: name, + }, + ctx + ); + }) + ); + } + + return { + defaults, + initialize, + getQueryParams, + buildQuery, + ...relations, + }; }; diff --git a/packages/strapi-hook-bookshelf/lib/mount-models.js b/packages/strapi-hook-bookshelf/lib/mount-models.js new file mode 100644 index 0000000000..6579bdaa48 --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/mount-models.js @@ -0,0 +1,736 @@ +'use strict'; +const _ = require('lodash'); +const pluralize = require('pluralize'); + +const utilsModels = require('strapi-utils').models; +const utils = require('./utils/'); +const relations = require('./relations'); +const buildDatabaseSchema = require('./buildDatabaseSchema'); + +const PIVOT_PREFIX = '_pivot_'; + +const getDatabaseName = connection => { + const dbName = _.get(connection.settings, 'database'); + const dbSchema = _.get(connection.settings, 'schema', 'public'); + switch (_.get(connection.settings, 'client')) { + case 'sqlite3': + return 'main'; + case 'pg': + return `${dbName}.${dbSchema}`; + case 'mysql': + return dbName; + default: + return dbName; + } +}; + +module.exports = ({ models, target, plugin = false }, ctx) => { + const { GLOBALS, connection, ORM } = ctx; + + // Parse every authenticated model. + const updates = Object.keys(models).map(async model => { + const definition = models[model]; + definition.globalName = _.upperFirst(_.camelCase(definition.globalId)); + definition.associations = []; + + // Define local GLOBALS to expose every models in this file. + GLOBALS[definition.globalId] = {}; + + // Add some informations about ORM & client connection & tableName + definition.orm = 'bookshelf'; + definition.databaseName = getDatabaseName(connection); + definition.client = _.get(connection.settings, 'client'); + _.defaults(definition, { + primaryKey: 'id', + primaryKeyType: _.get(definition, 'options.idAttributeType', 'integer'), + }); + + // Use default timestamp column names if value is `true` + if (_.get(definition, 'options.timestamps', false) === true) { + _.set(definition, 'options.timestamps', ['created_at', 'updated_at']); + } + // Use false for values other than `Boolean` or `Array` + if ( + !_.isArray(_.get(definition, 'options.timestamps')) && + !_.isBoolean(_.get(definition, 'options.timestamps')) + ) { + _.set(definition, 'options.timestamps', false); + } + + // Register the final model for Bookshelf. + const loadedModel = _.assign( + { + tableName: definition.collectionName, + hasTimestamps: _.get(definition, 'options.timestamps', false), + idAttribute: _.get(definition, 'options.idAttribute', 'id'), + associations: [], + defaults: Object.keys(definition.attributes).reduce((acc, current) => { + if ( + definition.attributes[current].type && + definition.attributes[current].default + ) { + acc[current] = definition.attributes[current].default; + } + + return acc; + }, {}), + }, + definition.options + ); + + if (_.isString(_.get(connection, 'options.pivot_prefix'))) { + loadedModel.toJSON = function(options = {}) { + const { shallow = false, omitPivot = false } = options; + const attributes = this.serialize(options); + + if (!shallow) { + const pivot = this.pivot && !omitPivot && this.pivot.attributes; + + // Remove pivot attributes with prefix. + _.keys(pivot).forEach( + key => delete attributes[`${PIVOT_PREFIX}${key}`] + ); + + // Add pivot attributes without prefix. + const pivotAttributes = _.mapKeys( + pivot, + (value, key) => `${connection.options.pivot_prefix}${key}` + ); + + return Object.assign({}, attributes, pivotAttributes); + } + + return attributes; + }; + } + + // Initialize the global variable with the + // capitalized model name. + if (!plugin) { + global[definition.globalName] = {}; + } + + const hasGroups = + Object.values(definition.attributes).filter(({ type } = {}) => { + return type && type === 'group'; + }).length > 0; + + if (hasGroups) { + const groupAttributes = Object.keys(definition.attributes).filter( + key => definition.attributes[key].type === 'group' + ); + + // create group model + const joinTable = `${definition.collectionName}_groups`; + const joinModel = ORM.Model.extend({ + tableName: joinTable, + slice() { + return this.morphTo( + 'slice', + ...groupAttributes.map(key => GLOBALS[strapi.groups[key].globalId]) + ); + }, + }); + + groupAttributes.forEach(name => { + loadedModel[name] = function() { + return this.hasMany(joinModel).query(qb => { + qb.where('field', name).orderBy('order'); + }); + }; + }); + + await ORM.knex.schema.createTableIfNotExists(joinTable, table => { + table.increments(); + table.string('field'); + table.integer('order').unsigned(); + table.string('slice_type'); + table.integer('slice_id').unsigned(); + table.integer(`${pluralize.singular(model)}_id`).unsigned(); + table.timestamps(null, true); + }); + } + + // Add every relationships to the loaded model for Bookshelf. + // Basic attributes don't need this-- only relations. + _.forEach(definition.attributes, (details, name) => { + if (details.type !== undefined) { + return; + } + + const verbose = + _.get( + utilsModels.getNature(details, name, undefined, model.toLowerCase()), + 'verbose' + ) || ''; + + // Build associations key + utilsModels.defineAssociations( + model.toLowerCase(), + definition, + details, + name + ); + + let globalId; + const globalName = details.model || details.collection || ''; + + // Exclude polymorphic association. + if (globalName !== '*') { + globalId = details.plugin + ? _.get( + strapi.plugins, + `${details.plugin}.models.${globalName.toLowerCase()}.globalId` + ) + : _.get(strapi.models, `${globalName.toLowerCase()}.globalId`); + } + + switch (verbose) { + case 'hasOne': { + const target = details.plugin + ? strapi.plugins[details.plugin].models[details.model] + : strapi.models[details.model]; + + const FK = _.findKey(target.attributes, details => { + if ( + details.hasOwnProperty('model') && + details.model === model && + details.hasOwnProperty('via') && + details.via === name + ) { + return details; + } + }); + + const columnName = _.get(target.attributes, [FK, 'columnName'], FK); + + loadedModel[name] = function() { + return this.hasOne(GLOBALS[globalId], columnName); + }; + break; + } + case 'hasMany': { + const columnName = details.plugin + ? _.get( + strapi.plugins, + `${ + details.plugin + }.models.${globalId.toLowerCase()}.attributes.${ + details.via + }.columnName`, + details.via + ) + : _.get( + strapi.models[globalId.toLowerCase()].attributes, + `${details.via}.columnName`, + details.via + ); + + // Set this info to be able to see if this field is a real database's field. + details.isVirtual = true; + + loadedModel[name] = function() { + return this.hasMany(GLOBALS[globalId], columnName); + }; + break; + } + case 'belongsTo': { + loadedModel[name] = function() { + return this.belongsTo( + GLOBALS[globalId], + _.get(details, 'columnName', name) + ); + }; + break; + } + case 'belongsToMany': { + const collection = details.plugin + ? strapi.plugins[details.plugin].models[details.collection] + : strapi.models[details.collection]; + + const collectionName = + _.get(details, 'collectionName') || + utilsModels.getCollectionName( + collection.attributes[details.via], + details + ); + + const relationship = collection.attributes[details.via]; + + // Force singular foreign key + relationship.attribute = pluralize.singular(relationship.collection); + details.attribute = pluralize.singular(details.collection); + + // Define PK column + details.column = utils.getPK(model, strapi.models); + relationship.column = utils.getPK(details.collection, strapi.models); + + // Sometimes the many-to-many relationships + // is on the same keys on the same models (ex: `friends` key in model `User`) + if ( + `${details.attribute}_${details.column}` === + `${relationship.attribute}_${relationship.column}` + ) { + relationship.attribute = pluralize.singular(details.via); + } + + // Set this info to be able to see if this field is a real database's field. + details.isVirtual = true; + + loadedModel[name] = function() { + if ( + _.isArray(_.get(details, 'withPivot')) && + !_.isEmpty(details.withPivot) + ) { + return this.belongsToMany( + GLOBALS[globalId], + collectionName, + `${relationship.attribute}_${relationship.column}`, + `${details.attribute}_${details.column}` + ).withPivot(details.withPivot); + } + + return this.belongsToMany( + GLOBALS[globalId], + collectionName, + `${relationship.attribute}_${relationship.column}`, + `${details.attribute}_${details.column}` + ); + }; + break; + } + case 'morphOne': { + const model = details.plugin + ? strapi.plugins[details.plugin].models[details.model] + : strapi.models[details.model]; + + const globalId = `${model.collectionName}_morph`; + + loadedModel[name] = function() { + return this.morphOne( + GLOBALS[globalId], + details.via, + `${definition.collectionName}` + ).query(qb => { + qb.where( + _.get(model, `attributes.${details.via}.filter`, 'field'), + name + ); + }); + }; + break; + } + case 'morphMany': { + const collection = details.plugin + ? strapi.plugins[details.plugin].models[details.collection] + : strapi.models[details.collection]; + + const globalId = `${collection.collectionName}_morph`; + + loadedModel[name] = function() { + return this.morphMany( + GLOBALS[globalId], + details.via, + `${definition.collectionName}` + ).query(qb => { + qb.where( + _.get(collection, `attributes.${details.via}.filter`, 'field'), + name + ); + }); + }; + break; + } + case 'belongsToMorph': + case 'belongsToManyMorph': { + const association = definition.associations.find( + association => association.alias === name + ); + + const morphValues = association.related.map(id => { + let models = Object.values(strapi.models).filter( + model => model.globalId === id + ); + + if (models.length === 0) { + models = Object.keys(strapi.plugins).reduce((acc, current) => { + const models = Object.values( + strapi.plugins[current].models + ).filter(model => model.globalId === id); + + if (acc.length === 0 && models.length > 0) { + acc = models; + } + + return acc; + }, []); + } + + if (models.length === 0) { + strapi.log.error(`Impossible to register the '${model}' model.`); + strapi.log.error( + 'The collection name cannot be found for the morphTo method.' + ); + strapi.stop(); + } + + return models[0].collectionName; + }); + + // Define new model. + const options = { + tableName: `${definition.collectionName}_morph`, + [definition.collectionName]: function() { + return this.belongsTo( + GLOBALS[definition.globalId], + `${definition.collectionName}_id` + ); + }, + related: function() { + return this.morphTo( + name, + ...association.related.map((id, index) => [ + GLOBALS[id], + morphValues[index], + ]) + ); + }, + }; + + GLOBALS[options.tableName] = ORM.Model.extend(options); + + // Set polymorphic table name to the main model. + target[model].morph = GLOBALS[options.tableName]; + + // Hack Bookshelf to create a many-to-many polymorphic association. + // Upload has many Upload_morph that morph to different model. + loadedModel[name] = function() { + if (verbose === 'belongsToMorph') { + return this.hasOne( + GLOBALS[options.tableName], + `${definition.collectionName}_id` + ); + } + + return this.hasMany( + GLOBALS[options.tableName], + `${definition.collectionName}_id` + ); + }; + break; + } + default: { + break; + } + } + }); + + // Call this callback function after we are done parsing + // all attributes for relationships-- see below. + + try { + // External function to map key that has been updated with `columnName` + const mapper = (params = {}) => { + if (definition.client === 'mysql' || definition.client === 'sqlite3') { + Object.keys(params).map(key => { + const attr = definition.attributes[key] || {}; + + if (attr.type === 'json') { + params[key] = JSON.stringify(params[key]); + } + }); + } + + return _.mapKeys(params, (value, key) => { + const attr = definition.attributes[key] || {}; + + return _.isPlainObject(attr) && _.isString(attr['columnName']) + ? attr['columnName'] + : key; + }); + }; + + // Update serialize to reformat data for polymorphic associations. + loadedModel.serialize = function(options) { + const attrs = _.clone(this.attributes); + + if (options && options.shallow) { + return attrs; + } + + const relations = this.relations; + + // Extract association except polymorphic. + const associations = definition.associations.filter( + association => + association.nature.toLowerCase().indexOf('morph') === -1 + ); + // Extract polymorphic association. + const polymorphicAssociations = definition.associations.filter( + association => + association.nature.toLowerCase().indexOf('morph') !== -1 + ); + + polymorphicAssociations.map(association => { + // Retrieve relation Bookshelf object. + const relation = relations[association.alias]; + + if (relation) { + // Extract raw JSON data. + attrs[association.alias] = relation.toJSON + ? relation.toJSON(options) + : relation; + + // Retrieve opposite model. + const model = association.plugin + ? strapi.plugins[association.plugin].models[ + association.collection || association.model + ] + : strapi.models[association.collection || association.model]; + + // Reformat data by bypassing the many-to-many relationship. + switch (association.nature) { + case 'oneToManyMorph': + attrs[association.alias] = + attrs[association.alias][model.collectionName]; + break; + case 'manyToManyMorph': + attrs[association.alias] = attrs[association.alias].map( + rel => rel[model.collectionName] + ); + break; + case 'oneMorphToOne': + attrs[association.alias] = attrs[association.alias].related; + break; + case 'manyMorphToOne': + case 'manyMorphToMany': + attrs[association.alias] = attrs[association.alias].map( + obj => obj.related + ); + break; + default: + } + } + }); + + associations.map(association => { + const relation = relations[association.alias]; + + if (relation) { + // Extract raw JSON data. + attrs[association.alias] = relation.toJSON + ? relation.toJSON(options) + : relation; + } + }); + + return attrs; + }; + + // Initialize lifecycle callbacks. + loadedModel.initialize = function() { + const lifecycle = { + creating: 'beforeCreate', + created: 'afterCreate', + destroying: 'beforeDestroy', + destroyed: 'afterDestroy', + updating: 'beforeUpdate', + updated: 'afterUpdate', + fetching: 'beforeFetch', + 'fetching:collection': 'beforeFetchAll', + fetched: 'afterFetch', + 'fetched:collection': 'afterFetchAll', + saving: 'beforeSave', + saved: 'afterSave', + }; + + _.forEach(lifecycle, (fn, key) => { + if (_.isFunction(target[model.toLowerCase()][fn])) { + this.on(key, target[model.toLowerCase()][fn]); + } + }); + + const findModelByAssoc = ({ assoc }) => { + return assoc.plugin + ? strapi.plugins[assoc.plugin].models[ + assoc.collection || assoc.model + ] + : strapi.models[assoc.collection || assoc.model]; + }; + + const isPolymorphic = ({ assoc }) => { + return assoc.nature.toLowerCase().indexOf('morph') !== -1; + }; + + const formatPolymorphicPopulate = ({ assoc, path, prefix = '' }) => { + if (_.isString(path) && path === assoc.via) { + return `related.${assoc.via}`; + } else if (_.isString(path) && path === assoc.alias) { + // MorphTo side. + if (assoc.related) { + return `${prefix}${assoc.alias}.related`; + } + + // oneToMorph or manyToMorph side. + // Retrieve collection name because we are using it to build our hidden model. + const model = findModelByAssoc({ assoc }); + + return { + [`${prefix}${assoc.alias}.${model.collectionName}`]: function( + query + ) { + query.orderBy('created_at', 'desc'); + }, + }; + } + }; + + // Update withRelated level to bypass many-to-many association for polymorphic relationshiips. + // Apply only during fetching. + this.on('fetching fetching:collection', (instance, attrs, options) => { + if (_.isArray(options.withRelated)) { + options.withRelated = options.withRelated + .map(path => { + const assoc = definition.associations.find( + assoc => assoc.alias === path || assoc.via === path + ); + + if (assoc && isPolymorphic({ assoc })) { + return formatPolymorphicPopulate({ + assoc, + path, + }); + } + + let extraAssocs = []; + if (assoc) { + const assocModel = findModelByAssoc({ assoc }); + + extraAssocs = assocModel.associations + .filter(assoc => isPolymorphic({ assoc })) + .map(assoc => + formatPolymorphicPopulate({ + assoc, + path: assoc.alias, + prefix: `${path}.`, + }) + ); + } + + return [path, ...extraAssocs]; + }) + .reduce((acc, paths) => acc.concat(paths), []); + } + + return _.isFunction(target[model.toLowerCase()]['beforeFetchAll']) + ? target[model.toLowerCase()]['beforeFetchAll'] + : Promise.resolve(); + }); + + //eslint-disable-next-line + this.on('saving', (instance, attrs, options) => { + instance.attributes = mapper(instance.attributes); + attrs = mapper(attrs); + + return _.isFunction(target[model.toLowerCase()]['beforeSave']) + ? target[model.toLowerCase()]['beforeSave'] + : Promise.resolve(); + }); + + // Convert to JSON format stringify json for mysql database + if (definition.client === 'mysql' || definition.client === 'sqlite3') { + const events = [ + { + name: 'saved', + target: 'afterSave', + }, + { + name: 'fetched', + target: 'afterFetch', + }, + { + name: 'fetched:collection', + target: 'afterFetchAll', + }, + ]; + + const jsonFormatter = attributes => { + Object.keys(attributes).map(key => { + const attr = definition.attributes[key] || {}; + + if (attr.type === 'json') { + attributes[key] = JSON.parse(attributes[key]); + } + }); + }; + + events.forEach(event => { + let fn; + + if (event.name.indexOf('collection') !== -1) { + fn = instance => + instance.models.map(entry => { + jsonFormatter(entry.attributes); + }); + } else { + fn = instance => jsonFormatter(instance.attributes); + } + + this.on(event.name, instance => { + fn(instance); + + return _.isFunction(target[model.toLowerCase()][event.target]) + ? target[model.toLowerCase()][event.target] + : Promise.resolve(); + }); + }); + } + }; + + loadedModel.hidden = _.keys( + _.keyBy( + _.filter(definition.attributes, (value, key) => { + if ( + value.hasOwnProperty('columnName') && + !_.isEmpty(value.columnName) && + value.columnName !== key + ) { + return true; + } + }), + 'columnName' + ) + ); + + GLOBALS[definition.globalId] = ORM.Model.extend(loadedModel); + + if (!plugin) { + // Only expose as real global variable the models which + // are not scoped in a plugin. + global[definition.globalId] = GLOBALS[definition.globalId]; + } + + // Expose ORM functions through the `strapi.models[xxx]` + // or `strapi.plugins[xxx].models[yyy]` object. + target[model] = _.assign(GLOBALS[definition.globalId], target[model]); + + // Push attributes to be aware of model schema. + target[model]._attributes = definition.attributes; + target[model].updateRelations = relations.update; + + return buildDatabaseSchema({ + ORM, + definition, + loadedModel, + connection, + model: target[model], + }); + } catch (err) { + strapi.log.error(`Impossible to register the '${model}' model.`); + strapi.log.error(err); + strapi.stop(); + } + }); + + return Promise.all(updates); +}; diff --git a/packages/strapi-hook-bookshelf/lib/types.js b/packages/strapi-hook-bookshelf/lib/types.js new file mode 100644 index 0000000000..a5491c766a --- /dev/null +++ b/packages/strapi-hook-bookshelf/lib/types.js @@ -0,0 +1,25 @@ +const types = { + UUID: 'uuid', + TEXT: 'text', + JSON: 'json', + STRING: 'string', + ENUM: 'enumeration', + PASSWORD: 'password', + EMAIL: 'email', + INTEGER: 'integer', + BIGINTEGER: 'biginteger', + FLOAT: 'float', + DECIMAL: 'decimal', + DATE: 'date', + TIME: 'time', + DATETIME: 'datetime', + TIMESTAMP: 'timestamp', + BOOLEAN: 'boolean', +}; + +const typesArray = Object.values(types); + +module.exports = { + types, + typesArray, +}; diff --git a/packages/strapi-hook-bookshelf/package.json b/packages/strapi-hook-bookshelf/package.json index 15f823db85..4e1fe3cf0e 100644 --- a/packages/strapi-hook-bookshelf/package.json +++ b/packages/strapi-hook-bookshelf/package.json @@ -30,7 +30,7 @@ ] }, "scripts": { - "test": "echo \"no tests yet\"" + "test": "jest" }, "author": { "email": "hi@strapi.io", diff --git a/packages/strapi-plugin-content-type-builder/services/ContentTypeBuilder.js b/packages/strapi-plugin-content-type-builder/services/ContentTypeBuilder.js index ce1bc99bd6..ccab59f796 100644 --- a/packages/strapi-plugin-content-type-builder/services/ContentTypeBuilder.js +++ b/packages/strapi-plugin-content-type-builder/services/ContentTypeBuilder.js @@ -507,6 +507,10 @@ module.exports = { return [trimmedNotConfigurableAttributes, errors]; }, + getConnections() { + return _.keys(strapi.config.currentEnvironment.database.connections); + }, + clearRelations(model, source, force) { const errors = []; diff --git a/packages/strapi/lib/Strapi.js b/packages/strapi/lib/Strapi.js index ccea6976d0..dc701f9d24 100644 --- a/packages/strapi/lib/Strapi.js +++ b/packages/strapi/lib/Strapi.js @@ -19,6 +19,7 @@ const { bootstrap, loadExtensions, initCoreStore, + loadGroups, } = require('./core'); const initializeMiddlewares = require('./middlewares'); const initializeHooks = require('./hooks'); @@ -100,8 +101,6 @@ class Strapi extends EventEmitter { installedHooks: getPrefixedDeps('strapi-hook', pkgJSON), }; - this.groupManager = new UniqueWritetMap('Group'); - this.serviceManager = new UniqueWritetMap('Service'); this.fs = createStrapiFs(this); this.requireProjectBootstrap(); } @@ -247,6 +246,7 @@ class Strapi extends EventEmitter { middlewares, hook, extensions, + groups, ] = await Promise.all([ loadConfig(this), loadApis(this), @@ -255,12 +255,14 @@ class Strapi extends EventEmitter { loadMiddlewares(this), loadHooks(this.config), loadExtensions(this.config), + loadGroups(this), ]); _.merge(this.config, config); this.api = api; this.admin = admin; + this.groups = groups; this.plugins = plugins; this.middleware = middlewares; this.hook = hook; @@ -284,10 +286,6 @@ class Strapi extends EventEmitter { await bootstrap(this); - // init service manager - this.initServices(); - this.initGroups(); - // Usage. await utils.usage(this.config); @@ -455,56 +453,6 @@ class Strapi extends EventEmitter { associations: model.associations, }); } - - initServices() { - this.serviceManager.clear(); - - Object.entries(this.plugins).forEach(([pluginKey, plugin]) => { - if (!plugin.services) { - return; - } - - Object.entries(plugin.services).forEach(([serviceKey, service]) => { - this.serviceManager.set(`${pluginKey}.${serviceKey}`, service); - }); - }); - - if (this.admin.services) { - Object.entries(this.admin.services).forEach(([serviceKey, service]) => { - this.serviceManager.set(`admin.${serviceKey}`, service); - }); - } - - Object.entries(this.api).forEach(([apiKey, api]) => { - if (!api.services) { - return; - } - - Object.entries(api.services).forEach(([serviceKey, service]) => { - this.serviceManager.set(`${apiKey}.${serviceKey}`, service); - }); - }); - - return this; - } - - service(key) { - return this.serviceManager.get(key); - } - - initGroups() { - this.groupManager.clear(); - - Object.entries(this.api).forEach(([apiKey, api]) => { - if (!api.groups) { - return; - } - - Object.entries(api.groups).forEach(([groupKey, group]) => { - this.groupManager.set(`${apiKey}.${groupKey}`, group); - }); - }); - } } module.exports = options => { @@ -512,31 +460,3 @@ module.exports = options => { global.strapi = strapi; return strapi; }; - -class UniqueWritetMap extends Map { - constructor(mapName) { - super(); - this.mapName = mapName; - } - - get(key) { - if (!this.has(key)) { - throw new Error(`${this.mapName} ${key} not found`); - } - - return super.get(key); - } - - set(key, value) { - if (this.has(key)) { - throw new Error( - `${ - this.mapName - } ${key} already exists. Make sure you don't have conflicts with your installed plugins` - ); - } - - super.set(key, value); - return this; - } -} diff --git a/packages/strapi/lib/core/bootstrap.js b/packages/strapi/lib/core/bootstrap.js index bac5b8c8c6..37c76c6ea4 100644 --- a/packages/strapi/lib/core/bootstrap.js +++ b/packages/strapi/lib/core/bootstrap.js @@ -41,6 +41,16 @@ module.exports = function(strapi) { // Initialize main router to use it in middlewares. strapi.router = routerJoi(); + Object.keys(strapi.groups).forEach(key => { + const group = strapi.groups[key]; + return Object.assign(group, { + globalId: group.globalId || _.upperFirst(_.camelCase(`group_${key}`)), + collectionName: + group.collectionName || `group_${key}`.toLocaleLowerCase(), + connection: group.connection || defaultConnection, + }); + }); + // Set models. strapi.models = Object.keys(strapi.api || []).reduce((acc, key) => { for (let index in strapi.api[key].models) { diff --git a/packages/strapi/lib/core/index.js b/packages/strapi/lib/core/index.js index c19a95396b..608166b3fe 100644 --- a/packages/strapi/lib/core/index.js +++ b/packages/strapi/lib/core/index.js @@ -9,6 +9,7 @@ const loadExtensions = require('./load-extensions'); const loadHooks = require('./load-hooks'); const bootstrap = require('./bootstrap'); const initCoreStore = require('./init-core-store'); +const loadGroups = require('./load-groups'); module.exports = { loadConfig, @@ -18,6 +19,7 @@ module.exports = { loadMiddlewares, loadHooks, loadExtensions, + loadGroups, bootstrap, initCoreStore, }; diff --git a/packages/strapi/lib/core/load-groups.js b/packages/strapi/lib/core/load-groups.js new file mode 100644 index 0000000000..ed56b47469 --- /dev/null +++ b/packages/strapi/lib/core/load-groups.js @@ -0,0 +1,15 @@ +'use strict'; + +const { join } = require('path'); +const { exists } = require('fs-extra'); +const loadFiles = require('../load/load-files'); + +module.exports = async ({ dir }) => { + const groupsDir = join(dir, 'groups'); + + if (!(await exists(groupsDir))) { + return {}; + } + + return await loadFiles(groupsDir, '*.*(js|json)'); +};