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,
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`;

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();
table.string('component_type').notNullable();
table.integer('component_id').notNullable();
table.integer(joinColumn).notNullable();
table
.integer(joinColumn)
.unsigned()
.notNullable();
table
.foreign(joinColumn)

View File

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

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",
"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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 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,

View File

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

View File

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

View File

@ -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 => {

View File

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

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
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,

View File

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

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": {
"commander": "^2.20.0",
"date-fns": "^2.8.1",
"joi-json": "^2.1.0",
"knex": "^0.16.5",
"lodash": "^4.17.11",

View File

@ -144,6 +144,7 @@ program
.option('-a, --api <api>', 'API name to generate a sub API')
.option('-p, --plugin <api>', 'plugin 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')
.action((id, attributes, cliArguments) => {
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"
integrity sha512-lcWy3AXDRJOD7MplwZMmNSRM//kZtJaLz4n6D1P5z9wEmZGBKhJRBIr1Xs9KNQJmdXPblvgffynYji4iylUTcA==
date-fns@^1.27.2, date-fns@^1.30.1:
date-fns@^1.27.2:
version "1.30.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
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:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"