feat(plugin-cm): add unique option for base fields (#745)

* feat(plugin-cm): add unique option for base fields

* refactor(plugin-cm): make sure unique constraint sync with field option

* fix(plugin-cm): fix sqlite unique field sync

* fix(plugin-cm): fix unique constraint sync logic

* refactor(plugin-cm): remove unique property for select components

* fix: previous

* fix: test error

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2022-08-20 23:23:13 +08:00 committed by GitHub
parent 7e6a394f73
commit a1dc139cf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 477 additions and 121 deletions

View File

@ -17,7 +17,7 @@ export const checkbox: IField = {
}, },
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps
}, },
filterable: { filterable: {
operators: operators.boolean, operators: operators.boolean,

View File

@ -1,4 +1,4 @@
import { defaultProps, operators } from './properties'; import { defaultProps, operators, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
export const email: IField = { export const email: IField = {
@ -21,6 +21,7 @@ export const email: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
}, },
filterable: { filterable: {
operators: operators.string, operators: operators.string,

View File

@ -1,5 +1,5 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties'; import { defaultProps, operators, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
import { i18n } from '../../i18n'; import { i18n } from '../../i18n';
@ -21,6 +21,7 @@ export const input: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
}, },
filterable: { filterable: {
operators: operators.string, operators: operators.string,

View File

@ -1,4 +1,4 @@
import { defaultProps, operators } from './properties'; import { defaultProps, operators, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
import { i18n } from '../../i18n'; import { i18n } from '../../i18n';
import { registerValidateFormats } from '@formily/core'; import { registerValidateFormats } from '@formily/core';
@ -33,6 +33,7 @@ export const integer: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
}, },
filterable: { filterable: {
operators: operators.number, operators: operators.number,

View File

@ -1,6 +1,4 @@
import { registerValidateRules } from '@formily/core'; import { defaultProps, operators, unique } from './properties';
import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties';
import { IField } from './types'; import { IField } from './types';
import { i18n } from '../../i18n'; import { i18n } from '../../i18n';
@ -27,6 +25,7 @@ export const number: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
'uiSchema.x-component-props.step': { 'uiSchema.x-component-props.step': {
type: 'string', type: 'string',
title: '{{t("Precision")}}', title: '{{t("Precision")}}',

View File

@ -1,5 +1,5 @@
import { i18n } from '../../i18n'; import { i18n } from '../../i18n';
import { defaultProps } from './properties'; import { defaultProps, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
export const password: IField = { export const password: IField = {
@ -21,6 +21,7 @@ export const password: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
}, },
validateSchema(fieldSchema) { validateSchema(fieldSchema) {
return { return {

View File

@ -1,5 +1,5 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties'; import { defaultProps, operators, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
import { i18n } from '../../i18n'; import { i18n } from '../../i18n';
import { registerValidateFormats, registerValidateRules, registerValidateLocale } from '@formily/core'; import { registerValidateFormats, registerValidateRules, registerValidateLocale } from '@formily/core';
@ -71,6 +71,7 @@ export const percent: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
'uiSchema.x-component-props.step': { 'uiSchema.x-component-props.step': {
type: 'string', type: 'string',
title: '{{t("Precision")}}', title: '{{t("Precision")}}',

View File

@ -1,4 +1,4 @@
import { defaultProps, operators } from './properties'; import { defaultProps, operators, unique } from './properties';
import { IField } from './types'; import { IField } from './types';
export const phone: IField = { export const phone: IField = {
@ -24,6 +24,7 @@ export const phone: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
unique,
}, },
filterable: { filterable: {
operators: operators.string, operators: operators.string,

View File

@ -30,6 +30,13 @@ export const type: ISchema = {
], ],
}; };
export const unique = {
type: 'boolean',
'x-content': '{{t("Unique")}}',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
};
export const relationshipType: ISchema = { export const relationshipType: ISchema = {
type: 'string', type: 'string',
title: '{{t("Relationship type")}}', title: '{{t("Relationship type")}}',

View File

@ -234,6 +234,7 @@ export default {
"Custom column name": "自定义列名称", "Custom column name": "自定义列名称",
"Edit description": "编辑描述", "Edit description": "编辑描述",
"Required": "必填", "Required": "必填",
"Unique": "不允许重复",
"Label field": "标签字段", "Label field": "标签字段",
"Default is the ID field": "默认为 ID 字段", "Default is the ID field": "默认为 ID 字段",
"Set default sorting rules": "设置排序规则", "Set default sorting rules": "设置排序规则",

View File

@ -12,6 +12,30 @@ describe('magic-attribute-model', () => {
await db.close(); await db.close();
}); });
it('case 0', async () => {
db.registerModels({ MagicAttributeModel });
const Test = db.collection({
name: 'tests',
model: 'MagicAttributeModel',
fields: [
{ type: 'string', name: 'title' },
{ type: 'json', name: 'options' },
],
});
await db.sync();
const test = await Test.model.create({
title: 'aa',
'x-component-props': { key1: 'val1', arr1: [1, 2, 3], arr2: [4, 5] },
});
test.set('x-component-props', { arr2: [1, 2, 3] });
expect(test.previous('options')['x-component-props']['arr2']).toEqual([4, 5]);
});
it('case 1', async () => { it('case 1', async () => {
db.registerModels({ MagicAttributeModel }); db.registerModels({ MagicAttributeModel });

View File

@ -134,10 +134,8 @@ export class Collection<
} }
private bindFieldEventListener() { private bindFieldEventListener() {
this.on('field.afterAdd', (field: Field) => { this.on('field.afterAdd', (field: Field) => field.bind());
field.bind(); this.on('field.afterRemove', (field: Field) => field.unbind());
});
this.on('field.afterRemove', (field) => field.unbind());
} }
forEachField(callback: (field: Field) => void) { forEachField(callback: (field: Field) => void) {
@ -285,7 +283,7 @@ export class Collection<
this.setField(options.name || name, options); this.setField(options.name || name, options);
} }
addIndex(index: any) { addIndex(index: string | string[] | { fields: string[], unique?: boolean,[key: string]: any }) {
if (!index) { if (!index) {
return; return;
} }
@ -347,22 +345,22 @@ export class Collection<
} }
async sync(syncOptions?: SyncOptions) { async sync(syncOptions?: SyncOptions) {
const modelNames = [this.model.name]; const modelNames = new Set([this.model.name]);
const associations = this.model.associations; const associations = this.model.associations;
for (const associationKey in associations) { for (const associationKey in associations) {
const association = associations[associationKey]; const association = associations[associationKey];
modelNames.push(association.target.name); modelNames.add(association.target.name);
if ((<any>association).through) { if ((<any>association).through) {
modelNames.push((<any>association).through.model.name); modelNames.add((<any>association).through.model.name);
} }
} }
const models: ModelCtor<Model>[] = []; const models: ModelCtor<Model>[] = [];
// @ts-ignore // @ts-ignore
this.context.database.sequelize.modelManager.forEachModel((model) => { this.context.database.sequelize.modelManager.forEachModel((model) => {
if (modelNames.includes(model.name)) { if (modelNames.has(model.name)) {
models.push(model); models.push(model);
} }
}); });

View File

@ -179,7 +179,7 @@ export abstract class Field {
unbind() { unbind() {
const { model } = this.context.collection; const { model } = this.context.collection;
model.removeAttribute(this.name); model.removeAttribute(this.name);
if (this.options.index) { if (this.options.index || this.options.unique) {
this.context.collection.removeIndex([this.name]); this.context.collection.removeIndex([this.name]);
} }
} }

View File

@ -1,7 +1,9 @@
import { merge } from '@nocobase/utils'; import { merge } from '@nocobase/utils';
import _ from 'lodash'; import _ from 'lodash';
import { Utils } from 'sequelize';
import Database from './database'; import Database from './database';
import { Model } from './model'; import { Model } from './model';
const Dottie = require('dottie');
export class MagicAttributeModel extends Model { export class MagicAttributeModel extends Model {
get magicAttribute() { get magicAttribute() {
@ -14,25 +16,205 @@ export class MagicAttributeModel extends Model {
if (typeof key === 'string') { if (typeof key === 'string') {
const [column] = key.split('.'); const [column] = key.split('.');
if ((this.constructor as any).hasAlias(column)) { if ((this.constructor as any).hasAlias(column)) {
return super.set(key, value, options); return this.setV1(key, value, options);
} }
if ((this.constructor as any).rawAttributes[column]) { if ((this.constructor as any).rawAttributes[column]) {
return super.set(key, value, options); return this.setV1(key, value, options);
} }
if (_.isPlainObject(value)) { if (_.isPlainObject(value)) {
const opts = super.get(this.magicAttribute) || {}; const opts = super.get(this.magicAttribute) || {};
return super.set(`${this.magicAttribute}.${key}`, merge(opts?.[key], value), options); return this.setV1(`${this.magicAttribute}.${key}`, merge(opts?.[key], value), options);
} }
return super.set(`${this.magicAttribute}.${key}`, value, options); return this.setV1(`${this.magicAttribute}.${key}`, value, options);
} else { } else {
if (!key) { if (!key) {
return; return;
} }
Object.keys(key).forEach((k) => { Object.keys(key).forEach((k) => {
this.set(k, key[k], options); this.setV1(k, key[k], options);
}); });
} }
return super.set(key, value, options); return this.setV1(key, value, options);
}
setV1(key?: any, value?: any, options?: any) {
let values;
let originalValue;
if (typeof key === 'object' && key !== null) {
values = key;
options = value || {};
if (options.reset) {
// @ts-ignore
this.dataValues = {};
for (const key in values) {
this.changed<any>(key, false);
}
}
// If raw, and we're not dealing with includes or special attributes, just set it straight on the dataValues object
// @ts-ignore
if (
options.raw &&
// @ts-ignore
!(this._options && this._options.include) &&
!(options && options.attributes) &&
// @ts-ignore
!this.constructor._hasDateAttributes &&
// @ts-ignore
!this.constructor._hasBooleanAttributes
) {
// @ts-ignore
if (Object.keys(this.dataValues).length) {
// @ts-ignore
Object.assign(this.dataValues, values);
} else {
// @ts-ignore
this.dataValues = values;
}
// If raw, .changed() shouldn't be true
// @ts-ignore
this._previousDataValues = { ...this.dataValues };
} else {
// Loop and call set
if (options.attributes) {
const setKeys = (data) => {
for (const k of data) {
if (values[k] === undefined) {
continue;
}
this.set(k, values[k], options);
}
};
setKeys(options.attributes);
// @ts-ignore
if (this.constructor._hasVirtualAttributes) {
// @ts-ignore
setKeys(this.constructor._virtualAttributes);
}
// @ts-ignore
if (this._options.includeNames) {
// @ts-ignore
setKeys(this._options.includeNames);
}
} else {
for (const key in values) {
this.set(key, values[key], options);
}
}
if (options.raw) {
// If raw, .changed() shouldn't be true
// @ts-ignore
this._previousDataValues = { ...this.dataValues };
}
}
return this;
}
if (!options) options = {};
if (!options.raw) {
// @ts-ignore
originalValue = this.dataValues[key];
}
// If not raw, and there's a custom setter
// @ts-ignore
if (!options.raw && this._customSetters[key]) {
// @ts-ignore
this._customSetters[key].call(this, value, key);
// custom setter should have changed value, get that changed value
// TODO: v5 make setters return new value instead of changing internal store
// @ts-ignore
const newValue = this.dataValues[key];
if (!_.isEqual(newValue, originalValue)) {
// @ts-ignore
this._previousDataValues[key] = originalValue;
this.changed(key, true);
}
} else {
// Check if we have included models, and if this key matches the include model names/aliases
// @ts-ignore
if (this._options && this._options.include && this._options.includeNames.includes(key)) {
// Pass it on to the include handler
// @ts-ignore
this._setInclude(key, value, options);
return this;
}
// Bunch of stuff we won't do when it's raw
if (!options.raw) {
// If attribute is not in model definition, return
// @ts-ignore
if (!this._isAttribute(key)) {
// @ts-ignore
if (key.includes('.') && this.constructor._jsonAttributes.has(key.split('.')[0])) {
// @ts-ignore
const previousNestedValue = Dottie.get(this.dataValues, key);
if (!_.isEqual(previousNestedValue, value)) {
// @ts-ignore
this._previousDataValues = _.cloneDeep(this._previousDataValues);
// @ts-ignore
Dottie.set(this.dataValues, key, value);
this.changed(key.split('.')[0], true);
}
}
return this;
}
// If attempting to set primary key and primary key is already defined, return
// @ts-ignore
if (this.constructor._hasPrimaryKeys && originalValue && this.constructor._isPrimaryKey(key)) {
return this;
}
// If attempting to set read only attributes, return
// @ts-ignore
if (
!this.isNewRecord &&
// @ts-ignore
this.constructor._hasReadOnlyAttributes &&
// @ts-ignore
this.constructor._readOnlyAttributes.has(key)
) {
return this;
}
}
// If there's a data type sanitizer
if (
!(value instanceof Utils.SequelizeMethod) &&
// @ts-ignore
Object.prototype.hasOwnProperty.call(this.constructor._dataTypeSanitizers, key)
) {
// @ts-ignore
value = this.constructor._dataTypeSanitizers[key].call(this, value, options);
}
// Set when the value has changed and not raw
if (
!options.raw &&
// True when sequelize method
(value instanceof Utils.SequelizeMethod ||
// Check for data type type comparators
// @ts-ignore
(!(value instanceof Utils.SequelizeMethod) &&
// @ts-ignore
this.constructor._dataTypeChanges[key] &&
// @ts-ignore
this.constructor._dataTypeChanges[key].call(this, value, originalValue, options)) || // Check default
// @ts-ignore
(!this.constructor._dataTypeChanges[key] && !_.isEqual(value, originalValue)))
) {
// @ts-ignore
this._previousDataValues[key] = originalValue;
this.changed(key, true);
}
// set data value
// @ts-ignore
this.dataValues[key] = value;
}
return this;
} }
get(key?: any, value?: any): any { get(key?: any, value?: any): any {

View File

@ -43,7 +43,7 @@ export function transactionWrapperBuilder(transactionGenerator) {
return results; return results;
} catch (err) { } catch (err) {
console.error({ err }); // console.error({ err });
await transaction.rollback(); await transaction.rollback();
throw err; throw err;
} }

View File

@ -1,4 +1,5 @@
import PluginUsers from '@nocobase/plugin-users'; import PluginUsers from '@nocobase/plugin-users';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginCollectionManager from '@nocobase/plugin-collection-manager'; import PluginCollectionManager from '@nocobase/plugin-collection-manager';
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage'; import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
import { mockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
@ -15,6 +16,7 @@ export async function prepareApp() {
app.plugin(PluginUsers); app.plugin(PluginUsers);
app.plugin(PluginUiSchema); app.plugin(PluginUiSchema);
app.plugin(PluginErrorHandler);
app.plugin(PluginCollectionManager); app.plugin(PluginCollectionManager);
app.plugin(PluginACL); app.plugin(PluginACL);

View File

@ -9,6 +9,12 @@
"url": "http://www.apache.org/licenses/LICENSE-2.0" "url": "http://www.apache.org/licenses/LICENSE-2.0"
} }
], ],
"dependencies": {
"@nocobase/database": "0.7.4-alpha.4",
"@nocobase/plugin-error-handler": "0.7.4-alpha.4",
"@nocobase/server": "0.7.4-alpha.4",
"sequelize": "^6.9.0"
},
"devDependencies": { "devDependencies": {
"@nocobase/test": "0.7.4-alpha.7" "@nocobase/test": "0.7.4-alpha.7"
}, },

View File

@ -2,19 +2,16 @@ import { Database } from '@nocobase/database';
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer, MockServer } from '@nocobase/test';
import CollectionManagerPlugin from '@nocobase/plugin-collection-manager'; import CollectionManagerPlugin from '@nocobase/plugin-collection-manager';
import { UiSchemaStoragePlugin } from '@nocobase/plugin-ui-schema-storage'; import { UiSchemaStoragePlugin } from '@nocobase/plugin-ui-schema-storage';
import { createApp } from '.';
describe('action test', () => { describe('action test', () => {
let db: Database; let db: Database;
let app: MockServer; let app: MockServer;
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = await createApp();
app.plugin(CollectionManagerPlugin); await app.install({ clean: true });
app.plugin(UiSchemaStoragePlugin);
db = app.db; db = app.db;
await db.clean({ drop: true });
await app.loadAndInstall();
}); });
afterEach(async () => { afterEach(async () => {

View File

@ -0,0 +1,96 @@
import { MockServer } from '@nocobase/test';
import { createApp } from '..';
describe('field indexes', () => {
let app: MockServer;
let agent;
beforeEach(async () => {
app = await createApp();
await app.install({ clean: true });
await app.start();
agent = app.agent();
await agent
.resource('collections')
.create({
values: {
name: 'test1',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('field value cannot be duplicated with unique index', async () => {
const tableName = 'test1';
// create an field with unique constraint
const field = await agent
.resource('collections.fields', tableName)
.create({
values: {
name: 'title',
type: 'string',
unique: true
},
});
// create a record
const response1 = await agent.resource(tableName).create({
values: {
title: 't1'
}
});
expect(response1.status).toBe(200);
expect(response1.body.data.title).toBe('t1');
// create another record with the same value on unique field should fail
const response2 = await agent.resource(tableName).create({
values: {
title: 't1'
}
});
expect(response2.status).toBe(400);
// update field to remove unique constraint
await agent.resource('fields').update({
filterByTk: field.id,
values: {
unique: false
}
});
// create another record with the same value on unique field should be ok
const response3 = await agent.resource(tableName).create({
values: {
title: 't1'
}
});
expect(response3.status).toBe(200);
expect(response3.body.data.title).toBe('t1');
// update field to add unique constraint should fail because of duplicated records
const response4 = await agent.resource('fields').update({
filterByTk: field.id,
values: {
unique: true
}
});
expect(response4.status).toBe(400);
// remove a duplicated record
await agent.resource(tableName).destroy({
filterByTk: response3.body.data.id
});
// update field to add unique constraint should be ok
const response6 = await agent.resource('fields').update({
filterByTk: field.id,
values: {
unique: true
}
});
expect(response6.status).toBe(200);
});
});

View File

@ -3,13 +3,14 @@ import { createApp } from '..';
describe('collections repository', () => { describe('collections repository', () => {
let app: MockServer; let app: MockServer;
let agent;
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
agent = app.agent();
await app.install({ clean: true }); await app.install({ clean: true });
await app.start(); await app.start();
await app await agent
.agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
@ -22,8 +23,7 @@ describe('collections repository', () => {
], ],
}, },
}); });
await app await agent
.agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
@ -36,8 +36,7 @@ describe('collections repository', () => {
], ],
}, },
}); });
await app await agent
.agent()
.resource('collections.fields', 'tags') .resource('collections.fields', 'tags')
.create({ .create({
values: { values: {
@ -46,8 +45,7 @@ describe('collections repository', () => {
type: 'belongsToMany', type: 'belongsToMany',
}, },
}); });
await app await agent
.agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
@ -60,8 +58,7 @@ describe('collections repository', () => {
], ],
}, },
}); });
await app await agent
.agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
@ -100,24 +97,21 @@ describe('collections repository', () => {
it('case 2', async () => { it('case 2', async () => {
const response = await app.agent().resource('posts').create(); const response = await app.agent().resource('posts').create();
const postId = response.body.data.id; const postId = response.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 1', title: 'comment 1',
}, },
}); });
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 2', title: 'comment 2',
}, },
}); });
const response2 = await app const response2 = await agent
.agent()
.resource('posts') .resource('posts')
.list({ .list({
filter: { filter: {
@ -130,16 +124,14 @@ describe('collections repository', () => {
it('case 3', async () => { it('case 3', async () => {
const response = await app.agent().resource('posts').create(); const response = await app.agent().resource('posts').create();
const postId = response.body.data.id; const postId = response.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 1', title: 'comment 1',
}, },
}); });
const response2 = await app const response2 = await agent
.agent()
.resource('posts') .resource('posts')
.list({ .list({
filter: { filter: {
@ -152,16 +144,14 @@ describe('collections repository', () => {
it('case 4', async () => { it('case 4', async () => {
const response = await app.agent().resource('posts').create(); const response = await app.agent().resource('posts').create();
const postId = response.body.data.id; const postId = response.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 1', title: 'comment 1',
}, },
}); });
const response2 = await app const response2 = await agent
.agent()
.resource('posts') .resource('posts')
.list({ .list({
filter: { filter: {
@ -186,8 +176,7 @@ describe('collections repository', () => {
}); });
it('case 6', async () => { it('case 6', async () => {
const response = await app const response = await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -205,8 +194,7 @@ describe('collections repository', () => {
}); });
it('case 7', async () => { it('case 7', async () => {
const response = await app const response = await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -219,8 +207,7 @@ describe('collections repository', () => {
}, },
}); });
const postId = response.body.data.id; const postId = response.body.data.id;
const response1 = await app const response1 = await agent
.agent()
.resource('posts.tags', postId) .resource('posts.tags', postId)
.list({ .list({
filter: { filter: {
@ -231,8 +218,7 @@ describe('collections repository', () => {
}); });
it('case 8', async () => { it('case 8', async () => {
const response = await app const response = await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -248,8 +234,7 @@ describe('collections repository', () => {
}, },
}); });
const postId = response.body.data.id; const postId = response.body.data.id;
const response1 = await app const response1 = await agent
.agent()
.resource('posts.tags', postId) .resource('posts.tags', postId)
.list({ .list({
filter: { filter: {
@ -260,8 +245,7 @@ describe('collections repository', () => {
}); });
it('case 9', async () => { it('case 9', async () => {
const response = await app const response = await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -277,8 +261,7 @@ describe('collections repository', () => {
}, },
}); });
const postId = response.body.data.id; const postId = response.body.data.id;
const response1 = await app const response1 = await agent
.agent()
.resource('posts.tags', postId) .resource('posts.tags', postId)
.list({ .list({
filter: { filter: {
@ -289,8 +272,7 @@ describe('collections repository', () => {
}); });
it('case 10', async () => { it('case 10', async () => {
const response = await app const response = await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -306,8 +288,7 @@ describe('collections repository', () => {
}, },
}); });
const postId = response.body.data.id; const postId = response.body.data.id;
const response1 = await app const response1 = await agent
.agent()
.resource('posts.tags', postId) .resource('posts.tags', postId)
.list({ .list({
appends: ['foos'], appends: ['foos'],
@ -322,16 +303,14 @@ describe('collections repository', () => {
it('case 11', async () => { it('case 11', async () => {
const response = await app.agent().resource('posts').create(); const response = await app.agent().resource('posts').create();
const postId = response.body.data.id; const postId = response.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 1', title: 'comment 1',
}, },
}); });
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
@ -340,24 +319,21 @@ describe('collections repository', () => {
}); });
const response2 = await app.agent().resource('posts').create(); const response2 = await app.agent().resource('posts').create();
const postId2 = response2.body.data.id; const postId2 = response2.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId2) .resource('posts.comments', postId2)
.create({ .create({
values: { values: {
title: 'comment 2', title: 'comment 2',
}, },
}); });
await app await agent
.agent()
.resource('posts.comments', postId2) .resource('posts.comments', postId2)
.create({ .create({
values: { values: {
title: 'comment 2', title: 'comment 2',
}, },
}); });
const response3 = await app const response3 = await agent
.agent()
.resource('posts') .resource('posts')
.list({ .list({
filter: { filter: {
@ -377,32 +353,28 @@ describe('collections repository', () => {
it('case 12', async () => { it('case 12', async () => {
const response = await app.agent().resource('posts').create(); const response = await app.agent().resource('posts').create();
const postId = response.body.data.id; const postId = response.body.data.id;
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 1', title: 'comment 1',
}, },
}); });
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 2', title: 'comment 2',
}, },
}); });
await app await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.create({ .create({
values: { values: {
title: 'comment 3', title: 'comment 3',
}, },
}); });
const response2 = await app const response2 = await agent
.agent()
.resource('posts.comments', postId) .resource('posts.comments', postId)
.list({ .list({
filter: { filter: {
@ -424,24 +396,21 @@ describe('collections repository', () => {
const tag1 = await tagRepository.create({ values: { title: 'tag1' } }); const tag1 = await tagRepository.create({ values: { title: 'tag1' } });
const tag2 = await tagRepository.create({ values: { title: 'tag2' } }); const tag2 = await tagRepository.create({ values: { title: 'tag2' } });
const tag3 = await tagRepository.create({ values: { title: 'tag3' } }); const tag3 = await tagRepository.create({ values: { title: 'tag3' } });
await app await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
tags: [tag1.get('id'), tag3.get('id')], tags: [tag1.get('id'), tag3.get('id')],
}, },
}); });
await app await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
tags: [tag2.get('id')], tags: [tag2.get('id')],
}, },
}); });
await app await agent
.agent()
.resource('posts') .resource('posts')
.create({ .create({
values: { values: {
@ -449,8 +418,7 @@ describe('collections repository', () => {
}, },
}); });
const response1 = await app const response1 = await agent
.agent()
.resource('posts') .resource('posts')
.list({ .list({
filter: { filter: {

View File

@ -1,7 +1,8 @@
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage'; import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
import { mockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
import lodash from 'lodash'; import lodash from 'lodash';
import CollectionManagerPlugin from '../'; import Plugin from '../';
export async function createApp(options = {}) { export async function createApp(options = {}) {
const app = mockServer(); const app = mockServer();
@ -10,7 +11,8 @@ export async function createApp(options = {}) {
await app.cleanDb(); await app.cleanDb();
} }
app.plugin(CollectionManagerPlugin); app.plugin(PluginErrorHandler);
app.plugin(Plugin);
app.plugin(PluginUiSchema); app.plugin(PluginUiSchema);
await app.load(); await app.load();

View File

@ -1,5 +1,6 @@
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import { mockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
import CollectionManagerPlugin from '../server'; import Plugin from '../server';
describe('collections repository', () => { describe('collections repository', () => {
it('case 1', async () => { it('case 1', async () => {
@ -9,7 +10,8 @@ describe('collections repository', () => {
}, },
}); });
await app1.cleanDb(); await app1.cleanDb();
app1.plugin(CollectionManagerPlugin); app1.plugin(PluginErrorHandler);
app1.plugin(Plugin);
await app1.load(); await app1.load();
await app1.install({ clean: true }); await app1.install({ clean: true });
await app1.start(); await app1.start();
@ -119,7 +121,8 @@ describe('collections repository', () => {
tablePrefix: 'through_', tablePrefix: 'through_',
}, },
}); });
app2.plugin(CollectionManagerPlugin); app2.plugin(PluginErrorHandler);
app2.plugin(Plugin);
await app2.load(); await app2.load();
await app2.start(); await app2.start();

View File

@ -1,11 +1,53 @@
import Database, { MagicAttributeModel } from '@nocobase/database'; import Database, { Field, MagicAttributeModel } from '@nocobase/database';
import { SyncOptions, Transactionable } from 'sequelize'; import { SyncOptions, Transactionable, UniqueConstraintError, Utils } from 'sequelize';
interface LoadOptions extends Transactionable { interface LoadOptions extends Transactionable {
// TODO // TODO
skipExist?: boolean; skipExist?: boolean;
} }
interface MigrateOptions extends SyncOptions, Transactionable {}
async function migrate(field: Field, options: MigrateOptions): Promise<void> {
const { unique } = field.options;
const { model } = field.collection;
const ukName = `${model.tableName}_${field.name}_uk`;
const queryInterface = model.sequelize.getQueryInterface();
const fieldAttribute = model.rawAttributes[field.name];
// @ts-ignore
const existedConstraints = await queryInterface.showConstraint(model.tableName, ukName, { transaction: options.transaction }) as any[];
const constraintBefore = existedConstraints.find(item => item.constraintName === ukName);
if (typeof fieldAttribute?.unique !== 'undefined') {
if (constraintBefore && !unique) {
await queryInterface.removeConstraint(model.tableName, ukName, { transaction: options.transaction });
}
fieldAttribute.unique = Boolean(constraintBefore);
}
await field.sync(options);
if (!constraintBefore && unique) {
await queryInterface.addConstraint(model.tableName, {
type: 'unique',
fields: [field.name],
name: ukName,
transaction: options.transaction
});
}
if (typeof fieldAttribute?.unique !== 'undefined') {
fieldAttribute.unique = unique;
}
// @ts-ignore
const updatedConstraints = await queryInterface.showConstraint(model.tableName, ukName, { transaction: options.transaction }) as any[];
const indexAfter = updatedConstraints.find(item => item.constraintName === ukName);
if (unique && !indexAfter) {
throw new UniqueConstraintError({
fields: { [field.name]: undefined }
});
}
}
export class FieldModel extends MagicAttributeModel { export class FieldModel extends MagicAttributeModel {
get db(): Database { get db(): Database {
return (<any>this.constructor).database; return (<any>this.constructor).database;
@ -28,21 +70,21 @@ export class FieldModel extends MagicAttributeModel {
const uiSchema = await UISchema.findByPk(options.uiSchemaUid, { const uiSchema = await UISchema.findByPk(options.uiSchemaUid, {
transaction: loadOptions.transaction, transaction: loadOptions.transaction,
}); });
return collection.setField(name, { ...options, uiSchema: uiSchema.get() }); Object.assign(options, { uiSchema: uiSchema.get() });
} else { }
return collection.setField(name, options); return collection.setField(name, options);
} }
}
async migrate(options?: SyncOptions & Transactionable) { async migrate(options: MigrateOptions = {}) {
const field = await this.load({ const field = await this.load({
transaction: options.transaction, transaction: options.transaction,
}); });
if (!field) { if (!field) {
return; return;
} }
// const migrator = Dialects[field.database.options.dialect] ?? migrate;
try { try {
await field.sync(options); await migrate(field, options);
} catch (error) { } catch (error) {
// field sync failed, delete from memory // field sync failed, delete from memory
field.remove(); field.remove();

View File

@ -1,6 +1,11 @@
import { Plugin } from '@nocobase/server';
import lodash from 'lodash';
import path from 'path'; import path from 'path';
import lodash from 'lodash';
import { UniqueConstraintError } from 'sequelize';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import { Plugin } from '@nocobase/server';
import { CollectionRepository } from '.'; import { CollectionRepository } from '.';
import { import {
afterCreateForReverseField, afterCreateForReverseField,
@ -64,19 +69,23 @@ export class CollectionManagerPlugin extends Plugin {
} }
}); });
this.app.db.on('fields.afterCreate', async (model, { context, transaction }) => { this.app.db.on('fields.afterCreate', async (model: FieldModel, { context, transaction }) => {
if (context) { if (context) {
await model.migrate({ transaction }); await model.migrate({ transaction });
} }
}); });
this.app.db.on('fields.afterUpdateWithAssociations', async (model, { context, transaction }) => { this.app.db.on('fields.afterUpdate', async (model: FieldModel, { context, transaction }) => {
if (context) { if (context) {
await model.load({ transaction }); const prev = model.previous('options')?.unique;
const next = model.get('options')?.unique;
if (lodash.isBoolean(prev) && lodash.isBoolean(next) && prev !== next) {
await model.migrate({ transaction });
}
} }
}); });
this.app.db.on('fields.afterCreateWithAssociations', async (model, { context, transaction }) => { this.app.db.on('fields.afterSaveWithAssociations', async (model, { context, transaction }) => {
if (context) { if (context) {
await model.load({ transaction }); await model.load({ transaction });
} }
@ -168,6 +177,14 @@ export class CollectionManagerPlugin extends Plugin {
await this.app.db.import({ await this.app.db.import({
directory: path.resolve(__dirname, './collections'), directory: path.resolve(__dirname, './collections'),
}); });
const errorHandlerPlugin = <PluginErrorHandler>this.app.getPlugin('@nocobase/plugin-error-handler');
errorHandlerPlugin.errorHandler.register(
(err) => err instanceof UniqueConstraintError,
(err, ctx) => {
return ctx.throw(400, ctx.t(`The value of ${Object.keys(err.fields)} field duplicated`));
},
);
} }
getName(): string { getName(): string {

View File

@ -18,6 +18,9 @@ export class ErrorHandler {
}, },
], ],
}; };
if (ctx.status === 500) {
console.error(err);
}
} }
middleware() { middleware() {
@ -27,7 +30,6 @@ export class ErrorHandler {
try { try {
await next(); await next();
} catch (err) { } catch (err) {
console.error(err);
for (const handler of self.handlers) { for (const handler of self.handlers) {
if (handler.guard(err)) { if (handler.guard(err)) {
return handler.render(err, ctx); return handler.render(err, ctx);

View File

@ -1,6 +1,7 @@
import { BelongsToManyRepository, Database } from '@nocobase/database'; import { BelongsToManyRepository, Database } from '@nocobase/database';
import PluginUsers from '@nocobase/plugin-users'; import PluginUsers from '@nocobase/plugin-users';
import PluginACL from '@nocobase/plugin-acl'; import PluginACL from '@nocobase/plugin-acl';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginCollectionManager from '@nocobase/plugin-collection-manager'; import PluginCollectionManager from '@nocobase/plugin-collection-manager';
import UiSchemaStoragePlugin, { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; import UiSchemaStoragePlugin, { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer, MockServer } from '@nocobase/test';
@ -24,6 +25,7 @@ describe('server hooks', () => {
db = app.db; db = app.db;
app.plugin(UiSchemaStoragePlugin); app.plugin(UiSchemaStoragePlugin);
app.plugin(PluginErrorHandler);
app.plugin(PluginCollectionManager); app.plugin(PluginCollectionManager);
app.plugin(PluginUsers); app.plugin(PluginUsers);
app.plugin(PluginACL); app.plugin(PluginACL);

View File

@ -1,4 +1,5 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginCollectionManager from '@nocobase/plugin-collection-manager'; import PluginCollectionManager from '@nocobase/plugin-collection-manager';
import UiSchemaStoragePlugin, { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; import UiSchemaStoragePlugin, { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer, MockServer } from '@nocobase/test';
@ -62,6 +63,7 @@ describe('server hooks', () => {
db = app.db; db = app.db;
app.plugin(UiSchemaStoragePlugin); app.plugin(UiSchemaStoragePlugin);
app.plugin(PluginErrorHandler);
app.plugin(PluginCollectionManager); app.plugin(PluginCollectionManager);
await app.loadAndInstall(); await app.loadAndInstall();