diff --git a/packages/strapi-connector-bookshelf/lib/buildDatabaseSchema.js b/packages/strapi-connector-bookshelf/lib/buildDatabaseSchema.js index 793a8f2a7f..1f33f748e6 100644 --- a/packages/strapi-connector-bookshelf/lib/buildDatabaseSchema.js +++ b/packages/strapi-connector-bookshelf/lib/buildDatabaseSchema.js @@ -9,6 +9,95 @@ module.exports = async ({ connection, model, }) => { + const createIdType = (table, definition) => { + if (definition.primaryKeyType === 'uuid' && definition.client === 'pg') { + return table + .specificType('id', 'uuid DEFAULT uuid_generate_v4()') + .notNullable() + .primary(); + } + + if (definition.primaryKeyType !== 'integer') { + const col = buildColType({ + name: 'id', + table, + definition, + attribute: { + type: definition.primaryKeyType, + }, + }); + + if (!col) throw new Error('Invalid primaryKeyType'); + + return col.notNullable().primary(); + } + + return table.increments('id'); + }; + + const buildColType = ({ name, attribute, table }) => { + if (!attribute.type) { + const relation = definition.associations.find(association => { + return association.alias === name; + }); + + if (['oneToOne', 'manyToOne', 'oneWay'].includes(relation.nature)) { + return buildColType({ + name, + attribute: { type: definition.primaryKeyType }, + table, + }); + } + + return null; + } + + // allo custom data type for a column + if (_.has(attribute, 'columnType')) { + return table.specificType(name, attribute.columnType); + } + + switch (attribute.type) { + case 'uuid': + return table.uuid(name); + case 'richtext': + case 'text': + return table.text(name, 'longtext'); + case 'json': + return definition.client === 'pg' + ? table.jsonb(name) + : table.text(name, 'longtext'); + case 'enumeration': + return table.enu(name, attribute.enum || []); + case 'string': + case 'password': + case 'email': + return table.string(name); + case 'integer': + return table.integer(name); + case 'biginteger': + return table.bigInteger(name); + case 'float': + return table.double(name); + case 'decimal': + return table.decimal(name, 10, 2); + case 'date': + return table.date(name); + case 'time': + return table.time(name, 3); + case 'datetime': + return table.datetime(name); + case 'timestamp': + return table.timestamp(name); + case 'currentTimestamp': + return table.timestamp(name).defaultTo(ORM.knex.fn.now()); + case 'boolean': + return table.boolean(name); + default: + return null; + } + }; + const { hasTimestamps } = loadedModel; let [createAtCol, updatedAtCol] = ['created_at', 'updated_at']; @@ -83,33 +172,26 @@ module.exports = async ({ Object.keys(columns).forEach(key => { const attribute = columns[key]; - const type = getType({ - definition, - attribute, - name: key, - tableExists, - }); - if (type) { - const col = tbl.specificType(key, type); + const col = buildColType({ name: key, attribute, table: tbl }); + if (!col) return; - if (attribute.required === true) { - if (definition.client !== 'sqlite3' || !tableExists) { - col.notNullable(); - } - } else { - col.nullable(); + if (attribute.required === true) { + if (definition.client !== 'sqlite3' || !tableExists) { + col.notNullable(); } + } else { + col.nullable(); + } - if (attribute.unique === true) { - if (definition.client !== 'sqlite3' || !tableExists) { - tbl.unique(key, uniqueColName(table, key)); - } + if (attribute.unique === true) { + if (definition.client !== 'sqlite3' || !tableExists) { + tbl.unique(key, uniqueColName(table, key)); } + } - if (alter) { - col.alter(); - } + if (alter) { + col.alter(); } }); }; @@ -124,7 +206,7 @@ module.exports = async ({ const createTable = (table, { trx = ORM.knex, ...opts } = {}) => { return trx.schema.createTable(table, tbl => { - tbl.specificType('id', getIdType(definition)); + createIdType(tbl, definition); createColumns(tbl, attributes, { ...opts, tableExists: false }); }); }; @@ -201,7 +283,7 @@ module.exports = async ({ await createTable(table, { trx }); const attrs = Object.keys(attributes).filter(attribute => - getType({ + isColumn({ definition, attribute: attributes[attribute], name: attribute, @@ -283,15 +365,22 @@ module.exports = async ({ // Add created_at and updated_at field if timestamp option is true if (hasTimestamps) { definition.attributes[createAtCol] = { - type: 'timestamp', + type: 'currentTimestamp', }; definition.attributes[updatedAtCol] = { - type: 'timestampUpdate', + type: 'currentTimestamp', }; } - // Save all attributes (with timestamps) - model.allAttributes = _.clone(definition.attributes); + // Save all attributes (with timestamps) and right type + model.allAttributes = _.assign(_.clone(definition.attributes), { + [createAtCol]: { + type: 'timestamp', + }, + [updatedAtCol]: { + type: 'timestamp', + }, + }); // Equilize tables if (connection.options && connection.options.autoMigration !== false) { @@ -369,79 +458,26 @@ module.exports = async ({ } }; -const getType = ({ definition, attribute, name, tableExists = false }) => { - const { client } = definition; - - if (!attribute.type) { - // Add integer value if there is a relation +const isColumn = ({ definition, attribute, name }) => { + if (!_.has(attribute, 'type')) { const relation = definition.associations.find(association => { return association.alias === name; }); - switch (relation.nature) { - case 'oneToOne': - case 'manyToOne': - case 'oneWay': - return definition.primaryKeyType; - default: - return null; + if (!relation) return false; + + if (['oneToOne', 'manyToOne', 'oneWay'].includes(relation.nature)) { + return true; } + + return false; } - switch (attribute.type) { - case 'uuid': - return client === 'pg' ? 'uuid' : 'varchar(36)'; - case 'richtext': - case 'text': - return client === 'pg' ? 'text' : 'longtext'; - case 'json': - return client === 'pg' ? 'jsonb' : 'longtext'; - case 'string': - case 'enumeration': - case 'password': - case 'email': - return 'varchar(255)'; - case 'integer': - return client === 'pg' ? 'integer' : 'int'; - case 'biginteger': - if (client === 'sqlite3') return 'bigint(53)'; // no choice until the sqlite3 package supports returning strings for big integers - return 'bigint'; - case 'float': - return client === 'pg' ? 'double precision' : 'double'; - case 'decimal': - return 'decimal(10,2)'; - // TODO: split time types as they should be different - case 'date': - case 'time': - case 'datetime': - if (client === 'pg') { - return 'timestamp with time zone'; - } - - return 'timestamp'; - case 'timestamp': - 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 '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'; - } - case 'boolean': - return 'boolean'; - default: + if (['component', 'dynamiczone'].includes(attribute.type)) { + return false; } + + return true; }; const storeTable = async (table, attributes) => { @@ -464,29 +500,4 @@ const storeTable = async (table, attributes) => { }).save(); }; -const defaultIdType = { - mysql: 'INT AUTO_INCREMENT NOT NULL PRIMARY KEY', - pg: 'SERIAL NOT NULL PRIMARY KEY', - sqlite3: 'INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', -}; - -const getIdType = definition => { - if (definition.primaryKeyType === 'uuid' && definition.client === 'pg') { - return 'uuid NOT NULL DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY'; - } - - if (definition.primaryKeyType !== 'integer') { - const type = getType({ - definition, - attribute: { - type: definition.primaryKeyType, - }, - }); - - return `${type} NOT NULL PRIMARY KEY`; - } - - return defaultIdType[definition.client]; -}; - const uniqueColName = (table, key) => `${table}_${key}_unique`; diff --git a/packages/strapi-connector-bookshelf/lib/formatter.js b/packages/strapi-connector-bookshelf/lib/formatter.js new file mode 100644 index 0000000000..24c4c01619 --- /dev/null +++ b/packages/strapi-connector-bookshelf/lib/formatter.js @@ -0,0 +1,64 @@ +'use strict'; + +const { isValid, format, formatISO } = require('date-fns'); +const { has } = require('lodash'); + +const createFormatter = client => ({ type }, value) => { + if (value === null) return null; + + const formatter = { + ...defaultFormatter, + ...formatters[client], + }; + + if (has(formatter, type)) { + return formatter[type](value); + } + + return value; +}; + +const defaultFormatter = { + json: value => { + if (typeof value === 'object') return value; + return JSON.parse(value); + }, + boolean: value => { + if (typeof value === 'boolean') { + return value; + } + + const strVal = value.toString(); + if (strVal === '1') { + return true; + } else if (strVal === '0') { + return false; + } else { + return null; + } + }, + date: value => { + const cast = new Date(value); + return isValid(cast) ? formatISO(cast, { representation: 'date' }) : null; + }, + datetime: value => { + const cast = new Date(value); + return isValid(cast) ? cast.toISOString() : null; + }, + timestamp: value => { + const cast = new Date(value); + return isValid(cast) ? format(cast, 'T') : null; + }, +}; + +const formatters = { + sqlite3: { + biginteger: value => { + return value.toString(); + }, + }, +}; + +module.exports = { + createFormatter, +}; diff --git a/packages/strapi-connector-bookshelf/lib/generate-component-relations.js b/packages/strapi-connector-bookshelf/lib/generate-component-relations.js index 78272d3cc4..09119ca10a 100644 --- a/packages/strapi-connector-bookshelf/lib/generate-component-relations.js +++ b/packages/strapi-connector-bookshelf/lib/generate-component-relations.js @@ -78,7 +78,10 @@ const createComponentJoinTables = async ({ definition, ORM }) => { .notNullable(); table.string('component_type').notNullable(); table.integer('component_id').notNullable(); - table.integer(joinColumn).notNullable(); + table + .integer(joinColumn) + .unsigned() + .notNullable(); table .foreign(joinColumn) diff --git a/packages/strapi-connector-bookshelf/lib/mount-models.js b/packages/strapi-connector-bookshelf/lib/mount-models.js index f56ebe7fb4..4e0135543a 100644 --- a/packages/strapi-connector-bookshelf/lib/mount-models.js +++ b/packages/strapi-connector-bookshelf/lib/mount-models.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); const { singular } = require('pluralize'); -const dateFns = require('date-fns'); const utilsModels = require('strapi-utils').models; const relations = require('./relations'); @@ -10,6 +9,8 @@ const { createComponentJoinTables, createComponentModels, } = require('./generate-component-relations'); +const { createParser } = require('./parser'); +const { createFormatter } = require('./formatter'); const populateFetch = require('./populate'); @@ -457,12 +458,14 @@ module.exports = ({ models, target, plugin = false }, ctx) => { // Call this callback function after we are done parsing // all attributes for relationships-- see below. + const parseValue = createParser(); try { // External function to map key that has been updated with `columnName` const mapper = (params = {}) => { Object.keys(params).map(key => { const attr = definition.attributes[key] || {}; - params[key] = castValueFromType(attr.type, params[key], definition); + + params[key] = parseValue(attr.type, params[key]); }); return _.mapKeys(params, (value, key) => { @@ -611,92 +614,54 @@ module.exports = ({ models, target, plugin = false }, ctx) => { : Promise.resolve(); }); - //eslint-disable-next-line - this.on('saving', (instance, attrs, options) => { - instance.attributes = mapper(instance.attributes); - attrs = mapper(attrs); + this.on('saving', (instance, attrs) => { + instance.attributes = _.assign(instance.attributes, 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 formatter = attributes => { - Object.keys(attributes).forEach(key => { - const attr = definition.attributes[key] || {}; - - if (attributes[key] === null) return; - - if (attr.type === 'json') { - attributes[key] = JSON.parse(attributes[key]); - } - - if (attr.type === 'boolean') { - if (typeof attributes[key] === 'boolean') { - return; - } - - const strVal = attributes[key].toString(); - if (strVal === '1') { - attributes[key] = true; - } else if (strVal === '0') { - attributes[key] = false; - } else { - attributes[key] = null; - } - } - - if (attr.type === 'date' && definition.client === 'sqlite3') { - attributes[key] = dateFns.parse(attributes[key]); - } - - if ( - attr.type === 'biginteger' && - definition.client === 'sqlite3' - ) { - attributes[key] = attributes[key].toString(); - } - }); - }; - - events.forEach(event => { - let fn; - - if (event.name.indexOf('collection') !== -1) { - fn = instance => - instance.models.map(entry => { - formatter(entry.attributes); - }); - } else { - fn = instance => formatter(instance.attributes); - } - - this.on(event.name, instance => { - fn(instance); - - return _.isFunction(target[model.toLowerCase()][event.target]) - ? target[model.toLowerCase()][event.target] - : Promise.resolve(); - }); + const formatValue = createFormatter(definition.client); + function formatEntry(entry) { + Object.keys(entry.attributes).forEach(key => { + const attr = definition.attributes[key] || {}; + entry.attributes[key] = formatValue(attr, entry.attributes[key]); }); } + + function formatOutput(instance) { + if (Array.isArray(instance.models)) { + instance.models.forEach(entry => formatEntry(entry)); + } else { + formatEntry(instance); + } + } + + const events = [ + { + name: 'saved', + target: 'afterSave', + }, + { + name: 'fetched', + target: 'afterFetch', + }, + { + name: 'fetched:collection', + target: 'afterFetchAll', + }, + ]; + + events.forEach(event => { + this.on(event.name, instance => { + formatOutput(instance); + + return _.isFunction(target[model.toLowerCase()][event.target]) + ? target[model.toLowerCase()][event.target] + : Promise.resolve(); + }); + }); }; loadedModel.hidden = _.keys( @@ -748,34 +713,3 @@ module.exports = ({ models, target, plugin = false }, ctx) => { return Promise.all(updates); }; - -const castValueFromType = (type, value /* definition */) => { - // do not cast null values - if (value === null) return null; - - switch (type) { - case 'json': - return JSON.stringify(value); - // TODO: handle real date format 1970-01-01 - // TODO: handle real time format 12:00:00 - case 'time': - case 'timestamp': - case 'date': - case 'datetime': { - const date = dateFns.parse(value); - if (dateFns.isValid(date)) return date; - - date.setTime(value); - - if (!dateFns.isValid(date)) { - throw new Error( - `Invalid ${type} format, expected a timestamp or an ISO date` - ); - } - - return date; - } - default: - return value; - } -}; diff --git a/packages/strapi-connector-bookshelf/lib/parser.js b/packages/strapi-connector-bookshelf/lib/parser.js new file mode 100644 index 0000000000..6e4419633c --- /dev/null +++ b/packages/strapi-connector-bookshelf/lib/parser.js @@ -0,0 +1,16 @@ +'use strict'; + +const { parseType } = require('strapi-utils'); + +const createParser = () => (type, value) => { + if (value === null) return null; + + switch (type) { + case 'json': + return JSON.stringify(value); + default: + return parseType({ type, value }); + } +}; + +module.exports = { createParser }; diff --git a/packages/strapi-connector-bookshelf/package.json b/packages/strapi-connector-bookshelf/package.json index 7276174bac..665256b64c 100644 --- a/packages/strapi-connector-bookshelf/package.json +++ b/packages/strapi-connector-bookshelf/package.json @@ -17,7 +17,7 @@ "main": "./lib", "dependencies": { "bookshelf": "^1.0.1", - "date-fns": "^1.30.1", + "date-fns": "^2.8.1", "inquirer": "^6.3.1", "lodash": "^4.17.11", "pluralize": "^7.0.0", diff --git a/packages/strapi-connector-mongoose/lib/mount-models.js b/packages/strapi-connector-mongoose/lib/mount-models.js index c2e33e43d2..62c5b9ea3c 100644 --- a/packages/strapi-connector-mongoose/lib/mount-models.js +++ b/packages/strapi-connector-mongoose/lib/mount-models.js @@ -71,7 +71,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => { definition.loadedModel[name] = { ...attr, - type: utils(instance).convertType(attr.type), + ...utils(instance).convertType(attr.type), }; }); @@ -203,7 +203,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => { type: 'timestamp', }; target[model].allAttributes[updatedAtCol] = { - type: 'timestampUpdate', + type: 'timestamp', }; } else if (timestampsOption === true) { schema.set('timestamps', true); @@ -214,7 +214,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => { type: 'timestamp', }; target[model].allAttributes.updatedAt = { - type: 'timestampUpdate', + type: 'timestamp', }; } schema.set( diff --git a/packages/strapi-connector-mongoose/lib/utils/index.js b/packages/strapi-connector-mongoose/lib/utils/index.js index daf0fdbeae..1a7fb9d71f 100644 --- a/packages/strapi-connector-mongoose/lib/utils/index.js +++ b/packages/strapi-connector-mongoose/lib/utils/index.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const Mongoose = require('mongoose'); +const { parseType } = require('strapi-utils'); /** * Module dependencies @@ -29,35 +30,50 @@ module.exports = (mongoose = Mongoose) => { const convertType = mongooseType => { switch (mongooseType.toLowerCase()) { case 'array': - return Array; + return { type: Array }; case 'boolean': - return 'Boolean'; + return { type: 'Boolean' }; case 'binary': - return 'Buffer'; - case 'date': - case 'datetime': + return { type: 'Buffer' }; case 'time': + return { + type: String, + validate: value => parseType({ type: 'time', value }), + set: value => parseType({ type: 'time', value }), + }; + case 'date': + return { + type: String, + validate: value => parseType({ type: 'date', value }), + set: value => parseType({ type: 'date', value }), + }; + case 'datetime': + return { + type: Date, + }; case 'timestamp': - return Date; + return { + type: Date, + }; case 'decimal': - return 'Decimal'; + return { type: 'Decimal' }; case 'float': - return 'Float'; + return { type: 'Float' }; case 'json': - return 'Mixed'; + return { type: 'Mixed' }; case 'biginteger': - return 'Long'; + return { type: 'Long' }; case 'integer': - return 'Number'; + return { type: 'Number' }; case 'uuid': - return 'ObjectId'; + return { type: 'ObjectId' }; case 'email': case 'enumeration': case 'password': case 'string': case 'text': case 'richtext': - return 'String'; + return { type: 'String' }; default: return undefined; } diff --git a/packages/strapi-generate-model/lib/before.js b/packages/strapi-generate-model/lib/before.js index ab7e215ed8..e24d0c1c5c 100644 --- a/packages/strapi-generate-model/lib/before.js +++ b/packages/strapi-generate-model/lib/before.js @@ -138,6 +138,7 @@ module.exports = (scope, cb) => { // Get default connection try { scope.connection = + scope.args.connection || JSON.parse( fs.readFileSync( path.resolve( @@ -148,7 +149,8 @@ module.exports = (scope, cb) => { 'database.json' ) ) - ).defaultConnection || ''; + ).defaultConnection || + ''; } catch (err) { return cb.invalid(err); } diff --git a/packages/strapi-plugin-content-manager/services/utils/configuration/__tests__/attributes.test.js b/packages/strapi-plugin-content-manager/services/utils/configuration/__tests__/attributes.test.js index aec2118a79..2d1b95b528 100644 --- a/packages/strapi-plugin-content-manager/services/utils/configuration/__tests__/attributes.test.js +++ b/packages/strapi-plugin-content-manager/services/utils/configuration/__tests__/attributes.test.js @@ -16,7 +16,7 @@ const createMockSchema = (attrs, timestamps = true) => { type: 'timestamp', }, updatedAt: { - type: 'timestampUpdate', + type: 'timestamp', }, } : {}), diff --git a/packages/strapi-plugin-content-manager/services/utils/configuration/layouts.js b/packages/strapi-plugin-content-manager/services/utils/configuration/layouts.js index 005bc80278..438246fcd1 100644 --- a/packages/strapi-plugin-content-manager/services/utils/configuration/layouts.js +++ b/packages/strapi-plugin-content-manager/services/utils/configuration/layouts.js @@ -15,6 +15,7 @@ const typeToSize = type => { case 'checkbox': case 'boolean': case 'date': + case 'time': case 'biginteger': case 'decimal': case 'float': diff --git a/packages/strapi-plugin-content-manager/test/types/date.test.e2e.js b/packages/strapi-plugin-content-manager/test/types/date.test.e2e.js index c342371206..2f1a4b43ba 100644 --- a/packages/strapi-plugin-content-manager/test/types/date.test.e2e.js +++ b/packages/strapi-plugin-content-manager/test/types/date.test.e2e.js @@ -24,14 +24,14 @@ describe('Test type date', () => { '/content-manager/explorer/application::withdate.withdate', { body: { - field: '2019-08-08T10:10:57.000Z', + field: '2019-08-08', }, } ); expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ - field: '2019-08-08T10:10:57.000Z', + field: '2019-08-08', }); }); @@ -49,58 +49,53 @@ describe('Test type date', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ - field: now.toISOString(), + field: '2019-01-12', }); }); - test('Create entry with timestamp value should be converted to ISO', async () => { - const now = new Date(2016, 4, 8); + test.each([ + '2019-08-08', + '2019-08-08 12:11:12', + '2019-08-08T00:00:00', + '2019-08-08T00:00:00Z', + '2019-08-08 00:00:00.123', + '2019-08-08 00:00:00.123Z', + '2019-08-08T00:00:00.123', + '2019-08-08T00:00:00.123Z', + ])( + 'Date can be sent in any iso format and the date part will be kept, (%s)', + async input => { + const res = await rq.post( + '/content-manager/explorer/application::withdate.withdate', + { + body: { + field: input, + }, + } + ); - const res = await rq.post( - '/content-manager/explorer/application::withdate.withdate', - { - body: { - field: now.getTime(), - }, - } - ); + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: '2019-08-08', + }); + } + ); - expect(res.statusCode).toBe(200); - expect(res.body).toMatchObject({ - field: now.toISOString(), - }); - }); + test.each([1234567891012, '1234567891012', '2019/12/11', '12:11:11'])( + 'Throws on invalid date (%s)', + async value => { + const res = await rq.post( + '/content-manager/explorer/application::withdate.withdate', + { + body: { + field: value, + }, + } + ); - test('Accepts string timestamp', async () => { - const now = new Date(2000, 0, 1); - - const res = await rq.post( - '/content-manager/explorer/application::withdate.withdate', - { - body: { - field: `${now.getTime()}`, - }, - } - ); - - expect(res.statusCode).toBe(200); - expect(res.body).toMatchObject({ - field: now.toISOString(), - }); - }); - - test('Throws on invalid date format', async () => { - const res = await rq.post( - '/content-manager/explorer/application::withdate.withdate', - { - body: { - field: 'azdazindoaizdnoainzd', - }, - } - ); - - expect(res.statusCode).toBe(400); - }); + expect(res.statusCode).toBe(400); + } + ); test('Reading entry, returns correct value', async () => { const res = await rq.get( @@ -110,7 +105,9 @@ describe('Test type date', () => { expect(res.statusCode).toBe(200); expect(Array.isArray(res.body)).toBe(true); res.body.forEach(entry => { - expect(new Date(entry.field).toISOString()).toBe(entry.field); + expect(entry.field).toMatch( + /^([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))$/ + ); }); }); @@ -121,7 +118,7 @@ describe('Test type date', () => { '/content-manager/explorer/application::withdate.withdate', { body: { - field: now.getTime(), + field: now, }, } ); @@ -139,7 +136,7 @@ describe('Test type date', () => { expect(updateRes.statusCode).toBe(200); expect(updateRes.body).toMatchObject({ id: res.body.id, - field: newDate.toISOString(), + field: '2017-11-23', }); }); }); diff --git a/packages/strapi-plugin-content-manager/test/types/datetime.test.e2e.js b/packages/strapi-plugin-content-manager/test/types/datetime.test.e2e.js new file mode 100644 index 0000000000..64b808717e --- /dev/null +++ b/packages/strapi-plugin-content-manager/test/types/datetime.test.e2e.js @@ -0,0 +1,145 @@ +const { registerAndLogin } = require('../../../../test/helpers/auth'); +const createModelsUtils = require('../../../../test/helpers/models'); +const { createAuthRequest } = require('../../../../test/helpers/request'); + +let modelsUtils; +let rq; + +describe('Test type date', () => { + beforeAll(async () => { + const token = await registerAndLogin(); + rq = createAuthRequest(token); + + modelsUtils = createModelsUtils({ rq }); + + await modelsUtils.createModelWithType('withdatetime', 'datetime'); + }, 60000); + + afterAll(async () => { + await modelsUtils.deleteModel('withdatetime'); + }, 60000); + + test('Create entry with valid value JSON', async () => { + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + body: { + field: '2019-08-08T10:10:57.000Z', + }, + } + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: '2019-08-08T10:10:57.000Z', + }); + }); + + test('Create entry with valid value FormData', async () => { + const now = new Date(2019, 0, 12); + + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + formData: { + data: JSON.stringify({ field: now }), + }, + } + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: now.toISOString(), + }); + }); + + test('Create entry with timestamp value should be converted to ISO', async () => { + const now = new Date(2016, 4, 8); + + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + body: { + field: now.getTime(), + }, + } + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: now.toISOString(), + }); + }); + + test('Accepts string timestamp', async () => { + const now = new Date(2000, 0, 1); + + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + body: { + field: `${now.getTime()}`, + }, + } + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: now.toISOString(), + }); + }); + + test('Throws on invalid date format', async () => { + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + body: { + field: 'azdazindoaizdnoainzd', + }, + } + ); + + expect(res.statusCode).toBe(400); + }); + + test('Reading entry, returns correct value', async () => { + const res = await rq.get( + '/content-manager/explorer/application::withdatetime.withdatetime' + ); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + res.body.forEach(entry => { + expect(new Date(entry.field).toISOString()).toBe(entry.field); + }); + }); + + test('Updating entry sets the right value and format JSON', async () => { + const now = new Date(2018, 7, 5); + + const res = await rq.post( + '/content-manager/explorer/application::withdatetime.withdatetime', + { + body: { + field: now.getTime(), + }, + } + ); + + const newDate = new Date(2017, 10, 23); + const updateRes = await rq.put( + `/content-manager/explorer/application::withdatetime.withdatetime/${res.body.id}`, + { + body: { + field: newDate, + }, + } + ); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.body).toMatchObject({ + id: res.body.id, + field: newDate.toISOString(), + }); + }); +}); diff --git a/packages/strapi-plugin-content-manager/test/types/time.test.e2e.js b/packages/strapi-plugin-content-manager/test/types/time.test.e2e.js new file mode 100644 index 0000000000..106a323c48 --- /dev/null +++ b/packages/strapi-plugin-content-manager/test/types/time.test.e2e.js @@ -0,0 +1,109 @@ +const { registerAndLogin } = require('../../../../test/helpers/auth'); +const createModelsUtils = require('../../../../test/helpers/models'); +const { createAuthRequest } = require('../../../../test/helpers/request'); + +let modelsUtils; +let rq; + +describe('Test type time', () => { + beforeAll(async () => { + const token = await registerAndLogin(); + rq = createAuthRequest(token); + + modelsUtils = createModelsUtils({ rq }); + + await modelsUtils.createModelWithType('withtime', 'time'); + }, 60000); + + afterAll(async () => { + await modelsUtils.deleteModel('withtime'); + }, 60000); + + test('Create entry with valid value JSON', async () => { + const res = await rq.post( + '/content-manager/explorer/application::withtime.withtime', + { + body: { + field: '10:10:57.123', + }, + } + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + field: '10:10:57.123', + }); + }); + + test.each(['00:00:00', '01:03:11.2', '01:03:11.93', '01:03:11.123'])( + 'Accepts multiple time formats %s', + async input => { + const res = await rq.post( + '/content-manager/explorer/application::withtime.withtime', + { + body: { + field: input, + }, + } + ); + + expect(res.statusCode).toBe(200); + } + ); + + test.each(['24:11:23', '23:72:11', '12:45:83', 1234, {}, 'test', new Date()])( + 'Throws on invalid time (%s)', + async input => { + const res = await rq.post( + '/content-manager/explorer/application::withtime.withtime', + { + body: { + field: input, + }, + } + ); + + expect(res.statusCode).toBe(400); + } + ); + + test('Reading entry, returns correct value', async () => { + const res = await rq.get( + '/content-manager/explorer/application::withtime.withtime' + ); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + res.body.forEach(entry => { + expect(entry.field).toMatch( + /^2[0-3]|[01][0-9]:[0-5][0-9]:[0-5][0-9](.[0-9]{1,3})?$/ + ); + }); + }); + + test('Updating entry sets the right value and format JSON', async () => { + const res = await rq.post( + '/content-manager/explorer/application::withtime.withtime', + { + body: { + field: '12:11:04', + }, + } + ); + + const uptimeRes = await rq.put( + `/content-manager/explorer/application::withtime.withtime/${res.body.id}`, + { + body: { + field: '13:45:19.123', + }, + } + ); + + expect(uptimeRes.statusCode).toBe(200); + expect(uptimeRes.body).toMatchObject({ + id: res.body.id, + field: '13:45:19.123', + }); + }); +}); diff --git a/packages/strapi-plugin-content-type-builder/controllers/validation/component.js b/packages/strapi-plugin-content-type-builder/controllers/validation/component.js index e313cf1f22..fc6fe2970e 100644 --- a/packages/strapi-plugin-content-type-builder/controllers/validation/component.js +++ b/packages/strapi-plugin-content-type-builder/controllers/validation/component.js @@ -6,31 +6,10 @@ const yup = require('yup'); const { isValidName, isValidIcon } = require('./common'); const formatYupErrors = require('./yup-formatter'); const createSchema = require('./model-schema'); -const { modelTypes } = require('./constants'); +const { modelTypes, DEFAULT_TYPES } = require('./constants'); const VALID_RELATIONS = ['oneWay', 'manyWay']; -const VALID_TYPES = [ - // advanced types - 'media', - - // scalar types - 'string', - 'text', - 'richtext', - 'json', - 'enumeration', - 'password', - 'email', - 'integer', - 'biginteger', - 'float', - 'decimal', - 'date', - 'boolean', - - // nested component - 'component', -]; +const VALID_TYPES = [...DEFAULT_TYPES, 'component']; const componentSchema = createSchema(VALID_TYPES, VALID_RELATIONS, { modelType: modelTypes.COMPONENT, diff --git a/packages/strapi-plugin-content-type-builder/controllers/validation/constants.js b/packages/strapi-plugin-content-type-builder/controllers/validation/constants.js index b275752ff7..39aac7e04d 100644 --- a/packages/strapi-plugin-content-type-builder/controllers/validation/constants.js +++ b/packages/strapi-plugin-content-type-builder/controllers/validation/constants.js @@ -3,7 +3,31 @@ const CONTENT_TYPE = 'CONTENT_TYPE'; const COMPONENT = 'COMPONENT'; +const DEFAULT_TYPES = [ + // advanced types + 'media', + + // scalar types + 'string', + 'text', + 'richtext', + 'json', + 'enumeration', + 'password', + 'email', + 'integer', + 'biginteger', + 'float', + 'decimal', + 'date', + 'time', + 'datetime', + 'timestamp', + 'boolean', +]; + module.exports = { + DEFAULT_TYPES, modelTypes: { CONTENT_TYPE, COMPONENT, diff --git a/packages/strapi-plugin-content-type-builder/controllers/validation/content-type.js b/packages/strapi-plugin-content-type-builder/controllers/validation/content-type.js index de33b40197..f6046b726a 100644 --- a/packages/strapi-plugin-content-type-builder/controllers/validation/content-type.js +++ b/packages/strapi-plugin-content-type-builder/controllers/validation/content-type.js @@ -6,7 +6,7 @@ const formatYupErrors = require('./yup-formatter'); const createSchema = require('./model-schema'); const { nestedComponentSchema } = require('./component'); -const { modelTypes } = require('./constants'); +const { modelTypes, DEFAULT_TYPES } = require('./constants'); const VALID_RELATIONS = [ 'oneWay', @@ -16,29 +16,7 @@ const VALID_RELATIONS = [ 'manyToOne', 'manyToMany', ]; -const VALID_TYPES = [ - // advanced types - 'media', - - // scalar types - 'string', - 'text', - 'richtext', - 'json', - 'enumeration', - 'password', - 'email', - 'integer', - 'biginteger', - 'float', - 'decimal', - 'date', - 'boolean', - - // nested component - 'component', - 'dynamiczone', -]; +const VALID_TYPES = [...DEFAULT_TYPES, 'component', 'dynamiczone']; const contentTypeSchema = createSchema(VALID_TYPES, VALID_RELATIONS, { modelType: modelTypes.CONTENT_TYPE, diff --git a/packages/strapi-plugin-content-type-builder/services/schema-builder/content-type-builder.js b/packages/strapi-plugin-content-type-builder/services/schema-builder/content-type-builder.js index af5fa12896..fb5d68cb5d 100644 --- a/packages/strapi-plugin-content-type-builder/services/schema-builder/content-type-builder.js +++ b/packages/strapi-plugin-content-type-builder/services/schema-builder/content-type-builder.js @@ -80,6 +80,10 @@ module.exports = function createComponentBuilder() { .set('collectionName', infos.collectionName || defaultCollectionName) .set(['info', 'name'], infos.name) .set(['info', 'description'], infos.description) + .set('options', { + increments: true, + timestamps: true, + }) .set('attributes', this.convertAttributes(infos.attributes)); Object.keys(infos.attributes).forEach(key => { diff --git a/packages/strapi-plugin-graphql/services/Aggregator.js b/packages/strapi-plugin-graphql/services/Aggregator.js index 09a9139fa3..a2bbeb8749 100644 --- a/packages/strapi-plugin-graphql/services/Aggregator.js +++ b/packages/strapi-plugin-graphql/services/Aggregator.js @@ -481,7 +481,7 @@ const formatModelConnectionsGQL = function(fields, model, name, modelResolver) { groupBy: `${globalId}GroupBy`, aggregate: `${globalId}Aggregator`, }; - const pluralName = pluralize.plural(name); + const pluralName = pluralize.plural(_.camelCase(name)); let modelConnectionTypes = `type ${connectionGlobalId} {${Schema.formatGQL( connectionFields diff --git a/packages/strapi-utils/lib/__tests__/parse-type.js b/packages/strapi-utils/lib/__tests__/parse-type.js new file mode 100644 index 0000000000..4ca7934db1 --- /dev/null +++ b/packages/strapi-utils/lib/__tests__/parse-type.js @@ -0,0 +1,89 @@ +const parseType = require('../parse-type'); + +describe('parseType', () => { + describe('boolean', () => { + it('Handles string booleans', () => { + expect(parseType({ type: 'boolean', value: 'true' })).toBe(true); + expect(parseType({ type: 'boolean', value: 't' })).toBe(true); + expect(parseType({ type: 'boolean', value: '1' })).toBe(true); + + expect(parseType({ type: 'boolean', value: 'false' })).toBe(false); + expect(parseType({ type: 'boolean', value: 'f' })).toBe(false); + expect(parseType({ type: 'boolean', value: '0' })).toBe(false); + + expect(() => parseType({ type: 'boolean', value: 'test' })).toThrow(); + }); + + it('Handles numerical booleans', () => { + expect(parseType({ type: 'boolean', value: 1 })).toBe(true); + + expect(parseType({ type: 'boolean', value: 0 })).toBe(false); + + expect(() => parseType({ type: 'boolean', value: 12 })).toThrow(); + }); + }); + + describe('Time', () => { + it('Always returns the same time format', () => { + expect(parseType({ type: 'time', value: '12:31:11' })).toBe( + '12:31:11.000' + ); + expect(parseType({ type: 'time', value: '12:31:11.2' })).toBe( + '12:31:11.200' + ); + expect(parseType({ type: 'time', value: '12:31:11.31' })).toBe( + '12:31:11.310' + ); + expect(parseType({ type: 'time', value: '12:31:11.319' })).toBe( + '12:31:11.319' + ); + }); + + it('Throws on invalid time format', () => { + expect(() => parseType({ type: 'time', value: '25:12:09' })).toThrow(); + expect(() => parseType({ type: 'time', value: '23:78:09' })).toThrow(); + expect(() => parseType({ type: 'time', value: '23:11:99' })).toThrow(); + + expect(() => parseType({ type: 'time', value: '12:12' })).toThrow(); + expect(() => parseType({ type: 'time', value: 'test' })).toThrow(); + expect(() => parseType({ type: 'time', value: 122 })).toThrow(); + expect(() => parseType({ type: 'time', value: {} })).toThrow(); + expect(() => parseType({ type: 'time', value: [] })).toThrow(); + }); + }); + + describe('Date', () => { + it('Supports ISO formats and always returns the right format', () => { + expect(parseType({ type: 'date', value: '2019-01-01 12:01:11' })).toBe( + '2019-01-01' + ); + + expect(parseType({ type: 'date', value: '2018-11-02' })).toBe( + '2018-11-02' + ); + + expect( + parseType({ type: 'date', value: '2019-04-21T00:00:00.000Z' }) + ).toBe('2019-04-21'); + }); + + it('Throws on invalid formator dates', () => { + expect(() => parseType({ type: 'date', value: '-1029-11-02' })).toThrow(); + expect(() => parseType({ type: 'date', value: '2019-13-02' })).toThrow(); + expect(() => parseType({ type: 'date', value: '2019-12-32' })).toThrow(); + expect(() => parseType({ type: 'date', value: '2019-02-31' })).toThrow(); + }); + }); + + describe('Datetime', () => { + it.each([ + '2019-01-01', + '2019-01-01 10:11:12', + '1234567890111', + '2019-01-01T10:11:12.123Z', + ])('Supports ISO formats and always returns a date %s', value => { + const r = parseType({ type: 'datetime', value }); + expect(r instanceof Date).toBe(true); + }); + }); +}); diff --git a/packages/strapi-utils/lib/buildQuery.js b/packages/strapi-utils/lib/buildQuery.js index fe03df1696..5e814b3323 100644 --- a/packages/strapi-utils/lib/buildQuery.js +++ b/packages/strapi-utils/lib/buildQuery.js @@ -1,6 +1,7 @@ //TODO: move to dbal const _ = require('lodash'); +const parseType = require('./parse-type'); const findModelByAssoc = assoc => { const { models } = assoc.plugin ? strapi.plugins[assoc.plugin] : strapi; @@ -58,33 +59,16 @@ const getAssociationFromFieldKey = ({ model, field }) => { }; /** - * Cast basic values based on attribute type + * Cast an input value * @param {Object} options - Options * @param {string} options.type - type of the atribute * @param {*} options.value - value tu cast + * @param {string} options.operator - name of operator */ -const castValueToType = ({ type, value }) => { - switch (type) { - case 'boolean': { - if (['true', 't', '1', 1, true].includes(value)) { - return true; - } - - if (['false', 'f', '0', 0].includes(value)) { - return false; - } - - return Boolean(value); - } - case 'integer': - case 'biginteger': - case 'float': - case 'decimal': { - return _.toNumber(value); - } - default: - return value; - } +const castInput = ({ type, value, operator }) => { + return Array.isArray(value) + ? value.map(val => castValue({ type, operator, value: val })) + : castValue({ type, operator, value: value }); }; /** @@ -95,8 +79,8 @@ const castValueToType = ({ type, value }) => { * @param {string} options.operator - name of operator */ const castValue = ({ type, value, operator }) => { - if (operator === 'null') return castValueToType({ type: 'boolean', value }); - return castValueToType({ type, value }); + if (operator === 'null') return parseType({ type: 'boolean', value }); + return parseType({ type, value }); }; /** * @@ -126,12 +110,10 @@ const buildQuery = ({ model, filters = {}, ...rest }) => { field, }); - const { type } = _.get(assocModel, ['attributes', attribute], {}); + const { type } = _.get(assocModel, ['allAttributes', attribute], {}); // cast value or array of values - const castedValue = Array.isArray(value) - ? value.map(val => castValue({ type, operator, value: val })) - : castValue({ type, operator, value: value }); + const castedValue = castInput({ type, operator, value }); return { field: field === 'id' ? model.primaryKey : field, diff --git a/packages/strapi-utils/lib/index.js b/packages/strapi-utils/lib/index.js index 8e2c773dc7..c65ba96441 100644 --- a/packages/strapi-utils/lib/index.js +++ b/packages/strapi-utils/lib/index.js @@ -8,6 +8,7 @@ const convertRestQueryParams = require('./convertRestQueryParams'); const buildQuery = require('./buildQuery'); const parseMultipartData = require('./parse-multipart'); const sanitizeEntity = require('./sanitize-entity'); +const parseType = require('./parse-type'); module.exports = { cli: require('./cli'), @@ -25,4 +26,5 @@ module.exports = { buildQuery, parseMultipartData, sanitizeEntity, + parseType, }; diff --git a/packages/strapi-utils/lib/parse-type.js b/packages/strapi-utils/lib/parse-type.js new file mode 100644 index 0000000000..aa34445e76 --- /dev/null +++ b/packages/strapi-utils/lib/parse-type.js @@ -0,0 +1,100 @@ +'use strict'; + +const _ = require('lodash'); +const dates = require('date-fns'); + +const timeRegex = new RegExp( + '^(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]{1,3})?$' +); + +const parseTime = value => { + if (dates.isDate(value)) return dates.format(value, 'HH:mm:ss.SSS'); + + if (typeof value !== 'string') { + throw new Error(`Expected a string, got a ${typeof value}`); + } + const result = value.match(timeRegex); + + if (result === null) { + throw new Error('Invalid time format, expected HH:mm:ss.SSS'); + } + + const [, hours, minutes, seconds, fraction = '.000'] = result; + const fractionPart = _.padEnd(fraction.slice(1), 3, '0'); + + return `${hours}:${minutes}:${seconds}.${fractionPart}`; +}; + +const parseDate = value => { + if (dates.isDate(value)) return dates.format(value, 'yyyy-MM-dd'); + try { + let date = dates.parseISO(value); + + if (dates.isValid(date)) return dates.format(date, 'yyyy-MM-dd'); + + throw new Error(`Invalid format, expected an ISO compatble date`); + } catch (error) { + throw new Error(`Invalid format, expected an ISO compatble date`); + } +}; + +const parseDateTimeOrTimestamp = value => { + if (dates.isDate(value)) return value; + try { + const date = dates.parseISO(value); + if (dates.isValid(date)) return date; + + const milliUnixDate = dates.parse(value, 'T', new Date()); + if (dates.isValid(milliUnixDate)) return milliUnixDate; + + throw new Error(`Invalid format, expected a timestamp or an ISO date`); + } catch (error) { + throw new Error(`Invalid format, expected a timestamp or an ISO date`); + } +}; + +/** + * Cast basic values based on attribute type + * @param {Object} options - Options + * @param {string} options.type - type of the atribute + * @param {*} options.value - value tu cast + */ +const parseType = ({ type, value }) => { + switch (type) { + case 'boolean': { + if (typeof value === 'boolean') return value; + + if (['true', 't', '1', 1].includes(value)) { + return true; + } + + if (['false', 'f', '0', 0].includes(value)) { + return false; + } + + throw new Error( + 'Invalid boolean input. Expected "t","1","true","false","0","f"' + ); + } + case 'integer': + case 'biginteger': + case 'float': + case 'decimal': { + return _.toNumber(value); + } + case 'time': { + return parseTime(value); + } + case 'date': { + return parseDate(value); + } + case 'timestamp': + case 'datetime': { + return parseDateTimeOrTimestamp(value); + } + default: + return value; + } +}; + +module.exports = parseType; diff --git a/packages/strapi-utils/package.json b/packages/strapi-utils/package.json index 404c5e5294..1ec717a638 100644 --- a/packages/strapi-utils/package.json +++ b/packages/strapi-utils/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "commander": "^2.20.0", + "date-fns": "^2.8.1", "joi-json": "^2.1.0", "knex": "^0.16.5", "lodash": "^4.17.11", diff --git a/packages/strapi/bin/strapi.js b/packages/strapi/bin/strapi.js index 7db7e17bf9..e106b1180b 100755 --- a/packages/strapi/bin/strapi.js +++ b/packages/strapi/bin/strapi.js @@ -144,6 +144,7 @@ program .option('-a, --api ', 'API name to generate a sub API') .option('-p, --plugin ', 'plugin name') .option('-t, --tpl