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_PASSWORD: password
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,
tablePrefix: process.env.DB_TABLE_PREFIX,
schema: process.env.DB_SCHEMA,
underscored: process.env.DB_UNDERSCORED === 'true',
} as IDatabaseOptions;
function customLogger(queryString, queryObject) {

View File

@ -43,6 +43,6 @@ excludeSqlite()('collection', () => {
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;
beforeEach(async () => {
db = mockDatabase({
logging: console.log,
});
db = mockDatabase();
await db.clean({ drop: true });
});
@ -240,9 +238,15 @@ describe('collection sync', () => {
await collection.sync();
const tableFields = await (<any>collection.model).queryInterface.describeTable(`${db.getTablePrefix()}users`);
expect(tableFields).toHaveProperty('firstName');
expect(tableFields).toHaveProperty('lastName');
expect(tableFields).toHaveProperty('age');
if (db.options.underscored) {
expect(tableFields).toHaveProperty('first_name');
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 () => {
@ -290,9 +294,15 @@ describe('collection sync', () => {
const model = collection.model;
await collection.sync();
const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}postsTags`);
expect(tableFields['postId']).toBeDefined();
expect(tableFields['tagId']).toBeDefined();
if (db.options.underscored) {
const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}posts_tags`);
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 () => {

View File

@ -886,11 +886,13 @@ pgOnly()('collection inherits', () => {
const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName);
expect(studentTableInfo.score).toBeDefined();
expect(studentTableInfo.name).toBeDefined();
expect(studentTableInfo.id).toBeDefined();
expect(studentTableInfo.createdAt).toBeDefined();
expect(studentTableInfo.updatedAt).toBeDefined();
const getField = (name) => student.model.rawAttributes[name].field;
expect(studentTableInfo[getField('score')]).toBeDefined();
expect(studentTableInfo[getField('name')]).toBeDefined();
expect(studentTableInfo[getField('id')]).toBeDefined();
expect(studentTableInfo[getField('createdAt')]).toBeDefined();
expect(studentTableInfo[getField('updatedAt')]).toBeDefined();
});
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 { Model } from './model';
import { Repository } from './repository';
import { checkIdentifier, md5 } from './utils';
import { checkIdentifier, md5, snakeCase } from './utils';
export type RepositoryType = typeof Repository;
@ -36,6 +36,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
* @default 'options'
*/
magicAttribute?: string;
[key: string]: any;
}
@ -72,11 +73,11 @@ export class Collection<
constructor(options: CollectionOptions, context: CollectionContext) {
super();
this.checkOptions(options);
this.context = context;
this.options = options;
this.checkOptions(options);
this.bindFieldEventListener();
this.modelInit();
@ -93,15 +94,31 @@ export class Collection<
private checkOptions(options: CollectionOptions) {
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() {
const { name, tableName } = this.options;
const { name } = this.options;
return {
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
modelName: name,
sequelize: this.context.database.sequelize,
tableName: tableName || name,
tableName: this.tableName(),
};
}
@ -112,8 +129,10 @@ export class Collection<
if (this.model) {
return;
}
const { name, model, autoGenId = true } = this.options;
let M: ModelStatic<Model> = Model;
if (this.context.database.sequelize.isDefined(name)) {
const m = this.context.database.sequelize.model(name);
if ((m as any).isThrough) {
@ -126,11 +145,13 @@ export class Collection<
return;
}
}
if (typeof model === 'string') {
M = this.context.database.models.get(model) || Model;
} else if (model) {
M = model;
}
// @ts-ignore
this.model = class extends M {};
this.model.init(null, this.sequelizeModelOptions());
@ -183,8 +204,31 @@ export class Collection<
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 {
checkIdentifier(name);
this.checkFieldType(name, options);
const { database } = this.context;
@ -362,9 +406,13 @@ export class Collection<
if (!index) {
return;
}
// collection defined indexes
let indexes: any = this.model.options.indexes || [];
let indexName = [];
let indexItem;
if (typeof index === 'string') {
indexItem = {
fields: [index],
@ -379,13 +427,17 @@ export class Collection<
indexItem = index;
indexName = index.fields;
}
if (lodash.isEqual(this.model.primaryKeyAttributes, indexName)) {
return;
}
const name: string = this.model.primaryKeyAttributes.join(',');
if (name.startsWith(`${indexName.join(',')},`)) {
return;
}
for (const item of indexes) {
if (lodash.isEqual(item.fields, indexName)) {
return;
@ -395,11 +447,13 @@ export class Collection<
return;
}
}
if (!indexItem) {
return;
}
indexes.push(indexItem);
this.model.options.indexes = indexes;
const tableName = this.model.getTableName();
// @ts-ignore
this.model._indexes = this.model.options.indexes
@ -411,6 +465,7 @@ export class Collection<
}
return item;
});
this.refreshIndexes();
}
@ -430,15 +485,17 @@ export class Collection<
refreshIndexes() {
// @ts-ignore
const indexes: any[] = this.model._indexes;
// @ts-ignore
this.model._indexes = indexes.filter((item) => {
for (const field of item.fields) {
if (!this.model.rawAttributes[field]) {
return false;
this.model._indexes = lodash.uniqBy(
indexes.map((item) => {
if (this.options.underscored) {
item.fields = item.fields.map((field) => snakeCase(field));
}
}
return true;
});
return item;
}),
'name',
);
}
async sync(syncOptions?: SyncOptions) {

View File

@ -60,6 +60,7 @@ import {
UpdateWithAssociationsListener,
ValidateListener
} from './types';
import { snakeCase } from './utils';
export interface MergeOptions extends merge.Options {}
@ -76,6 +77,7 @@ export interface IDatabaseOptions extends Options {
tablePrefix?: string;
migrator?: any;
usingBigIntForId?: boolean;
underscored?: boolean;
}
export type DatabaseOptions = IDatabaseOptions;
@ -167,7 +169,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
constructor(options: DatabaseOptions) {
super();
this.version = new DatabaseVersion(this);
const opts = {
@ -265,6 +266,12 @@ export class Database extends EventEmitter implements AsyncEmitter {
}
initListener() {
this.on('beforeDefine', (model, options) => {
if (this.options.underscored) {
options.underscored = true;
}
});
this.on('afterCreate', async (instance) => {
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) {
@ -328,6 +356,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
collection<Attributes = any, CreateAttributes = Attributes>(
options: CollectionOptions,
): Collection<Attributes, CreateAttributes> {
if (this.options.underscored) {
options.underscored = true;
}
this.emit('beforeDefineCollection', options);
const hasValidInheritsOptions = (() => {
@ -477,6 +509,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
throw Error(`unsupported field type ${type}`);
}
if (options.field && this.options.underscored) {
options.field = snakeCase(options.field);
}
return new Field(options, context);
}
@ -525,11 +561,17 @@ export class Database extends EventEmitter implements AsyncEmitter {
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({
transaction: options?.transaction,
});
return !!tables.find((table) => table === `${this.getTablePrefix()}${name}`);
return tables.includes(this.getCollection(name).model.tableName);
}
public isSqliteMemory() {

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { Collection } from '../collection';
import { Database } from '../database';
import { InheritedCollection } from '../inherited-collection';
import { ModelEventTypes } from '../types';
import { snakeCase } from '../utils';
export interface FieldContext {
database: Database;
@ -89,6 +90,18 @@ export abstract class Field {
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) {
const attribute = this.collection.model.rawAttributes[this.name];
@ -113,7 +126,12 @@ export abstract class Field {
}
if (this.collection.model.options.timestamps !== false) {
// 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;
}
}
@ -132,19 +150,28 @@ export abstract class Field {
return;
}
}
if (this.options.field && this.name !== this.options.field) {
// field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用
this.remove();
return;
}
// if (this.options.field && this.name !== this.options.field) {
// // field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用
// this.remove();
// return;
// }
const columnReferencesCount = _.filter(
this.collection.model.rawAttributes,
(attr) => attr.field == this.columnName(),
).length;
if (
await this.existsInDb({
(await this.existsInDb({
transaction: options?.transaction,
})
})) &&
columnReferencesCount == 1
) {
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();
}
@ -154,18 +181,20 @@ export abstract class Field {
};
let sql;
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')) {
sql = `
select column_name
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 {
sql = `
select column_name
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@ export class OptionsParser {
for (const sortKey of sort) {
let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
let sortField: Array<any> = sortKey.replace('-', '').split('.');
if (this.database.inDialect('postgres', 'sqlite')) {
direction = `${direction} NULLS LAST`;
}
@ -86,7 +87,11 @@ export class OptionsParser {
sortField[i] = associationModel.associations[associationKey].target;
associationModel = sortField[i];
}
} else {
const rawField = this.model.rawAttributes[sortField[0]];
sortField[0] = rawField?.field || sortField[0];
}
sortField.push(direction);
if (this.database.inDialect('mysql')) {
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 association = <BelongsToMany>this.association;
const throughModel = this.throughModel();
const instancesToIds = (instances) => {
return instances.map((instance) => instance.get(this.targetKey()));
};
@ -69,7 +71,7 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
// Through Table
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({
[association.otherKey]: {
[throughModel.rawAttributes[association.otherKey].field]: {
[Op.in]: ids,
},
});

View File

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

View File

@ -124,7 +124,9 @@ AND NOT attisdropped
if (options.alter) {
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]) {
await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options);
}

View File

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

View File

@ -1,6 +1,7 @@
import crypto from 'crypto';
import { IdentifierError } from './errors/identifier-error';
import { Model } from './model';
import lodash from 'lodash';
type HandleAppendsQueryOptions = {
templateModel: any;
@ -73,3 +74,11 @@ export function checkIdentifier(value: string) {
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();
}
});
this.app.on('beforeUpgrade', async () => {
await this.collection.sync();
});
@ -195,6 +196,7 @@ export class PluginManager {
if (this.plugins.has(pluginName)) {
throw new Error(`plugin name [${pluginName}] already exists`);
}
this.plugins.set(pluginName, instance);
return instance;
}

View File

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

View File

@ -1,6 +1,6 @@
import { NoPermissionError } from '@nocobase/acl';
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 lodash from 'lodash';
import { resolve } from 'path';
@ -262,6 +262,7 @@ export class PluginACL extends Plugin {
this.app.db.on('rolesResources.afterDestroy', async (model, options) => {
const role = this.acl.getRole(model.get('roleName'));
if (role) {
role.revokeResource(model.get('name'));
}
@ -281,6 +282,7 @@ export class PluginACL extends Plugin {
const { transaction } = options;
const collectionName = model.get('collectionName');
const fieldName = model.get('name');
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(
Model.getTableName(),
{
// ...queryParams,
where: queryParams.where,
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],
includeIgnoreAttributes: false,
// include: queryParams.include,
},
Model,
);
const whereCase = actionSql.match(/WHERE (.*?);/)[1];
conditions.push({
whereCase,
action,

View File

@ -19,6 +19,32 @@ describe('collections repository', () => {
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 () => {
// 什么都没提供,随机 name 和 key
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
const response1 = await agent.resource(tableName).create({
values: {

View File

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

View File

@ -396,4 +396,144 @@ describe('collections repository', () => {
});
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);
});
it('can create relation with child table', async () => {
it('can create relation with child table', async () => {
await agent.resource('collections').create({
values: {
name: 'a',
@ -100,6 +100,8 @@ pgOnly()('Inherited Collection', () => {
},
});
const collectionB = app.db.getCollection('b');
const res = await agent.resource('b').create({
values: {
af: 'a1',

View File

@ -1,5 +1,6 @@
import { Database, MigrationContext } from '@nocobase/database';
import Migrator from '../../migrations/20221121111113-update-id-to-bigint';
import lodash from 'lodash';
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe : describe.skip);
@ -72,6 +73,11 @@ excludeSqlite()('update id to bigint test', () => {
.describeTable(
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);
expect(tableInfo[fieldName].type).toBe('BIGINT');
};

View File

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

View File

@ -30,6 +30,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
const queryGenerator = queryInterface.queryGenerator as any;
const updateToBigInt = async (model, fieldName) => {
const columnName = model.rawAttributes[fieldName].field;
let sql;
const tableName = model.tableName;
@ -51,7 +52,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
if (model.rawAttributes[fieldName].type instanceof DataTypes.INTEGER) {
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')) {
const dataTypeOrOptions = model.rawAttributes[fieldName];
const attributeName = fieldName;
@ -68,6 +69,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
table: tableName,
},
);
sql = queryGenerator.changeColumnQuery(tableName, query);
sql = sql.replace(' PRIMARY KEY;', ' ;');
@ -83,7 +85,7 @@ export default class UpdateIdToBigIntMigrator extends Migration {
}
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 sequenceName = result[0]['pg_get_serial_sequence'];

View File

@ -1,5 +1,5 @@
import Database, { Collection, Field, MagicAttributeModel } from '@nocobase/database';
import { SyncOptions, Transactionable, UniqueConstraintError } from 'sequelize';
import Database, { Collection, MagicAttributeModel, snakeCase } from '@nocobase/database';
import { SyncOptions, Transactionable } from 'sequelize';
interface LoadOptions extends Transactionable {
// TODO
@ -10,58 +10,6 @@ interface MigrateOptions extends SyncOptions, Transactionable {
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 {
get db(): Database {
return (<any>this.constructor).database;
@ -106,10 +54,24 @@ export class FieldModel extends MagicAttributeModel {
field = await this.load({
transaction: options.transaction,
});
if (!field) {
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) {
// field sync failed, delete from memory
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(
options: Transactionable & {
defaultValue: any;
@ -153,7 +165,7 @@ export class FieldModel extends MagicAttributeModel {
await queryInterface.changeColumn(
collection.model.tableName,
this.get('name'),
collection.model.rawAttributes[this.get('name')].field,
{
type: field.dataType,
defaultValue: options.defaultValue,

View File

@ -5,6 +5,7 @@ import { UniqueConstraintError } from 'sequelize';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import { Plugin } from '@nocobase/server';
import { Mutex } from 'async-mutex';
import { CollectionRepository } from '.';
import {
@ -122,7 +123,7 @@ export class CollectionManagerPlugin extends Plugin {
const next = currentOptions['unique'];
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
this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db));
const mutex = new Mutex();
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) => {

View File

@ -1,5 +1,5 @@
import { mockServer, MockServer } from '@nocobase/test';
import { Database } from '@nocobase/database';
import { Database, Model } from '@nocobase/database';
import * as os from 'os';
import path from 'path';
import lodash from 'lodash';
@ -98,7 +98,11 @@ describe('dump', () => {
const collectionMeta = JSON.parse(collectionMetaFile);
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');

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,
});
await app.cleanDb();
app.plugin(plugin);
app.db.import({

View File

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

View File

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

View File

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