Merge branch 'features/dynamic-zones' of github.com:strapi/strapi into front/dynamic-zones-ctb-listview

This commit is contained in:
soupette 2019-12-06 08:59:27 +01:00
commit 7b6a24c99e
26 changed files with 840 additions and 377 deletions

View File

@ -9,6 +9,95 @@ module.exports = async ({
connection, connection,
model, 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; const { hasTimestamps } = loadedModel;
let [createAtCol, updatedAtCol] = ['created_at', 'updated_at']; let [createAtCol, updatedAtCol] = ['created_at', 'updated_at'];
@ -83,15 +172,9 @@ module.exports = async ({
Object.keys(columns).forEach(key => { Object.keys(columns).forEach(key => {
const attribute = columns[key]; const attribute = columns[key];
const type = getType({
definition,
attribute,
name: key,
tableExists,
});
if (type) { const col = buildColType({ name: key, attribute, table: tbl });
const col = tbl.specificType(key, type); if (!col) return;
if (attribute.required === true) { if (attribute.required === true) {
if (definition.client !== 'sqlite3' || !tableExists) { if (definition.client !== 'sqlite3' || !tableExists) {
@ -110,7 +193,6 @@ module.exports = async ({
if (alter) { if (alter) {
col.alter(); col.alter();
} }
}
}); });
}; };
@ -124,7 +206,7 @@ module.exports = async ({
const createTable = (table, { trx = ORM.knex, ...opts } = {}) => { const createTable = (table, { trx = ORM.knex, ...opts } = {}) => {
return trx.schema.createTable(table, tbl => { return trx.schema.createTable(table, tbl => {
tbl.specificType('id', getIdType(definition)); createIdType(tbl, definition);
createColumns(tbl, attributes, { ...opts, tableExists: false }); createColumns(tbl, attributes, { ...opts, tableExists: false });
}); });
}; };
@ -201,7 +283,7 @@ module.exports = async ({
await createTable(table, { trx }); await createTable(table, { trx });
const attrs = Object.keys(attributes).filter(attribute => const attrs = Object.keys(attributes).filter(attribute =>
getType({ isColumn({
definition, definition,
attribute: attributes[attribute], attribute: attributes[attribute],
name: attribute, name: attribute,
@ -283,15 +365,22 @@ module.exports = async ({
// Add created_at and updated_at field if timestamp option is true // Add created_at and updated_at field if timestamp option is true
if (hasTimestamps) { if (hasTimestamps) {
definition.attributes[createAtCol] = { definition.attributes[createAtCol] = {
type: 'timestamp', type: 'currentTimestamp',
}; };
definition.attributes[updatedAtCol] = { definition.attributes[updatedAtCol] = {
type: 'timestampUpdate', type: 'currentTimestamp',
}; };
} }
// Save all attributes (with timestamps) // Save all attributes (with timestamps) and right type
model.allAttributes = _.clone(definition.attributes); model.allAttributes = _.assign(_.clone(definition.attributes), {
[createAtCol]: {
type: 'timestamp',
},
[updatedAtCol]: {
type: 'timestamp',
},
});
// Equilize tables // Equilize tables
if (connection.options && connection.options.autoMigration !== false) { if (connection.options && connection.options.autoMigration !== false) {
@ -369,79 +458,26 @@ module.exports = async ({
} }
}; };
const getType = ({ definition, attribute, name, tableExists = false }) => { const isColumn = ({ definition, attribute, name }) => {
const { client } = definition; if (!_.has(attribute, 'type')) {
if (!attribute.type) {
// Add integer value if there is a relation
const relation = definition.associations.find(association => { const relation = definition.associations.find(association => {
return association.alias === name; return association.alias === name;
}); });
switch (relation.nature) { if (!relation) return false;
case 'oneToOne':
case 'manyToOne': if (['oneToOne', 'manyToOne', 'oneWay'].includes(relation.nature)) {
case 'oneWay': return true;
return definition.primaryKeyType;
default:
return null;
}
} }
switch (attribute.type) { return false;
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'; if (['component', 'dynamiczone'].includes(attribute.type)) {
case 'timestamp': return false;
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:
} }
return true;
}; };
const storeTable = async (table, attributes) => { const storeTable = async (table, attributes) => {
@ -464,29 +500,4 @@ const storeTable = async (table, attributes) => {
}).save(); }).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`; const uniqueColName = (table, key) => `${table}_${key}_unique`;

View File

@ -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,
};

View File

@ -78,7 +78,10 @@ const createComponentJoinTables = async ({ definition, ORM }) => {
.notNullable(); .notNullable();
table.string('component_type').notNullable(); table.string('component_type').notNullable();
table.integer('component_id').notNullable(); table.integer('component_id').notNullable();
table.integer(joinColumn).notNullable(); table
.integer(joinColumn)
.unsigned()
.notNullable();
table table
.foreign(joinColumn) .foreign(joinColumn)

View File

@ -1,7 +1,6 @@
'use strict'; 'use strict';
const _ = require('lodash'); const _ = require('lodash');
const { singular } = require('pluralize'); const { singular } = require('pluralize');
const dateFns = require('date-fns');
const utilsModels = require('strapi-utils').models; const utilsModels = require('strapi-utils').models;
const relations = require('./relations'); const relations = require('./relations');
@ -10,6 +9,8 @@ const {
createComponentJoinTables, createComponentJoinTables,
createComponentModels, createComponentModels,
} = require('./generate-component-relations'); } = require('./generate-component-relations');
const { createParser } = require('./parser');
const { createFormatter } = require('./formatter');
const populateFetch = require('./populate'); const populateFetch = require('./populate');
@ -457,12 +458,14 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
// Call this callback function after we are done parsing // Call this callback function after we are done parsing
// all attributes for relationships-- see below. // all attributes for relationships-- see below.
const parseValue = createParser();
try { try {
// External function to map key that has been updated with `columnName` // External function to map key that has been updated with `columnName`
const mapper = (params = {}) => { const mapper = (params = {}) => {
Object.keys(params).map(key => { Object.keys(params).map(key => {
const attr = definition.attributes[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) => { return _.mapKeys(params, (value, key) => {
@ -611,18 +614,30 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
: Promise.resolve(); : Promise.resolve();
}); });
//eslint-disable-next-line this.on('saving', (instance, attrs) => {
this.on('saving', (instance, attrs, options) => { instance.attributes = _.assign(instance.attributes, mapper(attrs));
instance.attributes = mapper(instance.attributes);
attrs = mapper(attrs);
return _.isFunction(target[model.toLowerCase()]['beforeSave']) return _.isFunction(target[model.toLowerCase()]['beforeSave'])
? target[model.toLowerCase()]['beforeSave'] ? target[model.toLowerCase()]['beforeSave']
: Promise.resolve(); : Promise.resolve();
}); });
// Convert to JSON format stringify json for mysql database const formatValue = createFormatter(definition.client);
if (definition.client === 'mysql' || definition.client === 'sqlite3') { 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 = [ const events = [
{ {
name: 'saved', name: 'saved',
@ -638,65 +653,15 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
}, },
]; ];
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 => { 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 => { this.on(event.name, instance => {
fn(instance); formatOutput(instance);
return _.isFunction(target[model.toLowerCase()][event.target]) return _.isFunction(target[model.toLowerCase()][event.target])
? target[model.toLowerCase()][event.target] ? target[model.toLowerCase()][event.target]
: Promise.resolve(); : Promise.resolve();
}); });
}); });
}
}; };
loadedModel.hidden = _.keys( loadedModel.hidden = _.keys(
@ -748,34 +713,3 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
return Promise.all(updates); 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;
}
};

View File

@ -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 };

View File

@ -17,7 +17,7 @@
"main": "./lib", "main": "./lib",
"dependencies": { "dependencies": {
"bookshelf": "^1.0.1", "bookshelf": "^1.0.1",
"date-fns": "^1.30.1", "date-fns": "^2.8.1",
"inquirer": "^6.3.1", "inquirer": "^6.3.1",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"pluralize": "^7.0.0", "pluralize": "^7.0.0",

View File

@ -71,7 +71,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
definition.loadedModel[name] = { definition.loadedModel[name] = {
...attr, ...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', type: 'timestamp',
}; };
target[model].allAttributes[updatedAtCol] = { target[model].allAttributes[updatedAtCol] = {
type: 'timestampUpdate', type: 'timestamp',
}; };
} else if (timestampsOption === true) { } else if (timestampsOption === true) {
schema.set('timestamps', true); schema.set('timestamps', true);
@ -214,7 +214,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
type: 'timestamp', type: 'timestamp',
}; };
target[model].allAttributes.updatedAt = { target[model].allAttributes.updatedAt = {
type: 'timestampUpdate', type: 'timestamp',
}; };
} }
schema.set( schema.set(

View File

@ -2,6 +2,7 @@
const _ = require('lodash'); const _ = require('lodash');
const Mongoose = require('mongoose'); const Mongoose = require('mongoose');
const { parseType } = require('strapi-utils');
/** /**
* Module dependencies * Module dependencies
@ -29,35 +30,50 @@ module.exports = (mongoose = Mongoose) => {
const convertType = mongooseType => { const convertType = mongooseType => {
switch (mongooseType.toLowerCase()) { switch (mongooseType.toLowerCase()) {
case 'array': case 'array':
return Array; return { type: Array };
case 'boolean': case 'boolean':
return 'Boolean'; return { type: 'Boolean' };
case 'binary': case 'binary':
return 'Buffer'; return { type: 'Buffer' };
case 'date':
case 'datetime':
case 'time': 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': case 'timestamp':
return Date; return {
type: Date,
};
case 'decimal': case 'decimal':
return 'Decimal'; return { type: 'Decimal' };
case 'float': case 'float':
return 'Float'; return { type: 'Float' };
case 'json': case 'json':
return 'Mixed'; return { type: 'Mixed' };
case 'biginteger': case 'biginteger':
return 'Long'; return { type: 'Long' };
case 'integer': case 'integer':
return 'Number'; return { type: 'Number' };
case 'uuid': case 'uuid':
return 'ObjectId'; return { type: 'ObjectId' };
case 'email': case 'email':
case 'enumeration': case 'enumeration':
case 'password': case 'password':
case 'string': case 'string':
case 'text': case 'text':
case 'richtext': case 'richtext':
return 'String'; return { type: 'String' };
default: default:
return undefined; return undefined;
} }

View File

@ -138,6 +138,7 @@ module.exports = (scope, cb) => {
// Get default connection // Get default connection
try { try {
scope.connection = scope.connection =
scope.args.connection ||
JSON.parse( JSON.parse(
fs.readFileSync( fs.readFileSync(
path.resolve( path.resolve(
@ -148,7 +149,8 @@ module.exports = (scope, cb) => {
'database.json' 'database.json'
) )
) )
).defaultConnection || ''; ).defaultConnection ||
'';
} catch (err) { } catch (err) {
return cb.invalid(err); return cb.invalid(err);
} }

View File

@ -16,7 +16,7 @@ const createMockSchema = (attrs, timestamps = true) => {
type: 'timestamp', type: 'timestamp',
}, },
updatedAt: { updatedAt: {
type: 'timestampUpdate', type: 'timestamp',
}, },
} }
: {}), : {}),

View File

@ -15,6 +15,7 @@ const typeToSize = type => {
case 'checkbox': case 'checkbox':
case 'boolean': case 'boolean':
case 'date': case 'date':
case 'time':
case 'biginteger': case 'biginteger':
case 'decimal': case 'decimal':
case 'float': case 'float':

View File

@ -24,14 +24,14 @@ describe('Test type date', () => {
'/content-manager/explorer/application::withdate.withdate', '/content-manager/explorer/application::withdate.withdate',
{ {
body: { body: {
field: '2019-08-08T10:10:57.000Z', field: '2019-08-08',
}, },
} }
); );
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ 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.statusCode).toBe(200);
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
field: now.toISOString(), field: '2019-01-12',
}); });
}); });
test('Create entry with timestamp value should be converted to ISO', async () => { test.each([
const now = new Date(2016, 4, 8); '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( const res = await rq.post(
'/content-manager/explorer/application::withdate.withdate', '/content-manager/explorer/application::withdate.withdate',
{ {
body: { body: {
field: now.getTime(), field: input,
}, },
} }
); );
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
field: now.toISOString(), field: '2019-08-08',
}); });
});
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); test.each([1234567891012, '1234567891012', '2019/12/11', '12:11:11'])(
expect(res.body).toMatchObject({ 'Throws on invalid date (%s)',
field: now.toISOString(), async value => {
});
});
test('Throws on invalid date format', async () => {
const res = await rq.post( const res = await rq.post(
'/content-manager/explorer/application::withdate.withdate', '/content-manager/explorer/application::withdate.withdate',
{ {
body: { body: {
field: 'azdazindoaizdnoainzd', field: value,
}, },
} }
); );
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); }
);
test('Reading entry, returns correct value', async () => { test('Reading entry, returns correct value', async () => {
const res = await rq.get( const res = await rq.get(
@ -110,7 +105,9 @@ describe('Test type date', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(Array.isArray(res.body)).toBe(true); expect(Array.isArray(res.body)).toBe(true);
res.body.forEach(entry => { 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', '/content-manager/explorer/application::withdate.withdate',
{ {
body: { body: {
field: now.getTime(), field: now,
}, },
} }
); );
@ -139,7 +136,7 @@ describe('Test type date', () => {
expect(updateRes.statusCode).toBe(200); expect(updateRes.statusCode).toBe(200);
expect(updateRes.body).toMatchObject({ expect(updateRes.body).toMatchObject({
id: res.body.id, id: res.body.id,
field: newDate.toISOString(), field: '2017-11-23',
}); });
}); });
}); });

View File

@ -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(),
});
});
});

View File

@ -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',
});
});
});

View File

@ -6,31 +6,10 @@ const yup = require('yup');
const { isValidName, isValidIcon } = require('./common'); const { isValidName, isValidIcon } = require('./common');
const formatYupErrors = require('./yup-formatter'); const formatYupErrors = require('./yup-formatter');
const createSchema = require('./model-schema'); const createSchema = require('./model-schema');
const { modelTypes } = require('./constants'); const { modelTypes, DEFAULT_TYPES } = require('./constants');
const VALID_RELATIONS = ['oneWay', 'manyWay']; const VALID_RELATIONS = ['oneWay', 'manyWay'];
const VALID_TYPES = [ const VALID_TYPES = [...DEFAULT_TYPES, 'component'];
// advanced types
'media',
// scalar types
'string',
'text',
'richtext',
'json',
'enumeration',
'password',
'email',
'integer',
'biginteger',
'float',
'decimal',
'date',
'boolean',
// nested component
'component',
];
const componentSchema = createSchema(VALID_TYPES, VALID_RELATIONS, { const componentSchema = createSchema(VALID_TYPES, VALID_RELATIONS, {
modelType: modelTypes.COMPONENT, modelType: modelTypes.COMPONENT,

View File

@ -3,7 +3,31 @@
const CONTENT_TYPE = 'CONTENT_TYPE'; const CONTENT_TYPE = 'CONTENT_TYPE';
const COMPONENT = 'COMPONENT'; 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 = { module.exports = {
DEFAULT_TYPES,
modelTypes: { modelTypes: {
CONTENT_TYPE, CONTENT_TYPE,
COMPONENT, COMPONENT,

View File

@ -6,7 +6,7 @@ const formatYupErrors = require('./yup-formatter');
const createSchema = require('./model-schema'); const createSchema = require('./model-schema');
const { nestedComponentSchema } = require('./component'); const { nestedComponentSchema } = require('./component');
const { modelTypes } = require('./constants'); const { modelTypes, DEFAULT_TYPES } = require('./constants');
const VALID_RELATIONS = [ const VALID_RELATIONS = [
'oneWay', 'oneWay',
@ -16,29 +16,7 @@ const VALID_RELATIONS = [
'manyToOne', 'manyToOne',
'manyToMany', 'manyToMany',
]; ];
const VALID_TYPES = [ const VALID_TYPES = [...DEFAULT_TYPES, 'component', 'dynamiczone'];
// advanced types
'media',
// scalar types
'string',
'text',
'richtext',
'json',
'enumeration',
'password',
'email',
'integer',
'biginteger',
'float',
'decimal',
'date',
'boolean',
// nested component
'component',
'dynamiczone',
];
const contentTypeSchema = createSchema(VALID_TYPES, VALID_RELATIONS, { const contentTypeSchema = createSchema(VALID_TYPES, VALID_RELATIONS, {
modelType: modelTypes.CONTENT_TYPE, modelType: modelTypes.CONTENT_TYPE,

View File

@ -80,6 +80,10 @@ module.exports = function createComponentBuilder() {
.set('collectionName', infos.collectionName || defaultCollectionName) .set('collectionName', infos.collectionName || defaultCollectionName)
.set(['info', 'name'], infos.name) .set(['info', 'name'], infos.name)
.set(['info', 'description'], infos.description) .set(['info', 'description'], infos.description)
.set('options', {
increments: true,
timestamps: true,
})
.set('attributes', this.convertAttributes(infos.attributes)); .set('attributes', this.convertAttributes(infos.attributes));
Object.keys(infos.attributes).forEach(key => { Object.keys(infos.attributes).forEach(key => {

View File

@ -481,7 +481,7 @@ const formatModelConnectionsGQL = function(fields, model, name, modelResolver) {
groupBy: `${globalId}GroupBy`, groupBy: `${globalId}GroupBy`,
aggregate: `${globalId}Aggregator`, aggregate: `${globalId}Aggregator`,
}; };
const pluralName = pluralize.plural(name); const pluralName = pluralize.plural(_.camelCase(name));
let modelConnectionTypes = `type ${connectionGlobalId} {${Schema.formatGQL( let modelConnectionTypes = `type ${connectionGlobalId} {${Schema.formatGQL(
connectionFields connectionFields

View File

@ -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);
});
});
});

View File

@ -1,6 +1,7 @@
//TODO: move to dbal //TODO: move to dbal
const _ = require('lodash'); const _ = require('lodash');
const parseType = require('./parse-type');
const findModelByAssoc = assoc => { const findModelByAssoc = assoc => {
const { models } = assoc.plugin ? strapi.plugins[assoc.plugin] : strapi; 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 {Object} options - Options
* @param {string} options.type - type of the atribute * @param {string} options.type - type of the atribute
* @param {*} options.value - value tu cast * @param {*} options.value - value tu cast
* @param {string} options.operator - name of operator
*/ */
const castValueToType = ({ type, value }) => { const castInput = ({ type, value, operator }) => {
switch (type) { return Array.isArray(value)
case 'boolean': { ? value.map(val => castValue({ type, operator, value: val }))
if (['true', 't', '1', 1, true].includes(value)) { : castValue({ type, operator, value: 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;
}
}; };
/** /**
@ -95,8 +79,8 @@ const castValueToType = ({ type, value }) => {
* @param {string} options.operator - name of operator * @param {string} options.operator - name of operator
*/ */
const castValue = ({ type, value, operator }) => { const castValue = ({ type, value, operator }) => {
if (operator === 'null') return castValueToType({ type: 'boolean', value }); if (operator === 'null') return parseType({ type: 'boolean', value });
return castValueToType({ type, value }); return parseType({ type, value });
}; };
/** /**
* *
@ -126,12 +110,10 @@ const buildQuery = ({ model, filters = {}, ...rest }) => {
field, field,
}); });
const { type } = _.get(assocModel, ['attributes', attribute], {}); const { type } = _.get(assocModel, ['allAttributes', attribute], {});
// cast value or array of values // cast value or array of values
const castedValue = Array.isArray(value) const castedValue = castInput({ type, operator, value });
? value.map(val => castValue({ type, operator, value: val }))
: castValue({ type, operator, value: value });
return { return {
field: field === 'id' ? model.primaryKey : field, field: field === 'id' ? model.primaryKey : field,

View File

@ -8,6 +8,7 @@ const convertRestQueryParams = require('./convertRestQueryParams');
const buildQuery = require('./buildQuery'); const buildQuery = require('./buildQuery');
const parseMultipartData = require('./parse-multipart'); const parseMultipartData = require('./parse-multipart');
const sanitizeEntity = require('./sanitize-entity'); const sanitizeEntity = require('./sanitize-entity');
const parseType = require('./parse-type');
module.exports = { module.exports = {
cli: require('./cli'), cli: require('./cli'),
@ -25,4 +26,5 @@ module.exports = {
buildQuery, buildQuery,
parseMultipartData, parseMultipartData,
sanitizeEntity, sanitizeEntity,
parseType,
}; };

View File

@ -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;

View File

@ -19,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"commander": "^2.20.0", "commander": "^2.20.0",
"date-fns": "^2.8.1",
"joi-json": "^2.1.0", "joi-json": "^2.1.0",
"knex": "^0.16.5", "knex": "^0.16.5",
"lodash": "^4.17.11", "lodash": "^4.17.11",

View File

@ -144,6 +144,7 @@ program
.option('-a, --api <api>', 'API name to generate a sub API') .option('-a, --api <api>', 'API name to generate a sub API')
.option('-p, --plugin <api>', 'plugin name') .option('-p, --plugin <api>', 'plugin name')
.option('-t, --tpl <template>', 'template name') .option('-t, --tpl <template>', 'template name')
.option('-c, --connection <connection>', 'The name of the connection to use')
.description('generate a model for an API') .description('generate a model for an API')
.action((id, attributes, cliArguments) => { .action((id, attributes, cliArguments) => {
cliArguments.attributes = attributes; cliArguments.attributes = attributes;

View File

@ -5954,11 +5954,16 @@ date-and-time@^0.6.3:
resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-0.6.3.tgz#2daee52df67c28bd93bce862756ac86b68cf4237" resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-0.6.3.tgz#2daee52df67c28bd93bce862756ac86b68cf4237"
integrity sha512-lcWy3AXDRJOD7MplwZMmNSRM//kZtJaLz4n6D1P5z9wEmZGBKhJRBIr1Xs9KNQJmdXPblvgffynYji4iylUTcA== integrity sha512-lcWy3AXDRJOD7MplwZMmNSRM//kZtJaLz4n6D1P5z9wEmZGBKhJRBIr1Xs9KNQJmdXPblvgffynYji4iylUTcA==
date-fns@^1.27.2, date-fns@^1.30.1: date-fns@^1.27.2:
version "1.30.1" version "1.30.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
date-fns@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.8.1.tgz#2109362ccb6c87c3ca011e9e31f702bc09e4123b"
integrity sha512-EL/C8IHvYRwAHYgFRse4MGAPSqlJVlOrhVYZ75iQBKrnv+ZedmYsgwH3t+BCDuZDXpoo07+q9j4qgSSOa7irJg==
date-now@^0.1.4: date-now@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"