feat: provide the underscored option for the database (#1366)

* feat: underscored options

* feat: underscored using hook

* feat: database underscored options

* feat: underscored env

* fix: collectionExistsInDb

* fix: test

* fix: nocobase install

* fix: test

* fix: belongsTo association

* fix: test of underscored

* chore: console.log

* fix: list action test

* fix: dump test

* chore: snakeCase algo

* fix: underscored field create

* fix: underscored env

* fix(acl): custom appends merge strategy (#1416)

* Update index.md

* fix(plugin-workflow): use promise to request (#1426)

* Update index.md

* Update collection.md

* Update index.md

* Update index.md

* Update collection.md

* Update field.md

* Update repository.md

* Update has-one-repository.md

* Update has-many-repository.md

* Update belongs-to-many-repository.md

* Update index.md

* chore: translate 'Add tab' in page header (#1424)

* fix: test

* fix: workflow test

* fix: underscored with inherits

* fix: underscored test

* fix:  process.env.DB_UNDERSCORED

* fix: process.env.DB_UNDERSCORED === 'true'

* fix: test

* fix: pg test

* fix: underscored table name

* feat: tableName & fieldName conflict check

* fix: test

* fix: underscored index

* fix: update field unique index

* fix: sync default value

* fix: collection manager create field

* chore: field sync

* fix: pg test

* chore: test

* fix: test

* chore: default constraint name

* chore: syncUniqueIndex

* feat: field destory before check

* feat: field type check

* fix: test

* fix: test

* fix: improve

* fix: should destroy when fields refer to the same field

* fix: acl meta with underscored

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-02-13 21:38:47 +08:00 committed by GitHub
parent 00acbfb9b0
commit 0e0eb6432e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1124 additions and 168 deletions

View File

@ -109,3 +109,97 @@ jobs:
DB_USER: root DB_USER: root
DB_PASSWORD: password DB_PASSWORD: password
DB_DATABASE: nocobase DB_DATABASE: nocobase
sqlite-underscored-test:
strategy:
matrix:
node_version: ['16']
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- run: yarn install
- name: Test with Sqlite
run: yarn test
env:
DB_DIALECT: sqlite
DB_STORAGE: /tmp/db.sqlite
DB_UNDERSCORED: true
postgres-underscored-test:
strategy:
matrix:
node_version: ['16']
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres:10
# Provide the password for postgres
env:
POSTGRES_USER: nocobase
POSTGRES_PASSWORD: password
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- run: yarn install
- name: Test with postgres
run: yarn test
env:
DB_DIALECT: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_USER: nocobase
DB_PASSWORD: password
DB_DATABASE: nocobase
DB_UNDERSCORED: true
mysql-underscored-test:
strategy:
matrix:
node_version: ['16']
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: nocobase
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- run: yarn install
- name: Test with MySQL
run: yarn test
env:
DB_DIALECT: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: password
DB_DATABASE: nocobase
DB_UNDERSCORED: true

View File

@ -12,6 +12,7 @@ export default {
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
tablePrefix: process.env.DB_TABLE_PREFIX, tablePrefix: process.env.DB_TABLE_PREFIX,
schema: process.env.DB_SCHEMA, schema: process.env.DB_SCHEMA,
underscored: process.env.DB_UNDERSCORED === 'true',
} as IDatabaseOptions; } as IDatabaseOptions;
function customLogger(queryString, queryObject) { function customLogger(queryString, queryObject) {

View File

@ -43,6 +43,6 @@ excludeSqlite()('collection', () => {
const profileTableInfo = await db.sequelize.getQueryInterface().describeTable(profile.model.tableName); const profileTableInfo = await db.sequelize.getQueryInterface().describeTable(profile.model.tableName);
expect(profileTableInfo['userId'].type).toBe('BIGINT'); expect(profileTableInfo[profile.model.rawAttributes['userId'].field].type).toBe('BIGINT');
}); });
}); });

View File

@ -8,9 +8,7 @@ describe('collection', () => {
let db: Database; let db: Database;
beforeEach(async () => { beforeEach(async () => {
db = mockDatabase({ db = mockDatabase();
logging: console.log,
});
await db.clean({ drop: true }); await db.clean({ drop: true });
}); });
@ -240,9 +238,15 @@ describe('collection sync', () => {
await collection.sync(); await collection.sync();
const tableFields = await (<any>collection.model).queryInterface.describeTable(`${db.getTablePrefix()}users`); const tableFields = await (<any>collection.model).queryInterface.describeTable(`${db.getTablePrefix()}users`);
expect(tableFields).toHaveProperty('firstName'); if (db.options.underscored) {
expect(tableFields).toHaveProperty('lastName'); expect(tableFields).toHaveProperty('first_name');
expect(tableFields).toHaveProperty('age'); expect(tableFields).toHaveProperty('last_name');
expect(tableFields).toHaveProperty('age');
} else {
expect(tableFields).toHaveProperty('firstName');
expect(tableFields).toHaveProperty('lastName');
expect(tableFields).toHaveProperty('age');
}
}); });
test('sync with association not exists', async () => { test('sync with association not exists', async () => {
@ -290,9 +294,15 @@ describe('collection sync', () => {
const model = collection.model; const model = collection.model;
await collection.sync(); await collection.sync();
const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}postsTags`); if (db.options.underscored) {
expect(tableFields['postId']).toBeDefined(); const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}posts_tags`);
expect(tableFields['tagId']).toBeDefined(); expect(tableFields['post_id']).toBeDefined();
expect(tableFields['tag_id']).toBeDefined();
} else {
const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}postsTags`);
expect(tableFields['postId']).toBeDefined();
expect(tableFields['tagId']).toBeDefined();
}
}); });
test('limit table name length', async () => { test('limit table name length', async () => {

View File

@ -886,11 +886,13 @@ pgOnly()('collection inherits', () => {
const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName); const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName);
expect(studentTableInfo.score).toBeDefined(); const getField = (name) => student.model.rawAttributes[name].field;
expect(studentTableInfo.name).toBeDefined();
expect(studentTableInfo.id).toBeDefined(); expect(studentTableInfo[getField('score')]).toBeDefined();
expect(studentTableInfo.createdAt).toBeDefined(); expect(studentTableInfo[getField('name')]).toBeDefined();
expect(studentTableInfo.updatedAt).toBeDefined(); expect(studentTableInfo[getField('id')]).toBeDefined();
expect(studentTableInfo[getField('createdAt')]).toBeDefined();
expect(studentTableInfo[getField('updatedAt')]).toBeDefined();
}); });
it('should get parent fields', async () => { it('should get parent fields', async () => {

View File

@ -0,0 +1,207 @@
import { Database, mockDatabase } from '@nocobase/database';
describe('underscored options', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
underscored: true,
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should set two field with same type', async () => {
const collection = db.collection({
name: 'test',
fields: [
{
type: 'string',
name: 'test_field',
},
{
type: 'string',
name: 'testField',
},
],
});
await db.sync();
});
it('should not set two field with difference type but same field name', async () => {
const collection = db.collection({
name: 'test',
fields: [
{
type: 'string',
name: 'test_field',
},
],
});
expect(() => {
collection.addField('testField', { type: 'integer' });
}).toThrowError();
expect(() => {
collection.addField('test123', { type: 'integer', field: 'test_field' });
}).toThrowError();
});
it('should create index', async () => {
const collectionA = db.collection({
name: 'testCollection',
fields: [
{
type: 'string',
name: 'aField',
},
{
type: 'string',
name: 'bField',
},
],
indexes: [
{
type: 'UNIQUE',
fields: ['aField', 'bField'],
},
],
});
await db.sync();
});
it('should use underscored option', async () => {
const collectionA = db.collection({
name: 'testCollection',
underscored: true,
fields: [
{
type: 'string',
name: 'testField',
},
],
});
await db.sync();
const tableName = collectionA.model.tableName;
expect(tableName.includes('test_collection')).toBeTruthy();
const repository = db.getRepository('testCollection');
await repository.create({
values: {
testField: 'test',
},
});
const record = await repository.findOne({});
expect(record.get('testField')).toBe('test');
});
it('should use database options', async () => {
const collectionA = db.collection({
name: 'testCollection',
fields: [
{
type: 'string',
name: 'testField',
},
],
});
await db.sync();
const tableName = collectionA.model.tableName;
expect(tableName.includes('test_collection')).toBeTruthy();
});
test('through table', async () => {
db.collection({
name: 'posts',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'belongsToMany',
name: 'tags',
through: 'collectionCategory',
target: 'posts',
sourceKey: 'name',
foreignKey: 'postsName',
targetKey: 'name',
otherKey: 'tagsName',
},
],
});
db.collection({
name: 'tags',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'belongsToMany',
name: 'posts',
target: 'posts',
through: 'collectionCategory',
sourceKey: 'name',
foreignKey: 'tagsName',
targetKey: 'name',
otherKey: 'postsName',
},
],
});
await db.sync();
const through = db.getCollection('collectionCategory');
expect(through.model.tableName.includes('collection_category')).toBeTruthy();
});
test('db collectionExists', async () => {
const collectionA = db.collection({
name: 'testCollection',
underscored: true,
fields: [
{
type: 'string',
name: 'testField',
},
],
});
expect(await db.collectionExistsInDb('testCollection')).toBeFalsy();
await db.sync();
expect(await db.collectionExistsInDb('testCollection')).toBeTruthy();
});
it('should throw error when table names conflict', async () => {
db.collection({
name: 'b1_z',
});
expect(() => {
db.collection({
name: 'b1Z',
});
}).toThrowError();
});
});

View File

@ -13,7 +13,7 @@ import { Database } from './database';
import { Field, FieldOptions } from './fields'; import { Field, FieldOptions } from './fields';
import { Model } from './model'; import { Model } from './model';
import { Repository } from './repository'; import { Repository } from './repository';
import { checkIdentifier, md5 } from './utils'; import { checkIdentifier, md5, snakeCase } from './utils';
export type RepositoryType = typeof Repository; export type RepositoryType = typeof Repository;
@ -36,6 +36,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
* @default 'options' * @default 'options'
*/ */
magicAttribute?: string; magicAttribute?: string;
[key: string]: any; [key: string]: any;
} }
@ -72,11 +73,11 @@ export class Collection<
constructor(options: CollectionOptions, context: CollectionContext) { constructor(options: CollectionOptions, context: CollectionContext) {
super(); super();
this.checkOptions(options);
this.context = context; this.context = context;
this.options = options; this.options = options;
this.checkOptions(options);
this.bindFieldEventListener(); this.bindFieldEventListener();
this.modelInit(); this.modelInit();
@ -93,15 +94,31 @@ export class Collection<
private checkOptions(options: CollectionOptions) { private checkOptions(options: CollectionOptions) {
checkIdentifier(options.name); checkIdentifier(options.name);
this.checkTableName();
}
private checkTableName() {
const tableName = this.tableName();
for (const [k, collection] of this.db.collections) {
if (collection.name != this.options.name && tableName === collection.tableName()) {
throw new Error(`collection ${collection.name} and ${this.name} have same tableName "${tableName}"`);
}
}
}
tableName() {
const { name, tableName } = this.options;
const tName = tableName || name;
return this.db.options.underscored ? snakeCase(tName) : tName;
} }
private sequelizeModelOptions() { private sequelizeModelOptions() {
const { name, tableName } = this.options; const { name } = this.options;
return { return {
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']), ..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
modelName: name, modelName: name,
sequelize: this.context.database.sequelize, sequelize: this.context.database.sequelize,
tableName: tableName || name, tableName: this.tableName(),
}; };
} }
@ -112,8 +129,10 @@ export class Collection<
if (this.model) { if (this.model) {
return; return;
} }
const { name, model, autoGenId = true } = this.options; const { name, model, autoGenId = true } = this.options;
let M: ModelStatic<Model> = Model; let M: ModelStatic<Model> = Model;
if (this.context.database.sequelize.isDefined(name)) { if (this.context.database.sequelize.isDefined(name)) {
const m = this.context.database.sequelize.model(name); const m = this.context.database.sequelize.model(name);
if ((m as any).isThrough) { if ((m as any).isThrough) {
@ -126,11 +145,13 @@ export class Collection<
return; return;
} }
} }
if (typeof model === 'string') { if (typeof model === 'string') {
M = this.context.database.models.get(model) || Model; M = this.context.database.models.get(model) || Model;
} else if (model) { } else if (model) {
M = model; M = model;
} }
// @ts-ignore // @ts-ignore
this.model = class extends M {}; this.model = class extends M {};
this.model.init(null, this.sequelizeModelOptions()); this.model.init(null, this.sequelizeModelOptions());
@ -183,8 +204,31 @@ export class Collection<
return this.setField(name, options); return this.setField(name, options);
} }
checkFieldType(name: string, options: FieldOptions) {
if (!this.db.options.underscored) {
return;
}
const fieldName = options.field || snakeCase(name);
const field = this.findField((f) => {
if (f.name === name) {
return false;
}
if (f.field) {
return f.field === fieldName;
}
return snakeCase(f.name) === fieldName;
});
if (!field) {
return;
}
if (options.type !== field.type) {
throw new Error(`fields with same column must be of the same type ${JSON.stringify(options)}`);
}
}
setField(name: string, options: FieldOptions): Field { setField(name: string, options: FieldOptions): Field {
checkIdentifier(name); checkIdentifier(name);
this.checkFieldType(name, options);
const { database } = this.context; const { database } = this.context;
@ -362,9 +406,13 @@ export class Collection<
if (!index) { if (!index) {
return; return;
} }
// collection defined indexes
let indexes: any = this.model.options.indexes || []; let indexes: any = this.model.options.indexes || [];
let indexName = []; let indexName = [];
let indexItem; let indexItem;
if (typeof index === 'string') { if (typeof index === 'string') {
indexItem = { indexItem = {
fields: [index], fields: [index],
@ -379,13 +427,17 @@ export class Collection<
indexItem = index; indexItem = index;
indexName = index.fields; indexName = index.fields;
} }
if (lodash.isEqual(this.model.primaryKeyAttributes, indexName)) { if (lodash.isEqual(this.model.primaryKeyAttributes, indexName)) {
return; return;
} }
const name: string = this.model.primaryKeyAttributes.join(','); const name: string = this.model.primaryKeyAttributes.join(',');
if (name.startsWith(`${indexName.join(',')},`)) { if (name.startsWith(`${indexName.join(',')},`)) {
return; return;
} }
for (const item of indexes) { for (const item of indexes) {
if (lodash.isEqual(item.fields, indexName)) { if (lodash.isEqual(item.fields, indexName)) {
return; return;
@ -395,11 +447,13 @@ export class Collection<
return; return;
} }
} }
if (!indexItem) { if (!indexItem) {
return; return;
} }
indexes.push(indexItem); indexes.push(indexItem);
this.model.options.indexes = indexes;
const tableName = this.model.getTableName(); const tableName = this.model.getTableName();
// @ts-ignore // @ts-ignore
this.model._indexes = this.model.options.indexes this.model._indexes = this.model.options.indexes
@ -411,6 +465,7 @@ export class Collection<
} }
return item; return item;
}); });
this.refreshIndexes(); this.refreshIndexes();
} }
@ -430,15 +485,17 @@ export class Collection<
refreshIndexes() { refreshIndexes() {
// @ts-ignore // @ts-ignore
const indexes: any[] = this.model._indexes; const indexes: any[] = this.model._indexes;
// @ts-ignore // @ts-ignore
this.model._indexes = indexes.filter((item) => { this.model._indexes = lodash.uniqBy(
for (const field of item.fields) { indexes.map((item) => {
if (!this.model.rawAttributes[field]) { if (this.options.underscored) {
return false; item.fields = item.fields.map((field) => snakeCase(field));
} }
} return item;
return true; }),
}); 'name',
);
} }
async sync(syncOptions?: SyncOptions) { async sync(syncOptions?: SyncOptions) {

View File

@ -60,6 +60,7 @@ import {
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener ValidateListener
} from './types'; } from './types';
import { snakeCase } from './utils';
export interface MergeOptions extends merge.Options {} export interface MergeOptions extends merge.Options {}
@ -76,6 +77,7 @@ export interface IDatabaseOptions extends Options {
tablePrefix?: string; tablePrefix?: string;
migrator?: any; migrator?: any;
usingBigIntForId?: boolean; usingBigIntForId?: boolean;
underscored?: boolean;
} }
export type DatabaseOptions = IDatabaseOptions; export type DatabaseOptions = IDatabaseOptions;
@ -167,7 +169,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
constructor(options: DatabaseOptions) { constructor(options: DatabaseOptions) {
super(); super();
this.version = new DatabaseVersion(this); this.version = new DatabaseVersion(this);
const opts = { const opts = {
@ -265,6 +266,12 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
initListener() { initListener() {
this.on('beforeDefine', (model, options) => {
if (this.options.underscored) {
options.underscored = true;
}
});
this.on('afterCreate', async (instance) => { this.on('afterCreate', async (instance) => {
instance?.toChangedWithAssociations?.(); instance?.toChangedWithAssociations?.();
}); });
@ -294,6 +301,27 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
} }
}); });
this.on('beforeDefineCollection', (options) => {
if (options.underscored) {
if (lodash.get(options, 'sortable.scopeKey')) {
options.sortable.scopeKey = snakeCase(options.sortable.scopeKey);
}
if (lodash.get(options, 'indexes')) {
// change index fields to snake case
options.indexes = options.indexes.map((index) => {
if (index.fields) {
index.fields = index.fields.map((field) => {
return snakeCase(field);
});
}
return index;
});
}
}
});
} }
addMigration(item: MigrationItem) { addMigration(item: MigrationItem) {
@ -328,6 +356,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
collection<Attributes = any, CreateAttributes = Attributes>( collection<Attributes = any, CreateAttributes = Attributes>(
options: CollectionOptions, options: CollectionOptions,
): Collection<Attributes, CreateAttributes> { ): Collection<Attributes, CreateAttributes> {
if (this.options.underscored) {
options.underscored = true;
}
this.emit('beforeDefineCollection', options); this.emit('beforeDefineCollection', options);
const hasValidInheritsOptions = (() => { const hasValidInheritsOptions = (() => {
@ -477,6 +509,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
throw Error(`unsupported field type ${type}`); throw Error(`unsupported field type ${type}`);
} }
if (options.field && this.options.underscored) {
options.field = snakeCase(options.field);
}
return new Field(options, context); return new Field(options, context);
} }
@ -525,11 +561,17 @@ export class Database extends EventEmitter implements AsyncEmitter {
await this.sequelize.getQueryInterface().dropAllTables(others); await this.sequelize.getQueryInterface().dropAllTables(others);
} }
async collectionExistsInDb(name, options?: Transactionable) { async collectionExistsInDb(name: string, options?: Transactionable) {
const collection = this.getCollection(name);
if (!collection) {
return false;
}
const tables = await this.sequelize.getQueryInterface().showAllTables({ const tables = await this.sequelize.getQueryInterface().showAllTables({
transaction: options?.transaction, transaction: options?.transaction,
}); });
return !!tables.find((table) => table === `${this.getTablePrefix()}${name}`);
return tables.includes(this.getCollection(name).model.tableName);
} }
public isSqliteMemory() { public isSqliteMemory() {

View File

@ -1,5 +1,5 @@
import { omit } from 'lodash'; import lodash, { omit } from 'lodash';
import { BelongsTo, BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';

View File

@ -57,7 +57,6 @@ export class BelongsToManyField extends RelationField {
} else { } else {
Through = database.collection({ Through = database.collection({
name: through, name: through,
// timestamps: false,
}); });
Object.defineProperty(Through.model, 'isThrough', { value: true }); Object.defineProperty(Through.model, 'isThrough', { value: true });

View File

@ -12,6 +12,7 @@ import { Collection } from '../collection';
import { Database } from '../database'; import { Database } from '../database';
import { InheritedCollection } from '../inherited-collection'; import { InheritedCollection } from '../inherited-collection';
import { ModelEventTypes } from '../types'; import { ModelEventTypes } from '../types';
import { snakeCase } from '../utils';
export interface FieldContext { export interface FieldContext {
database: Database; database: Database;
@ -89,6 +90,18 @@ export abstract class Field {
return this.collection.removeField(this.name); return this.collection.removeField(this.name);
} }
columnName() {
if (this.options.field) {
return this.options.field;
}
if (this.database.options.underscored) {
return snakeCase(this.name);
}
return this.name;
}
async removeFromDb(options?: QueryInterfaceOptions) { async removeFromDb(options?: QueryInterfaceOptions) {
const attribute = this.collection.model.rawAttributes[this.name]; const attribute = this.collection.model.rawAttributes[this.name];
@ -113,7 +126,12 @@ export abstract class Field {
} }
if (this.collection.model.options.timestamps !== false) { if (this.collection.model.options.timestamps !== false) {
// timestamps 相关字段不删除 // timestamps 相关字段不删除
if (['createdAt', 'updatedAt', 'deletedAt'].includes(this.name)) { let timestampsFields = ['createdAt', 'updatedAt', 'deletedAt'];
if (this.database.options.underscored) {
timestampsFields = timestampsFields.map((field) => snakeCase(field));
}
if (timestampsFields.includes(this.columnName())) {
this.collection.fields.delete(this.name);
return; return;
} }
} }
@ -132,19 +150,28 @@ export abstract class Field {
return; return;
} }
} }
if (this.options.field && this.name !== this.options.field) {
// field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用 // if (this.options.field && this.name !== this.options.field) {
this.remove(); // // field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用
return; // this.remove();
} // return;
// }
const columnReferencesCount = _.filter(
this.collection.model.rawAttributes,
(attr) => attr.field == this.columnName(),
).length;
if ( if (
await this.existsInDb({ (await this.existsInDb({
transaction: options?.transaction, transaction: options?.transaction,
}) })) &&
columnReferencesCount == 1
) { ) {
const queryInterface = this.database.sequelize.getQueryInterface(); const queryInterface = this.database.sequelize.getQueryInterface();
await queryInterface.removeColumn(this.collection.model.tableName, this.name, options); await queryInterface.removeColumn(this.collection.model.tableName, this.columnName(), options);
} }
this.remove(); this.remove();
} }
@ -154,18 +181,20 @@ export abstract class Field {
}; };
let sql; let sql;
if (this.database.sequelize.getDialect() === 'sqlite') { if (this.database.sequelize.getDialect() === 'sqlite') {
sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.name}'`; sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.columnName()}'`;
} else if (this.database.inDialect('mysql')) { } else if (this.database.inDialect('mysql')) {
sql = ` sql = `
select column_name select column_name
from INFORMATION_SCHEMA.COLUMNS from INFORMATION_SCHEMA.COLUMNS
where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.name}' where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${
this.collection.model.tableName
}' AND column_name='${this.columnName()}'
`; `;
} else { } else {
sql = ` sql = `
select column_name select column_name
from INFORMATION_SCHEMA.COLUMNS from INFORMATION_SCHEMA.COLUMNS
where TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.name}' where TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.columnName()}'
`; `;
} }
const [rows] = await this.database.sequelize.query(sql, opts); const [rows] = await this.database.sequelize.query(sql, opts);

View File

@ -1,4 +1,4 @@
import { omit } from 'lodash'; import lodash, { omit } from 'lodash';
import { import {
AssociationScope, AssociationScope,
DataType, DataType,
@ -8,7 +8,7 @@ import {
Utils, Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { checkIdentifier } from '../utils'; import { checkIdentifier, snakeCase } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
@ -84,11 +84,15 @@ export class HasOneField extends RelationField {
} }
get foreignKey() { get foreignKey() {
if (this.options.foreignKey) { const foreignKey = (() => {
return this.options.foreignKey; if (this.options.foreignKey) {
} return this.options.foreignKey;
const { model } = this.context.collection; }
return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_')); const { model } = this.context.collection;
return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_'));
})();
return foreignKey;
} }
reference(association): Reference { reference(association): Reference {

View File

@ -18,3 +18,4 @@ export * from './update-associations';
export * from './collection-importer'; export * from './collection-importer';
export * from './filter-match'; export * from './filter-match';
export * from './field-repository/array-field-repository'; export * from './field-repository/array-field-repository';
export { snakeCase } from './utils';

View File

@ -31,10 +31,11 @@ export function getConfigByEnv() {
collate: 'utf8mb4_unicode_ci', collate: 'utf8mb4_unicode_ci',
}, },
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
underscored: process.env.DB_UNDERSCORED === 'true',
}; };
} }
export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase { export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase {
const dbOptions = merge(getConfigByEnv(), options); const dbOptions = merge(getConfigByEnv(), options) as any;
return new MockDatabase(dbOptions); return new MockDatabase(dbOptions);
} }

View File

@ -1,9 +1,8 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { DataTypes, Model as SequelizeModel, ModelStatic } from 'sequelize'; import { Model as SequelizeModel, ModelStatic } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
import { Field } from './fields'; import { Field } from './fields';
import type { InheritedCollection } from './inherited-collection';
import { SyncRunner } from './sync-runner'; import { SyncRunner } from './sync-runner';
const _ = lodash; const _ = lodash;
@ -28,6 +27,7 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
public static collection: Collection; public static collection: Collection;
[key: string]: any; [key: string]: any;
protected _changedWithAssociations = new Set(); protected _changedWithAssociations = new Set();
protected _previousDataValuesWithAssociations = {}; protected _previousDataValuesWithAssociations = {};

View File

@ -75,6 +75,7 @@ export class OptionsParser {
for (const sortKey of sort) { for (const sortKey of sort) {
let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
let sortField: Array<any> = sortKey.replace('-', '').split('.'); let sortField: Array<any> = sortKey.replace('-', '').split('.');
if (this.database.inDialect('postgres', 'sqlite')) { if (this.database.inDialect('postgres', 'sqlite')) {
direction = `${direction} NULLS LAST`; direction = `${direction} NULLS LAST`;
} }
@ -86,7 +87,11 @@ export class OptionsParser {
sortField[i] = associationModel.associations[associationKey].target; sortField[i] = associationModel.associations[associationKey].target;
associationModel = sortField[i]; associationModel = sortField[i];
} }
} else {
const rawField = this.model.rawAttributes[sortField[0]];
sortField[0] = rawField?.field || sortField[0];
} }
sortField.push(direction); sortField.push(direction);
if (this.database.inDialect('mysql')) { if (this.database.inDialect('mysql')) {
orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]); orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]);

View File

@ -62,6 +62,8 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const association = <BelongsToMany>this.association; const association = <BelongsToMany>this.association;
const throughModel = this.throughModel();
const instancesToIds = (instances) => { const instancesToIds = (instances) => {
return instances.map((instance) => instance.get(this.targetKey())); return instances.map((instance) => instance.get(this.targetKey()));
}; };
@ -69,7 +71,7 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
// Through Table // Through Table
const throughTableWhere: Array<any> = [ const throughTableWhere: Array<any> = [
{ {
[association.foreignKey]: this.sourceKeyValue, [throughModel.rawAttributes[association.foreignKey].field]: this.sourceKeyValue,
}, },
]; ];
@ -100,7 +102,7 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
} }
throughTableWhere.push({ throughTableWhere.push({
[association.otherKey]: { [throughModel.rawAttributes[association.otherKey].field]: {
[Op.in]: ids, [Op.in]: ids,
}, },
}); });

View File

@ -11,7 +11,7 @@ import {
Op, Op,
Transactionable, Transactionable,
UpdateOptions as SequelizeUpdateOptions, UpdateOptions as SequelizeUpdateOptions,
WhereOperators WhereOperators,
} from 'sequelize'; } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
@ -407,7 +407,12 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const guard = UpdateGuard.fromOptions(this.model, { ...options, action: 'create' }); const guard = UpdateGuard.fromOptions(this.model, {
...options,
action: 'create',
underscored: this.collection.options.underscored,
});
const values = guard.sanitize(options.values || {}); const values = guard.sanitize(options.values || {});
const instance = await this.model.create<any>(values, { const instance = await this.model.create<any>(values, {
@ -476,7 +481,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
} }
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const guard = UpdateGuard.fromOptions(this.model, options); const guard = UpdateGuard.fromOptions(this.model, { ...options, underscored: this.collection.options.underscored });
const values = guard.sanitize(options.values); const values = guard.sanitize(options.values);

View File

@ -124,7 +124,9 @@ AND NOT attisdropped
if (options.alter) { if (options.alter) {
const columns = await queryInterface.describeTable(tableName, options); const columns = await queryInterface.describeTable(tableName, options);
for (const columnName in childAttributes) { for (const attribute in childAttributes) {
const columnName = childAttributes[attribute].field;
if (!columns[columnName]) { if (!columns[columnName]) {
await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options); await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options);
} }

View File

@ -13,6 +13,7 @@ type UpdateAction = 'create' | 'update';
export class UpdateGuard { export class UpdateGuard {
model: ModelStatic<any>; model: ModelStatic<any>;
action: UpdateAction; action: UpdateAction;
underscored: boolean;
private associationKeysToBeUpdate: AssociationKeysToBeUpdate; private associationKeysToBeUpdate: AssociationKeysToBeUpdate;
private blackList: BlackList; private blackList: BlackList;
private whiteList: WhiteList; private whiteList: WhiteList;
@ -162,6 +163,11 @@ export class UpdateGuard {
guard.setBlackList(options.blacklist); guard.setBlackList(options.blacklist);
guard.setAction(lodash.get(options, 'action', 'update')); guard.setAction(lodash.get(options, 'action', 'update'));
guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); guard.setAssociationKeysToBeUpdate(options.updateAssociationValues);
if (options.underscored) {
guard.underscored = options.underscored;
}
return guard; return guard;
} }
} }

View File

@ -1,6 +1,7 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { IdentifierError } from './errors/identifier-error'; import { IdentifierError } from './errors/identifier-error';
import { Model } from './model'; import { Model } from './model';
import lodash from 'lodash';
type HandleAppendsQueryOptions = { type HandleAppendsQueryOptions = {
templateModel: any; templateModel: any;
@ -73,3 +74,11 @@ export function checkIdentifier(value: string) {
throw new IdentifierError(`Identifier ${value} is too long`); throw new IdentifierError(`Identifier ${value} is too long`);
} }
} }
export function getTableName(collectionName: string, options) {
return options.underscored ? snakeCase(collectionName) : collectionName;
}
export function snakeCase(name: string) {
return require('sequelize').Utils.underscore(name);
}

View File

@ -76,6 +76,7 @@ export class PluginManager {
await this.repository.load(); await this.repository.load();
} }
}); });
this.app.on('beforeUpgrade', async () => { this.app.on('beforeUpgrade', async () => {
await this.collection.sync(); await this.collection.sync();
}); });
@ -195,6 +196,7 @@ export class PluginManager {
if (this.plugins.has(pluginName)) { if (this.plugins.has(pluginName)) {
throw new Error(`plugin name [${pluginName}] already exists`); throw new Error(`plugin name [${pluginName}] already exists`);
} }
this.plugins.set(pluginName, instance); this.plugins.set(pluginName, instance);
return instance; return instance;
} }

View File

@ -32,12 +32,22 @@ describe('list action with acl', () => {
role: 'user', role: 'user',
}); });
app.acl.addFixedParams('tests', 'destroy', () => {
return {
filter: {
$and: [{ 'name.$ne': 't1' }, { 'name.$ne': 't2' }],
},
};
});
userRole.grantAction('tests:view', {}); userRole.grantAction('tests:view', {});
userRole.grantAction('tests:update', { userRole.grantAction('tests:update', {
own: true, own: true,
}); });
userRole.grantAction('tests:destroy', {});
const Test = app.db.collection({ const Test = app.db.collection({
name: 'tests', name: 'tests',
fields: [ fields: [
@ -74,13 +84,13 @@ describe('list action with acl', () => {
before: 'acl', before: 'acl',
}, },
); );
const response = await app.agent().set('X-With-ACL-Meta', true).resource('tests').list({}); const response = await app.agent().set('X-With-ACL-Meta', true).resource('tests').list({});
const data = response.body; const data = response.body;
expect(data.meta.allowedActions.view).toEqual(['t1', 't2', 't3']); expect(data.meta.allowedActions.view).toEqual(['t1', 't2', 't3']);
expect(data.meta.allowedActions.update).toEqual(['t1', 't2']); expect(data.meta.allowedActions.update).toEqual(['t1', 't2']);
expect(data.meta.allowedActions.destroy).toEqual([]); expect(data.meta.allowedActions.destroy).toEqual(['t3']);
}); });
it('should list items with meta permission', async () => { it('should list items with meta permission', async () => {
@ -241,12 +251,15 @@ describe('list association action with acl', () => {
}); });
const userPlugin = app.getPlugin('users'); const userPlugin = app.getPlugin('users');
const userAgent = app.agent().set('X-With-ACL-Meta', true).auth( const userAgent = app
userPlugin.jwtService.sign({ .agent()
userId: user.get('id'), .set('X-With-ACL-Meta', true)
}), .auth(
{ type: 'bearer' }, userPlugin.jwtService.sign({
); userId: user.get('id'),
}),
{ type: 'bearer' },
);
await userAgent.resource('posts').create({ await userAgent.resource('posts').create({
values: { values: {

View File

@ -1,6 +1,6 @@
import { NoPermissionError } from '@nocobase/acl'; import { NoPermissionError } from '@nocobase/acl';
import { Context, utils as actionUtils } from '@nocobase/actions'; import { Context, utils as actionUtils } from '@nocobase/actions';
import { Collection, RelationField } from '@nocobase/database'; import { Collection, RelationField, snakeCase } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import lodash from 'lodash'; import lodash from 'lodash';
import { resolve } from 'path'; import { resolve } from 'path';
@ -262,6 +262,7 @@ export class PluginACL extends Plugin {
this.app.db.on('rolesResources.afterDestroy', async (model, options) => { this.app.db.on('rolesResources.afterDestroy', async (model, options) => {
const role = this.acl.getRole(model.get('roleName')); const role = this.acl.getRole(model.get('roleName'));
if (role) { if (role) {
role.revokeResource(model.get('name')); role.revokeResource(model.get('name'));
} }
@ -281,6 +282,7 @@ export class PluginACL extends Plugin {
const { transaction } = options; const { transaction } = options;
const collectionName = model.get('collectionName'); const collectionName = model.get('collectionName');
const fieldName = model.get('name'); const fieldName = model.get('name');
const resourceActions = (await this.app.db.getRepository('rolesResourcesActions').find({ const resourceActions = (await this.app.db.getRepository('rolesResourcesActions').find({
@ -695,16 +697,57 @@ export class PluginACL extends Plugin {
const actionSql = ctx.db.sequelize.queryInterface.queryGenerator.selectQuery( const actionSql = ctx.db.sequelize.queryInterface.queryGenerator.selectQuery(
Model.getTableName(), Model.getTableName(),
{ {
// ...queryParams, where: (() => {
where: queryParams.where, const filterObj = queryParams.where;
if (!this.db.options.underscored) {
return filterObj;
}
const iterate = (rootObj, path = []) => {
const obj = path.length == 0 ? rootObj : lodash.get(rootObj, path);
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (obj[i] === null) {
continue;
}
if (typeof obj[i] === 'object') {
iterate(rootObj, [...path, i]);
}
}
return;
}
Reflect.ownKeys(obj).forEach((key) => {
if (Array.isArray(obj) && key == 'length') {
return;
}
if ((typeof obj[key] === 'object' && obj[key] !== null) || typeof obj[key] === 'symbol') {
iterate(rootObj, [...path, key]);
}
if (typeof key === 'string' && key !== snakeCase(key)) {
lodash.set(rootObj, [...path, snakeCase(key)], lodash.cloneDeep(obj[key]));
lodash.unset(rootObj, [...path, key]);
}
});
};
iterate(filterObj);
return filterObj;
})(),
attributes: [primaryKeyField], attributes: [primaryKeyField],
includeIgnoreAttributes: false, includeIgnoreAttributes: false,
// include: queryParams.include,
}, },
Model, Model,
); );
const whereCase = actionSql.match(/WHERE (.*?);/)[1]; const whereCase = actionSql.match(/WHERE (.*?);/)[1];
conditions.push({ conditions.push({
whereCase, whereCase,
action, action,

View File

@ -19,6 +19,32 @@ describe('collections repository', () => {
await app.destroy(); await app.destroy();
}); });
test('create underscored field', async () => {
if (process.env.DB_UNDERSCORED !== 'true') {
return;
}
const collection = await Collection.repository.create({
values: {
name: 'testCollection',
createdAt: true,
fields: [
{
type: 'date',
field: 'createdAt',
name: 'createdAt',
},
],
},
});
await collection.migrate();
const testCollection = db.getCollection('testCollection');
expect(testCollection.model.rawAttributes.createdAt.field).toEqual('created_at');
});
it('case 1', async () => { it('case 1', async () => {
// 什么都没提供,随机 name 和 key // 什么都没提供,随机 name 和 key
const data = await Collection.repository.create({ const data = await Collection.repository.create({
@ -157,4 +183,164 @@ describe('collections repository', () => {
], ],
}); });
}); });
it('should destroy when fields refer to the same field', async () => {
await Collection.repository.create({
context: {},
values: {
name: 'tests',
timestamps: true,
fields: [
{
type: 'date',
name: 'dateA',
},
{
type: 'date',
name: 'date_a',
},
],
},
});
const testCollection = db.getCollection('tests');
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName);
const tableInfo0 = await getTableInfo();
expect(tableInfo0['date_a']).toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: ['dateA', 'date_a'],
},
});
const count = await Field.repository.count();
expect(count).toBe(0);
const tableInfo1 = await getTableInfo();
expect(tableInfo1['dateA']).not.toBeDefined();
expect(tableInfo1['date_a']).not.toBeDefined();
});
it('should not destroy timestamps columns', async () => {
const createdAt = db.options.underscored ? 'created_at' : 'createdAt';
await Collection.repository.create({
context: {},
values: {
name: 'tests',
timestamps: true,
fields: [
{
type: 'date',
name: 'createdAt',
},
],
},
});
const testCollection = db.getCollection('tests');
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName);
const tableInfo0 = await getTableInfo();
expect(tableInfo0[createdAt]).toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: 'createdAt',
},
});
const tableInfo1 = await getTableInfo();
expect(tableInfo1[createdAt]).toBeDefined();
expect(testCollection.hasField('createdAt')).toBeFalsy();
expect(testCollection.model.rawAttributes['createdAt']).toBeDefined();
});
it('should not destroy column when column belongs to a field', async () => {
if (db.options.underscored !== true) return;
await Collection.repository.create({
context: {},
values: {
name: 'tests',
fields: [
{
type: 'string',
name: 'test_field',
},
{
type: 'string',
name: 'testField',
},
{
type: 'string',
name: 'test123',
field: 'test_field',
},
{
type: 'string',
name: 'otherField',
},
],
},
});
const testCollection = db.getCollection('tests');
expect(
testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field,
).toBe(true);
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName);
const tableInfo0 = await getTableInfo();
expect(tableInfo0['other_field']).toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: 'otherField',
},
});
expect(testCollection.model.rawAttributes['otherField']).toBeUndefined();
const tableInfo1 = await getTableInfo();
expect(tableInfo1['other_field']).not.toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: 'testField',
},
});
expect(testCollection.model.rawAttributes['testField']).toBeUndefined();
const tableInfo2 = await getTableInfo();
expect(tableInfo2['test_field']).toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: 'test_field',
},
});
const tableInfo3 = await getTableInfo();
expect(tableInfo3['test_field']).toBeDefined();
await Field.repository.destroy({
context: {},
filter: {
name: 'test123',
},
});
const tableInfo4 = await getTableInfo();
expect(tableInfo4['test_field']).not.toBeDefined();
});
}); });

View File

@ -69,6 +69,8 @@ describe('field indexes', () => {
}, },
}); });
expect(field.status).toBe(200);
// create a record // create a record
const response1 = await agent.resource(tableName).create({ const response1 = await agent.resource(tableName).create({
values: { values: {

View File

@ -38,6 +38,7 @@ describe('reverseField options', () => {
reverseField: {}, reverseField: {},
}, },
}); });
const json = JSON.parse(JSON.stringify(field.toJSON())); const json = JSON.parse(JSON.stringify(field.toJSON()));
expect(json).toMatchObject({ expect(json).toMatchObject({
type: 'hasMany', type: 'hasMany',

View File

@ -396,4 +396,144 @@ describe('collections repository', () => {
}); });
expect(response1.body.data.length).toBe(2); expect(response1.body.data.length).toBe(2);
}); });
it('should update field with default value', async () => {
const createCollectionResponse = await app
.agent()
.resource('collections')
.create({
values: {
name: 'test',
fields: [
{
name: 'testField',
type: 'string',
},
],
},
});
const testField = await app.db.getRepository('fields').findOne({
filter: {
name: 'testField',
},
});
// update field with unique index
const addDefaultValueResponse = await app
.agent()
.resource('fields')
.update({
values: {
defaultValue: '1231',
},
filterByTk: testField.get('key'),
});
expect(addDefaultValueResponse.statusCode).toEqual(200);
});
it('should create collection field', async () => {
await app
.agent()
.resource('collections')
.create({
values: {
name: 'test',
},
});
const addFieldResponse = await app
.agent()
.resource('fields')
.create({
values: {
name: 'testField',
collectionName: 'test',
type: 'string',
},
});
expect(addFieldResponse.statusCode).toEqual(200);
const collection = app.db.getCollection('test');
const columnName = collection.model.rawAttributes.testField.field;
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.model.tableName);
expect(tableInfo[columnName]).toBeDefined();
});
it('should update field with unique index', async () => {
const createCollectionResponse = await app
.agent()
.resource('collections')
.create({
values: {
name: 'test',
fields: [
{
name: 'testField',
type: 'string',
},
],
},
});
expect(createCollectionResponse.statusCode).toEqual(200);
const testField = await app.db.getRepository('fields').findOne({
filter: {
name: 'testField',
},
});
// update field with unique index
const addIndexResponse = await app
.agent()
.resource('fields')
.update({
values: {
unique: true,
},
filterByTk: testField.get('key'),
});
expect(addIndexResponse.statusCode).toEqual(200);
const indexes = (await app.db.sequelize
.getQueryInterface()
.showIndex(app.db.getCollection('test').model.tableName)) as any;
const columnName = app.db.getCollection('test').model.rawAttributes.testField.field;
expect(
indexes.find(
(index) => index.unique == true && index.fields[0].attribute == columnName && index.fields.length === 1,
),
).toBeDefined();
const removeIndexResponse = await app
.agent()
.resource('fields')
.update({
values: {
unique: false,
},
filterByTk: testField.get('key'),
});
expect(removeIndexResponse.statusCode).toEqual(200);
const afterIndexes = (await app.db.sequelize
.getQueryInterface()
.showIndex(app.db.getCollection('test').model.tableName)) as any;
expect(
afterIndexes.find(
(index) => index.unique == true && index.fields[0].attribute == columnName && index.fields.length === 1,
),
).not.toBeDefined();
});
}); });

View File

@ -64,7 +64,7 @@ pgOnly()('Inherited Collection', () => {
expect(response.statusCode).toBe(500); expect(response.statusCode).toBe(500);
}); });
it('can create relation with child table', async () => { it('can create relation with child table', async () => {
await agent.resource('collections').create({ await agent.resource('collections').create({
values: { values: {
name: 'a', name: 'a',
@ -100,6 +100,8 @@ pgOnly()('Inherited Collection', () => {
}, },
}); });
const collectionB = app.db.getCollection('b');
const res = await agent.resource('b').create({ const res = await agent.resource('b').create({
values: { values: {
af: 'a1', af: 'a1',

View File

@ -1,5 +1,6 @@
import { Database, MigrationContext } from '@nocobase/database'; import { Database, MigrationContext } from '@nocobase/database';
import Migrator from '../../migrations/20221121111113-update-id-to-bigint'; import Migrator from '../../migrations/20221121111113-update-id-to-bigint';
import lodash from 'lodash';
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe : describe.skip); const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe : describe.skip);
@ -72,6 +73,11 @@ excludeSqlite()('update id to bigint test', () => {
.describeTable( .describeTable(
db.getCollection(collectionName) ? db.getCollection(collectionName).model.tableName : collectionName, db.getCollection(collectionName) ? db.getCollection(collectionName).model.tableName : collectionName,
); );
if (process.env.DB_UNDERSCORED) {
fieldName = lodash.snakeCase(fieldName);
}
console.log(`${collectionName}, ${fieldName}`, tableInfo[fieldName].type); console.log(`${collectionName}, ${fieldName}`, tableInfo[fieldName].type);
expect(tableInfo[fieldName].type).toBe('BIGINT'); expect(tableInfo[fieldName].type).toBe('BIGINT');
}; };

View File

@ -27,15 +27,21 @@ describe('collections.fields', () => {
], ],
}, },
}); });
const collection = app.db.getCollection('test1'); const collection = app.db.getCollection('test1');
const field = collection.getField('name'); const field = collection.getField('name');
expect(collection.hasField('name')).toBeTruthy(); expect(collection.hasField('name')).toBeTruthy();
const r1 = await field.existsInDb(); const r1 = await field.existsInDb();
expect(r1).toBeTruthy(); expect(r1).toBeTruthy();
await app.agent().resource('collections.fields', 'test1').destroy({ await app.agent().resource('collections.fields', 'test1').destroy({
filterByTk: 'name', filterByTk: 'name',
}); });
expect(collection.hasField('name')).toBeFalsy(); expect(collection.hasField('name')).toBeFalsy();
const r2 = await field.existsInDb(); const r2 = await field.existsInDb();
expect(r2).toBeFalsy(); expect(r2).toBeFalsy();
}); });

View File

@ -30,6 +30,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
const queryGenerator = queryInterface.queryGenerator as any; const queryGenerator = queryInterface.queryGenerator as any;
const updateToBigInt = async (model, fieldName) => { const updateToBigInt = async (model, fieldName) => {
const columnName = model.rawAttributes[fieldName].field;
let sql; let sql;
const tableName = model.tableName; const tableName = model.tableName;
@ -51,7 +52,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
if (model.rawAttributes[fieldName].type instanceof DataTypes.INTEGER) { if (model.rawAttributes[fieldName].type instanceof DataTypes.INTEGER) {
if (db.inDialect('postgres')) { if (db.inDialect('postgres')) {
sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${fieldName}" SET DATA TYPE BIGINT;`; sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" SET DATA TYPE BIGINT;`;
} else if (db.inDialect('mysql')) { } else if (db.inDialect('mysql')) {
const dataTypeOrOptions = model.rawAttributes[fieldName]; const dataTypeOrOptions = model.rawAttributes[fieldName];
const attributeName = fieldName; const attributeName = fieldName;
@ -68,6 +69,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
table: tableName, table: tableName,
}, },
); );
sql = queryGenerator.changeColumnQuery(tableName, query); sql = queryGenerator.changeColumnQuery(tableName, query);
sql = sql.replace(' PRIMARY KEY;', ' ;'); sql = sql.replace(' PRIMARY KEY;', ' ;');
@ -83,7 +85,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
} }
if (db.inDialect('postgres')) { if (db.inDialect('postgres')) {
const sequenceQuery = `SELECT pg_get_serial_sequence('"${model.tableName}"', '${fieldName}');`; const sequenceQuery = `SELECT pg_get_serial_sequence('"${model.tableName}"', '${columnName}');`;
const [result] = await this.sequelize.query(sequenceQuery, {}); const [result] = await this.sequelize.query(sequenceQuery, {});
const sequenceName = result[0]['pg_get_serial_sequence']; const sequenceName = result[0]['pg_get_serial_sequence'];

View File

@ -1,5 +1,5 @@
import Database, { Collection, Field, MagicAttributeModel } from '@nocobase/database'; import Database, { Collection, MagicAttributeModel, snakeCase } from '@nocobase/database';
import { SyncOptions, Transactionable, UniqueConstraintError } from 'sequelize'; import { SyncOptions, Transactionable } from 'sequelize';
interface LoadOptions extends Transactionable { interface LoadOptions extends Transactionable {
// TODO // TODO
@ -10,58 +10,6 @@ interface MigrateOptions extends SyncOptions, Transactionable {
isNew?: boolean; isNew?: boolean;
} }
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;
@ -106,10 +54,24 @@ export class FieldModel extends MagicAttributeModel {
field = await this.load({ field = await this.load({
transaction: options.transaction, transaction: options.transaction,
}); });
if (!field) { if (!field) {
return; return;
} }
await migrate(field, options); const collection = this.getFieldCollection();
if (isNew && collection.model.rawAttributes[this.get('name')] && this.get('unique')) {
// trick: set unique to false to avoid auto sync unique index
collection.model.rawAttributes[this.get('name')].unique = false;
}
await field.sync(options);
if (isNew && this.get('unique')) {
await this.syncUniqueIndex({
transaction: options.transaction,
});
}
} catch (error) { } catch (error) {
// field sync failed, delete from memory // field sync failed, delete from memory
if (isNew && field) { if (isNew && field) {
@ -136,6 +98,56 @@ export class FieldModel extends MagicAttributeModel {
}); });
} }
async syncUniqueIndex(options: Transactionable) {
const unique = this.get('unique');
const collection = this.getFieldCollection();
const field = collection.getField(this.get('name'));
const columnName = collection.model.rawAttributes[this.get('name')].field;
const tableName = collection.model.tableName;
const queryInterface = this.db.sequelize.getQueryInterface() as any;
const existsIndexes = await queryInterface.showIndex(collection.model.tableName, {
transaction: options.transaction,
});
const existUniqueIndex = existsIndexes.find((item) => {
return item.unique && item.fields[0].attribute === columnName && item.fields.length === 1;
});
let existsUniqueConstraint;
let constraintName = `${tableName}_${field.name}_uk`;
if (existUniqueIndex) {
const existsUniqueConstraints = await queryInterface.showConstraint(
collection.model.tableName,
constraintName,
{},
);
existsUniqueConstraint = existsUniqueConstraints[0];
}
if (unique && !existsUniqueConstraint) {
// @ts-ignore
await collection.sync({ ...options, force: false, alter: { drop: false } });
await queryInterface.addConstraint(tableName, {
type: 'unique',
fields: [columnName],
name: constraintName,
transaction: options.transaction,
});
}
if (!unique && existsUniqueConstraint) {
await queryInterface.removeConstraint(collection.model.tableName, constraintName, {
transaction: options.transaction,
});
}
}
async syncDefaultValue( async syncDefaultValue(
options: Transactionable & { options: Transactionable & {
defaultValue: any; defaultValue: any;
@ -153,7 +165,7 @@ export class FieldModel extends MagicAttributeModel {
await queryInterface.changeColumn( await queryInterface.changeColumn(
collection.model.tableName, collection.model.tableName,
this.get('name'), collection.model.rawAttributes[this.get('name')].field,
{ {
type: field.dataType, type: field.dataType,
defaultValue: options.defaultValue, defaultValue: options.defaultValue,

View File

@ -5,6 +5,7 @@ import { UniqueConstraintError } from 'sequelize';
import PluginErrorHandler from '@nocobase/plugin-error-handler'; import PluginErrorHandler from '@nocobase/plugin-error-handler';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { Mutex } from 'async-mutex';
import { CollectionRepository } from '.'; import { CollectionRepository } from '.';
import { import {
@ -122,7 +123,7 @@ export class CollectionManagerPlugin extends Plugin {
const next = currentOptions['unique']; const next = currentOptions['unique'];
if (Boolean(prev) !== Boolean(next)) { if (Boolean(prev) !== Boolean(next)) {
await model.migrate({ transaction }); await model.syncUniqueIndex({ transaction });
} }
} }
@ -149,8 +150,12 @@ export class CollectionManagerPlugin extends Plugin {
// before field remove // before field remove
this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db)); this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db));
const mutex = new Mutex();
this.app.db.on('fields.beforeDestroy', async (model: FieldModel, options) => { this.app.db.on('fields.beforeDestroy', async (model: FieldModel, options) => {
await model.remove(options); await mutex.runExclusive(async () => {
await model.remove(options);
});
}); });
this.app.db.on('collections.beforeDestroy', async (model: CollectionModel, options) => { this.app.db.on('collections.beforeDestroy', async (model: CollectionModel, options) => {

View File

@ -1,5 +1,5 @@
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer, MockServer } from '@nocobase/test';
import { Database } from '@nocobase/database'; import { Database, Model } from '@nocobase/database';
import * as os from 'os'; import * as os from 'os';
import path from 'path'; import path from 'path';
import lodash from 'lodash'; import lodash from 'lodash';
@ -98,7 +98,11 @@ describe('dump', () => {
const collectionMeta = JSON.parse(collectionMetaFile); const collectionMeta = JSON.parse(collectionMetaFile);
expect(collectionMeta.count).toEqual(2); expect(collectionMeta.count).toEqual(2);
expect(collectionMeta.columns).toEqual(Object.keys(db.getCollection('users').model.rawAttributes)); expect(collectionMeta.columns).toEqual(
Object.keys(db.getCollection('users').model.rawAttributes).map(
(fieldName) => db.getCollection('users').model.rawAttributes[fieldName].field,
),
);
const dataPath = path.resolve(testDir, 'collections', 'users', 'data'); const dataPath = path.resolve(testDir, 'collections', 'users', 'data');

View File

@ -0,0 +1,50 @@
import { getApp } from '.';
describe('attachment', () => {
let db;
let app;
beforeEach(async () => {
app = await getApp();
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should linked to a instance', async () => {
const testCollection = db.collection({
name: 'test',
fields: [
{
name: 'name',
type: 'string',
},
{
type: 'belongsTo',
name: 'logo',
target: 'attachments',
},
],
});
await db.sync();
await testCollection.repository.create({
values: {
name: 'test',
logo: {
title: 'nocobase-logo',
filename: '682e5ad037dd02a0fe4800a3e91c283b.png',
extname: '.png',
mimetype: 'image/png',
url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/682e5ad037dd02a0fe4800a3e91c283b.png',
},
},
});
const item = await testCollection.repository.findOne({});
expect(item.get('logoId')).toBeDefined();
});
});

View File

@ -13,6 +13,8 @@ export async function getApp(options = {}): Promise<MockServer> {
acl: false, acl: false,
}); });
await app.cleanDb();
app.plugin(plugin); app.plugin(plugin);
app.db.import({ app.db.import({

View File

@ -7,7 +7,7 @@ import calculators from '../calculators';
import { JOB_STATUS } from '../constants'; import { JOB_STATUS } from '../constants';
export function sleep(ms: number) { export function sleep(ms: number) {
return new Promise(resolve => { return new Promise((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
}); });
} }
@ -26,38 +26,40 @@ export async function getApp({ manual, ...options }: MockAppOptions = {}): Promi
run(node, { result }, execution) { run(node, { result }, execution) {
return { return {
status: JOB_STATUS.RESOLVED, status: JOB_STATUS.RESOLVED,
result result,
}; };
} },
}, },
error: { error: {
run(node, input, execution) { run(node, input, execution) {
throw new Error('definite error'); throw new Error('definite error');
} },
}, },
'prompt->error': { 'prompt->error': {
run(node, input, execution) { run(node, input, execution) {
return { return {
status: JOB_STATUS.PENDING status: JOB_STATUS.PENDING,
}; };
}, },
resume(node, input, execution) { resume(node, input, execution) {
throw new Error('input failed'); throw new Error('input failed');
} },
} },
} },
}); });
if (!calculators.get('no1')) { if (!calculators.get('no1')) {
calculators.register('no1', () => 1); calculators.register('no1', () => 1);
} }
await app.db.clean({ drop: true });
await app.load(); await app.load();
await app.db.import({ await app.db.import({
directory: path.resolve(__dirname, './collections') directory: path.resolve(__dirname, './collections'),
}); });
try { try {

View File

@ -3,8 +3,6 @@ import Database from '@nocobase/database';
import { getApp, sleep } from '..'; import { getApp, sleep } from '..';
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants'; import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
describe('workflow > instructions > delay', () => { describe('workflow > instructions > delay', () => {
let app: Application; let app: Application;
let db: Database; let db: Database;
@ -16,6 +14,7 @@ describe('workflow > instructions > delay', () => {
app = await getApp(); app = await getApp();
db = app.db; db = app.db;
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
@ -24,8 +23,8 @@ describe('workflow > instructions > delay', () => {
type: 'collection', type: 'collection',
config: { config: {
mode: 1, mode: 1,
collection: 'posts' collection: 'posts',
} },
}); });
}); });
@ -37,8 +36,8 @@ describe('workflow > instructions > delay', () => {
type: 'delay', type: 'delay',
config: { config: {
duration: 2000, duration: 2000,
endStatus: JOB_STATUS.RESOLVED endStatus: JOB_STATUS.RESOLVED,
} },
}); });
const post = await PostRepo.create({ values: { title: 't1' } }); const post = await PostRepo.create({ values: { title: 't1' } });
@ -63,8 +62,8 @@ describe('workflow > instructions > delay', () => {
type: 'delay', type: 'delay',
config: { config: {
duration: 2000, duration: 2000,
endStatus: JOB_STATUS.REJECTED endStatus: JOB_STATUS.REJECTED,
} },
}); });
const post = await PostRepo.create({ values: { title: 't1' } }); const post = await PostRepo.create({ values: { title: 't1' } });
@ -89,8 +88,8 @@ describe('workflow > instructions > delay', () => {
type: 'delay', type: 'delay',
config: { config: {
duration: 2000, duration: 2000,
endStatus: JOB_STATUS.RESOLVED endStatus: JOB_STATUS.RESOLVED,
} },
}); });
const n2 = await workflow.createNode({ const n2 = await workflow.createNode({
type: 'create', type: 'create',
@ -98,11 +97,11 @@ describe('workflow > instructions > delay', () => {
collection: 'comment', collection: 'comment',
params: { params: {
values: { values: {
status: 'should be number but use string to raise an error' status: 'should be number but use string to raise an error',
} },
} },
}, },
upstreamId: n1.id upstreamId: n1.id,
}); });
await n1.setDownstream(n2); await n1.setDownstream(n2);
@ -131,8 +130,8 @@ describe('workflow > instructions > delay', () => {
type: 'delay', type: 'delay',
config: { config: {
duration: 2000, duration: 2000,
endStatus: JOB_STATUS.RESOLVED endStatus: JOB_STATUS.RESOLVED,
} },
}); });
}); });

View File

@ -8,6 +8,8 @@ describe('shop actions', () => {
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = mockServer();
await app.cleanDb();
app.plugin(Plugin); app.plugin(Plugin);
agent = app.agent(); agent = app.agent();
db = app.db; db = app.db;
@ -26,15 +28,16 @@ describe('shop actions', () => {
title: 'iPhone 14 Pro', title: 'iPhone 14 Pro',
price: 7999, price: 7999,
enabled: true, enabled: true,
inventory: 1 inventory: 1,
} },
}); });
expect(product.data.price).toEqual(7999); expect(product.data.price).toEqual(7999);
const { body: order } = await agent.resource('orders').create({ const { body: order } = await agent.resource('orders').create({
values: { values: {
productId: product.data.id productId: product.data.id,
} },
}); });
expect(order.data.totalPrice).toEqual(7999); expect(order.data.totalPrice).toEqual(7999);
expect(order.data.status).toEqual(0); expect(order.data.status).toEqual(0);
@ -43,8 +46,8 @@ describe('shop actions', () => {
filterByTk: order.data.id, filterByTk: order.data.id,
values: { values: {
provider: 'SF', provider: 'SF',
trackingNumber: '123456789' trackingNumber: '123456789',
} },
}); });
expect(deliveredOrder.data.status).toBe(2); expect(deliveredOrder.data.status).toBe(2);
expect(deliveredOrder.data.delivery.trackingNumber).toBe('123456789'); expect(deliveredOrder.data.delivery.trackingNumber).toBe('123456789');