test: add automated testing (#4098)

* test: string includes operator

* chore: operator test coverage

* chore: database utils test

* chore: acl test

* chore: no permission error

* chore: code

* fix: run coverage test

* chore: datasource test

* chore: datasource test

* chore: datasource test

* chore: datasource test

* chore: datasource test

* chore: datasource

* fix: build

* chore: plugin data source manager test

* chore: acl test

* chore: query interface test

* chore: ui schema storage test

* chore: save template test

* chore: ui schema insert position action

* chore: ignore migration

* chore: plugin acl test

* chore: ignore command in coverage

* chore: ignore

* chore: remove db2resource

* chore: ignore migration

* chore: ipc server test

* chore: database test

* chore: database api comments

* chore: value parser test

* chore: build

* chore: backup & restore test

* chore: plugin manager test

* chore: pm

* chore: pm ignore

* chore: skip migration

* chore: remove unused code

* fix: import

* chore: remove unused code

* chore: remove unused code

* fix: action test

* chore: data wrapping middleware

* fix: build

* fix: build

* fix: build

* test: fix T-4105

* chore: test

* fix: data-source-manager test

* fix: sql collection test

* fix: test

* fix: test

* fix: test

* fix: typo

* chore: datasource manager test

* chore: console.log

---------

Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
ChengLei Shao 2024-04-26 17:44:59 +08:00 committed by GitHub
parent c5811315aa
commit 71e8d07f15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
137 changed files with 1833 additions and 1496 deletions

View File

@ -41,6 +41,28 @@ describe('acl', () => {
}); });
}); });
it('should getRole', () => {
const role = acl.define({
role: 'admin',
actions: {
'posts:edit': {
own: true,
},
},
});
expect(acl.getRole('admin')).toBe(role);
});
it('should set available action', () => {
acl.setAvailableAction('edit', {
displayName: 'Edit',
});
const action = acl.getAvailableAction('edit');
expect(action.name).toBe('edit');
});
it('should define role with predicate', () => { it('should define role with predicate', () => {
acl.setAvailableAction('edit', { acl.setAvailableAction('edit', {
type: 'old-data', type: 'old-data',

View File

@ -0,0 +1,30 @@
import { skip } from '../skip-middleware';
describe('Skip Middleware', () => {
it('should skip middleware', async () => {
const skipMiddleware = skip({ resourceName: 'posts', actionName: 'list' });
const ctx: any = {
action: {
resourceName: 'posts',
actionName: 'list',
},
permission: {},
};
await skipMiddleware(ctx, async () => {
expect(ctx.permission.skip).toBeTruthy();
});
const ctx2: any = {
action: {
resourceName: 'posts',
actionName: 'create',
},
permission: {},
};
await skipMiddleware(ctx2, async () => {
expect(ctx2.permission.skip).toBeFalsy();
});
});
});

View File

@ -77,11 +77,6 @@ export class ACL extends EventEmitter {
*/ */
actionAlias = new Map<string, string>(); actionAlias = new Map<string, string>();
/**
* @internal
*/
configResources: string[] = [];
protected availableActions = new Map<string, ACLAvailableAction>(); protected availableActions = new Map<string, ACLAvailableAction>();
protected fixedParamsManager = new FixedParamsManager(); protected fixedParamsManager = new FixedParamsManager();
@ -147,27 +142,6 @@ export class ACL extends EventEmitter {
return this.roles.delete(name); return this.roles.delete(name);
} }
/**
* @internal
*/
registerConfigResources(names: string[]) {
names.forEach((name) => this.registerConfigResource(name));
}
/**
* @internal
*/
registerConfigResource(name: string) {
this.configResources.push(name);
}
/**
* @internal
*/
isConfigResource(name: string) {
return this.configResources.includes(name);
}
setAvailableAction(name: string, options: AvailableActionOptions = {}) { setAvailableAction(name: string, options: AvailableActionOptions = {}) {
this.availableActions.set(name, new ACLAvailableAction(name, options)); this.availableActions.set(name, new ACLAvailableAction(name, options));

View File

@ -3,5 +3,4 @@ export * from './acl-available-action';
export * from './acl-available-strategy'; export * from './acl-available-strategy';
export * from './acl-resource'; export * from './acl-resource';
export * from './acl-role'; export * from './acl-role';
export * from './no-permission-error';
export * from './skip-middleware'; export * from './skip-middleware';

View File

@ -1,7 +0,0 @@
class NoPermissionError extends Error {
constructor(...args) {
super(...args);
}
}
export { NoPermissionError };

View File

@ -4,7 +4,7 @@ import Koa from 'koa';
import bodyParser from 'koa-bodyparser'; import bodyParser from 'koa-bodyparser';
import qs from 'qs'; import qs from 'qs';
import supertest, { SuperAgentTest } from 'supertest'; import supertest, { SuperAgentTest } from 'supertest';
import db2resource from '../../../server/src/middlewares/db2resource'; import db2resource from './db2resource';
interface ActionParams { interface ActionParams {
fields?: string[]; fields?: string[];
@ -30,6 +30,7 @@ interface ActionParams {
* @deprecated * @deprecated
*/ */
associatedIndex?: string; associatedIndex?: string;
[key: string]: any; [key: string]: any;
} }
@ -44,6 +45,7 @@ interface SortActionParams {
method?: string; method?: string;
target?: any; target?: any;
sticky?: boolean; sticky?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -54,6 +56,7 @@ interface Resource {
update: (params?: ActionParams) => Promise<supertest.Response>; update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>; destroy: (params?: ActionParams) => Promise<supertest.Response>;
sort: (params?: SortActionParams) => Promise<supertest.Response>; sort: (params?: SortActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>; [name: string]: (params?: ActionParams) => Promise<supertest.Response>;
} }

View File

@ -0,0 +1,149 @@
import { list } from '../default-actions/list';
import { vi } from 'vitest';
import { createMoveAction } from '../default-actions/move';
describe('action test', () => {
describe('list action', async () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should list with paginate', async () => {
const listAction = list;
const ctx: any = {
getCurrentRepository() {
return {};
},
action: {
params: {
paginate: true,
},
},
};
vi.spyOn(ctx, 'getCurrentRepository').mockImplementation(() => {
return {
findAndCount: async () => [
[
{
id: 1,
name: 'test',
},
{
id: 2,
name: 'test2',
},
],
2,
],
};
});
await listAction(ctx, () => {});
expect(ctx.body).toMatchObject({
count: 2,
rows: [
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
],
page: 1,
pageSize: 50,
totalPage: 1,
});
});
it('should list with non paginate', async () => {
const listAction = list;
const ctx: any = {
getCurrentRepository() {
return {};
},
action: {
params: {
paginate: false,
},
},
};
vi.spyOn(ctx, 'getCurrentRepository').mockImplementation(() => {
return {
find: async () => [
{
id: 1,
name: 'test',
},
{
id: 2,
name: 'test2',
},
],
};
});
await listAction(ctx, () => {});
expect(ctx.body).toMatchObject([
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
]);
});
});
describe('move action', async () => {
it('should call database move action', async () => {
const dbMove = vi.fn();
const moveAction = createMoveAction(dbMove);
const ctx: any = {
getCurrentRepository() {
return {
database: {},
};
},
action: {
params: {
filterByTk: 1,
targetCollection: 'test',
},
},
};
await moveAction(ctx, () => {});
expect(dbMove).toHaveBeenCalled();
});
it('should move when repository can move', async () => {
const moveAction = createMoveAction(() => {});
const ctx: any = {
getCurrentRepository() {
return {};
},
action: {
params: {
filterByTk: 1,
targetCollection: 'test',
},
},
};
const moveFn = vi.fn();
vi.spyOn(ctx, 'getCurrentRepository').mockImplementation(() => {
return {
move: async () => {
moveFn();
},
};
});
await moveAction(ctx, () => {});
expect(moveFn).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,79 @@
import { CollectionManager } from '@nocobase/data-source-manager';
import { Repository } from '../repository';
describe('Collection Manager', () => {
it('should define collection', async () => {
const collectionManager = new CollectionManager();
collectionManager.defineCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
});
const UsersCollection = collectionManager.getCollection('users');
expect(UsersCollection).toBeTruthy();
expect(collectionManager.hasCollection('users')).toBeTruthy();
});
it('should extend collection', async () => {
const collectionManager = new CollectionManager();
collectionManager.defineCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
});
const UsersCollection = collectionManager.getCollection('users');
collectionManager.extendCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'age',
},
],
});
expect(UsersCollection.getField('age')).toBeTruthy();
});
it('should register repository', async () => {
class MockRepository extends Repository {
async find() {
return [];
}
}
const collectionManager = new CollectionManager();
collectionManager.registerRepositories({
MockRepository: MockRepository,
});
collectionManager.defineCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
repository: 'MockRepository',
});
const UsersCollection = collectionManager.getCollection('users');
expect(UsersCollection.repository).toBe(MockRepository);
});
});

View File

@ -1,6 +1,7 @@
import { createMockServer, mockDatabase, supertest } from '@nocobase/test'; import { createMockServer, mockDatabase, supertest } from '@nocobase/test';
import { SequelizeDataSource } from '../sequelize-data-source'; import { SequelizeDataSource } from '../sequelize-data-source';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { DataSourceManager } from '@nocobase/data-source-manager';
describe('example', () => { describe('example', () => {
test.skip('case1', async () => { test.skip('case1', async () => {
@ -198,4 +199,25 @@ describe('example', () => {
await app.destroy(); await app.destroy();
}); });
it('should throw error when request datasource not exists', async () => {
const dataSourceManager = new DataSourceManager();
const middleware = dataSourceManager.middleware();
const ctx = {
get: () => 'main',
throw: (message) => {
throw new Error(message);
},
};
let err;
try {
await middleware(ctx, () => {});
} catch (e) {
err = e;
}
expect(err.message).toBe('data source main does not exist');
});
}); });

View File

@ -0,0 +1,69 @@
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
describe('sequelize collection manager', () => {
it('should define collection', async () => {
const collectionManager = new SequelizeCollectionManager({
dialect: 'sqlite',
database: ':memory:',
});
collectionManager.defineCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
});
const UsersCollection = collectionManager.getCollection('users');
expect(UsersCollection).toBeTruthy();
expect(collectionManager.hasCollection('users')).toBeTruthy();
});
it('should set collection filter', async () => {
const collectionManager = new SequelizeCollectionManager({
dialect: 'sqlite',
database: ':memory:',
collectionsFilter: (collection) => {
return ['posts', 'comments'].includes(collection.name);
},
});
collectionManager.defineCollection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
});
collectionManager.defineCollection({
name: 'posts',
fields: [
{
type: 'string',
name: 'title',
},
],
});
collectionManager.defineCollection({
name: 'comments',
fields: [
{
type: 'string',
name: 'content',
},
],
});
const collections = collectionManager.getCollections();
expect(collections.length).toBe(2);
expect(collections.map((collection) => collection.name)).toEqual(['posts', 'comments']);
});
});

View File

@ -0,0 +1,14 @@
import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager';
describe('utils', () => {
it('should join collection name', async () => {
expect(joinCollectionName('main', 'users')).toBe('users');
expect(joinCollectionName('test', 'users')).toBe('test:users');
});
it('should parse collection name', async () => {
expect(parseCollectionName('main:users')).toEqual(['main', 'users']);
expect(parseCollectionName('users')).toEqual(['main', 'users']);
expect(parseCollectionName('')).toEqual([]);
});
});

View File

@ -1,7 +1,8 @@
import { FieldOptions, IField } from './types'; import { FieldOptions, IField } from './types';
export class CollectionField implements IField { export class CollectionField implements IField {
options; options: FieldOptions;
constructor(options: FieldOptions) { constructor(options: FieldOptions) {
this.updateOptions(options); this.updateOptions(options);
} }

View File

@ -8,9 +8,13 @@ export class CollectionManager implements ICollectionManager {
constructor(options = {}) {} constructor(options = {}) {}
/* istanbul ignore next -- @preserve */
getRegisteredFieldType(type) {} getRegisteredFieldType(type) {}
/* istanbul ignore next -- @preserve */
getRegisteredFieldInterface(key: string) {} getRegisteredFieldInterface(key: string) {}
/* istanbul ignore next -- @preserve */
getRegisteredModel(key: string) { getRegisteredModel(key: string) {
return this.models.get(key); return this.models.get(key);
} }
@ -22,8 +26,13 @@ export class CollectionManager implements ICollectionManager {
return this.repositories.get(key); return this.repositories.get(key);
} }
/* istanbul ignore next -- @preserve */
registerFieldTypes() {} registerFieldTypes() {}
/* istanbul ignore next -- @preserve */
registerFieldInterfaces() {} registerFieldInterfaces() {}
/* istanbul ignore next -- @preserve */
registerCollectionTemplates() {} registerCollectionTemplates() {}
registerModels(models: Record<string, any>) { registerModels(models: Record<string, any>) {
@ -46,6 +55,7 @@ export class CollectionManager implements ICollectionManager {
extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions): ICollection { extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions): ICollection {
const collection = this.getCollection(collectionOptions.name); const collection = this.getCollection(collectionOptions.name);
collection.updateOptions(collectionOptions, mergeOptions);
return collection; return collection;
} }

View File

@ -13,9 +13,7 @@ export class Collection implements ICollection {
) { ) {
this.setRepository(options.repository); this.setRepository(options.repository);
if (options.fields) { if (options.fields) {
for (const field of options.fields) { this.setFields(options.fields);
this.setField(field.name, field);
}
} }
} }
@ -24,6 +22,7 @@ export class Collection implements ICollection {
newOptions = merge(this.options, newOptions, mergeOptions); newOptions = merge(this.options, newOptions, mergeOptions);
this.options = newOptions; this.options = newOptions;
this.setFields(newOptions.fields || []);
if (options.repository) { if (options.repository) {
this.setRepository(options.repository); this.setRepository(options.repository);
} }
@ -31,6 +30,12 @@ export class Collection implements ICollection {
return this; return this;
} }
setFields(fields: any[]) {
for (const field of fields) {
this.setField(field.name, field);
}
}
setField(name: string, options: any) { setField(name: string, options: any) {
const field = new CollectionField(options); const field = new CollectionField(options);
this.fields.set(name, field); this.fields.set(name, field);

View File

@ -1,5 +0,0 @@
import Database from '@nocobase/database';
export interface DataSourceWithDatabase {
db: Database;
}

View File

@ -1,5 +1,5 @@
import { ACL } from '@nocobase/acl'; import { ACL } from '@nocobase/acl';
import { ResourceManager, getNameByParams, parseRequest } from '@nocobase/resourcer'; import { getNameByParams, parseRequest, ResourceManager } from '@nocobase/resourcer';
import EventEmitter from 'events'; import EventEmitter from 'events';
import compose from 'koa-compose'; import compose from 'koa-compose';
import { loadDefaultActions } from './load-default-actions'; import { loadDefaultActions } from './load-default-actions';
@ -34,14 +34,49 @@ export abstract class DataSource extends EventEmitter {
}); });
this.collectionManager = this.createCollectionManager(options); this.collectionManager = this.createCollectionManager(options);
this.resourceManager.registerActionHandlers(loadDefaultActions(this)); this.resourceManager.registerActionHandlers(loadDefaultActions());
if (options.acl !== false) { if (options.acl !== false) {
this.resourceManager.use(this.acl.middleware(), { tag: 'acl', after: ['auth'] }); this.resourceManager.use(this.acl.middleware(), { tag: 'acl', after: ['auth'] });
} }
} }
collectionToResourceMiddleware() { middleware(middlewares: any = []) {
const dataSource = this;
if (!this['_used']) {
for (const [fn, options] of middlewares) {
this.resourceManager.use(fn, options);
}
this['_used'] = true;
}
return async (ctx, next) => {
ctx.dataSource = dataSource;
ctx.getCurrentRepository = () => {
const { resourceName, resourceOf } = ctx.action;
return this.collectionManager.getRepository(resourceName, resourceOf);
};
return compose([this.collectionToResourceMiddleware(), this.resourceManager.middleware()])(ctx, next);
};
}
createACL() {
return new ACL();
}
createResourceManager(options) {
return new ResourceManager(options);
}
async load(options: any = {}) {}
abstract createCollectionManager(options?: any): ICollectionManager;
protected collectionToResourceMiddleware() {
return async (ctx, next) => { return async (ctx, next) => {
const params = parseRequest( const params = parseRequest(
{ {
@ -77,38 +112,4 @@ export abstract class DataSource extends EventEmitter {
return next(); return next();
}; };
} }
middleware(middlewares: any = []) {
const dataSource = this;
if (!this['_used']) {
for (const [fn, options] of middlewares) {
this.resourceManager.use(fn, options);
}
this['_used'] = true;
}
return async (ctx, next) => {
ctx.getCurrentRepository = () => {
const { resourceName, resourceOf } = ctx.action;
return this.collectionManager.getRepository(resourceName, resourceOf);
};
ctx.dataSource = dataSource;
return compose([this.collectionToResourceMiddleware(), this.resourceManager.middleware()])(ctx, next);
};
}
createACL() {
return new ACL();
}
createResourceManager(options) {
return new ResourceManager(options);
}
async load(options: any = {}) {}
abstract createCollectionManager(options?: any): ICollectionManager;
} }

View File

@ -1,5 +1,5 @@
import { assign } from '@nocobase/utils'; import { assign } from '@nocobase/utils';
import { getRepositoryFromParams, pageArgsToLimitArgs } from './utils'; import { pageArgsToLimitArgs } from './utils';
import { Context } from '@nocobase/actions'; import { Context } from '@nocobase/actions';
function totalPage(total, pageSize): number { function totalPage(total, pageSize): number {
@ -27,7 +27,7 @@ function findArgs(ctx: Context) {
async function listWithPagination(ctx: Context) { async function listWithPagination(ctx: Context) {
const { page = 1, pageSize = 50 } = ctx.action.params; const { page = 1, pageSize = 50 } = ctx.action.params;
const repository = getRepositoryFromParams(ctx); const repository = ctx.getCurrentRepository();
const options = { const options = {
context: ctx, context: ctx,
@ -53,7 +53,7 @@ async function listWithPagination(ctx: Context) {
} }
async function listWithNonPaged(ctx: Context) { async function listWithNonPaged(ctx: Context) {
const repository = getRepositoryFromParams(ctx); const repository = ctx.getCurrentRepository();
const rows = await repository.find({ context: ctx, ...findArgs(ctx) }); const rows = await repository.find({ context: ctx, ...findArgs(ctx) });

View File

@ -1,22 +1,20 @@
import actions, { Context } from '@nocobase/actions'; import { Context } from '@nocobase/actions';
import { getRepositoryFromParams } from './utils'; export function createMoveAction(databaseMoveAction) {
return async function move(ctx: Context, next) {
const repository = ctx.getCurrentRepository();
const databaseMoveAction = actions.move; if (repository.move) {
ctx.body = await repository.move(ctx.action.params);
await next();
return;
}
export async function move(ctx: Context, next) { if (repository.database) {
const repository = getRepositoryFromParams(ctx); ctx.databaseRepository = repository;
return databaseMoveAction(ctx, next);
}
if (repository.move) { throw new Error(`Repository can not handle action move for ${ctx.action.resourceName}`);
ctx.body = await repository.move(ctx.action.params); };
await next();
return;
}
if (repository.database) {
ctx.databaseRepository = repository;
return databaseMoveAction(ctx, next);
}
throw new Error(`Repository can not handle action move for ${ctx.action.resourceName}`);
} }

View File

@ -1,10 +1,10 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { DataSource } from '../data-source'; import { DataSource } from '../data-source';
import { getRepositoryFromParams } from './utils'; import { Context } from '@nocobase/actions';
export function proxyToRepository(paramKeys: string[] | ((ctx: any) => object), repositoryMethod: string) { export function proxyToRepository(paramKeys: string[] | ((ctx: any) => object), repositoryMethod: string) {
return async function (ctx, next) { return async function (ctx: Context, next: () => Promise<void>) {
const repository = getRepositoryFromParams(ctx); const repository = ctx.getCurrentRepository();
const callObj = const callObj =
typeof paramKeys === 'function' ? paramKeys(ctx) : { ...lodash.pick(ctx.action.params, paramKeys), context: ctx }; typeof paramKeys === 'function' ? paramKeys(ctx) : { ...lodash.pick(ctx.action.params, paramKeys), context: ctx };
const dataSource: DataSource = ctx.dataSource; const dataSource: DataSource = ctx.dataSource;

View File

@ -1,6 +1,3 @@
import { Context } from '@nocobase/actions';
import { DataSource, IRepository } from '../';
export function pageArgsToLimitArgs( export function pageArgsToLimitArgs(
page: number, page: number,
pageSize: number, pageSize: number,
@ -13,20 +10,3 @@ export function pageArgsToLimitArgs(
limit: pageSize, limit: pageSize,
}; };
} }
export function getRepositoryFromParams(ctx: Context): IRepository {
const { resourceName, sourceId, actionName } = ctx.action;
const dataSource: DataSource = ctx.dataSource;
if (sourceId === '_' && ['get', 'list'].includes(actionName)) {
const collection = dataSource.collectionManager.getCollection(resourceName);
return dataSource.collectionManager.getRepository(collection.name);
}
if (sourceId) {
return dataSource.collectionManager.getRepository(resourceName, sourceId);
}
return dataSource.collectionManager.getRepository(resourceName);
}

View File

@ -7,5 +7,4 @@ export * from './sequelize-data-source';
export * from './load-default-actions'; export * from './load-default-actions';
export * from './types'; export * from './types';
export * from './data-source-with-database';
export * from './utils'; export * from './utils';

View File

@ -1,7 +1,7 @@
import { list } from './default-actions/list'; import { list } from './default-actions/list';
import { move } from './default-actions/move'; import { createMoveAction } from './default-actions/move';
import { proxyToRepository } from './default-actions/proxy-to-repository'; import { proxyToRepository } from './default-actions/proxy-to-repository';
import { DataSource } from './data-source'; import globalActions from '@nocobase/actions';
type Actions = { [key: string]: { params: Array<string> | ((ctx: any) => Array<string>); method: string } }; type Actions = { [key: string]: { params: Array<string> | ((ctx: any) => Array<string>); method: string } };
@ -65,13 +65,13 @@ const actions: Actions = {
}, },
}; };
export function loadDefaultActions(dataSource: DataSource) { export function loadDefaultActions() {
return { return {
...Object.keys(actions).reduce((carry, key) => { ...Object.keys(actions).reduce((carry, key) => {
carry[key] = proxyToRepository(actions[key].params, actions[key].method); carry[key] = proxyToRepository(actions[key].params, actions[key].method);
return carry; return carry;
}, {}), }, {}),
list, list,
move, move: createMoveAction(globalActions.move),
}; };
} }

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { IModel, IRepository } from './types'; import { IModel, IRepository } from './types';
import * as console from 'console'; import * as console from 'console';
@ -5,13 +7,21 @@ export class Repository implements IRepository {
async create(options) { async create(options) {
console.log('Repository.create....'); console.log('Repository.create....');
} }
async update(options) {} async update(options) {}
async find(options?: any): Promise<IModel[]> { async find(options?: any): Promise<IModel[]> {
return []; return [];
} }
async findOne(options?: any): Promise<IModel> { async findOne(options?: any): Promise<IModel> {
return {}; return {
toJSON() {
return {};
},
};
} }
async destroy(options) {} async destroy(options) {}
count(options?: any): Promise<Number> { count(options?: any): Promise<Number> {

View File

@ -1,9 +1,12 @@
/* istanbul ignore file -- @preserve */
import Database from '@nocobase/database'; import Database from '@nocobase/database';
import { CollectionOptions, ICollection, ICollectionManager, IRepository, MergeOptions } from './types'; import { CollectionOptions, ICollection, ICollectionManager, IRepository, MergeOptions } from './types';
export class SequelizeCollectionManager implements ICollectionManager { export class SequelizeCollectionManager implements ICollectionManager {
db: Database; db: Database;
options: any; options: any;
constructor(options) { constructor(options) {
this.db = this.createDB(options); this.db = this.createDB(options);
this.options = options; this.options = options;

View File

@ -2,8 +2,6 @@ import { DataSource } from './data-source';
import { SequelizeCollectionManager } from './sequelize-collection-manager'; import { SequelizeCollectionManager } from './sequelize-collection-manager';
export class SequelizeDataSource extends DataSource { export class SequelizeDataSource extends DataSource {
async load() {}
createCollectionManager(options?: any) { createCollectionManager(options?: any) {
return new SequelizeCollectionManager(options.collectionManager); return new SequelizeCollectionManager(options.collectionManager);
} }

View File

@ -25,27 +25,44 @@ export type FieldOptions = {
export interface IField { export interface IField {
options: FieldOptions; options: FieldOptions;
} }
export interface ICollection { export interface ICollection {
repository: any; repository: IRepository;
updateOptions(options: any): void;
updateOptions(options: CollectionOptions, mergeOptions?: MergeOptions): void;
setField(name: string, options: any): IField; setField(name: string, options: any): IField;
removeField(name: string): void; removeField(name: string): void;
getFields(): Array<IField>; getFields(): Array<IField>;
getField(name: string): IField; getField(name: string): IField;
[key: string]: any; [key: string]: any;
} }
export interface IModel { export interface IModel {
toJSON: () => any;
[key: string]: any; [key: string]: any;
} }
export interface IRepository { export interface IRepository {
find(options?: any): Promise<IModel[]>; find(options?: any): Promise<IModel[]>;
findOne(options?: any): Promise<IModel>; findOne(options?: any): Promise<IModel>;
count(options?: any): Promise<Number>; count(options?: any): Promise<Number>;
findAndCount(options?: any): Promise<[IModel[], Number]>; findAndCount(options?: any): Promise<[IModel[], Number]>;
create(options: any): void;
update(options: any): void; create(options: any): any;
destroy(options: any): void;
update(options: any): any;
destroy(options: any): any;
[key: string]: any; [key: string]: any;
} }
@ -55,9 +72,13 @@ export type MergeOptions = {
export interface ICollectionManager { export interface ICollectionManager {
registerFieldTypes(types: Record<string, any>): void; registerFieldTypes(types: Record<string, any>): void;
registerFieldInterfaces(interfaces: Record<string, any>): void; registerFieldInterfaces(interfaces: Record<string, any>): void;
registerCollectionTemplates(templates: Record<string, any>): void; registerCollectionTemplates(templates: Record<string, any>): void;
registerModels(models: Record<string, any>): void; registerModels(models: Record<string, any>): void;
registerRepositories(repositories: Record<string, any>): void; registerRepositories(repositories: Record<string, any>): void;
getRegisteredRepository(key: string): IRepository; getRegisteredRepository(key: string): IRepository;
@ -67,9 +88,12 @@ export interface ICollectionManager {
extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions): ICollection; extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions): ICollection;
hasCollection(name: string): boolean; hasCollection(name: string): boolean;
getCollection(name: string): ICollection; getCollection(name: string): ICollection;
getCollections(): Array<ICollection>; getCollections(): Array<ICollection>;
getRepository(name: string, sourceId?: string | number): IRepository; getRepository(name: string, sourceId?: string | number): IRepository;
sync(): Promise<void>; sync(): Promise<void>;
} }

View File

@ -12,5 +12,6 @@ export function joinCollectionName(dataSourceName: string, collectionName: strin
if (!dataSourceName || dataSourceName === 'main') { if (!dataSourceName || dataSourceName === 'main') {
return collectionName; return collectionName;
} }
return `${dataSourceName}:${collectionName}`; return `${dataSourceName}:${collectionName}`;
} }

View File

@ -32,6 +32,8 @@ describe('filterMatch', () => {
}), }),
).toBeTruthy(); ).toBeTruthy();
expect(filterMatch(post, { 'title.$not': 't1' })).toBeFalsy();
expect( expect(
filterMatch(post, { filterMatch(post, {
$or: [{ title: 't1' }, { title: 't2' }], $or: [{ title: 't1' }, { title: 't2' }],

View File

@ -0,0 +1,68 @@
import Database, { Collection, mockDatabase } from '@nocobase/database';
describe('boolean operator', () => {
let db: Database;
let User: Collection;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase({});
await db.clean({ drop: true });
User = db.collection({
name: 'users',
fields: [{ type: 'boolean', name: 'activated' }],
});
await db.sync({
force: true,
});
});
it('should query $isFalsy', async () => {
await db.getRepository('users').create({
values: [
{
activated: false,
},
{
activated: true,
},
],
});
const res = await db.getRepository('users').find({
filter: {
'activated.$isFalsy': true,
},
});
expect(res.length).toBe(1);
});
it('should query $isTruly', async () => {
await db.getRepository('users').create({
values: [
{
activated: false,
},
{
activated: true,
},
],
});
const res = await db.getRepository('users').find({
filter: {
'activated.$isTruly': true,
},
});
expect(res.length).toBe(1);
});
});

View File

@ -24,7 +24,7 @@ describe('string operator', () => {
}); });
}); });
it('should escape underscore in inlcude operator', async () => { it('should escape underscore in include operator', async () => {
const u1 = await db.getRepository('users').create({ const u1 = await db.getRepository('users').create({
values: { values: {
name: 'names of u1', name: 'names of u1',
@ -40,6 +40,169 @@ describe('string operator', () => {
expect(u1Res).toBeNull(); expect(u1Res).toBeNull();
}); });
it('should query with include operator with array values', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'u2',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$includes': ['u1', 'u2'],
},
});
expect(res.length).toEqual(2);
});
it('should query $notIncludes with array values', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'u2',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$notIncludes': ['u1', 'u2'],
},
});
expect(res.length).toEqual(0);
});
it('should query $notIncludes', async () => {
await db.getRepository('users').create({
values: {
name: 'u1',
},
});
const res = await db.getRepository('users').find({
filter: {
'name.$notIncludes': 'u1',
},
});
expect(res.length).toEqual(0);
});
it('should query $startsWith', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'b1',
},
{
name: 'c1',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$startsWith': ['u', 'b'],
},
});
expect(res.length).toEqual(2);
});
it('should query $notStartsWith', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'v2',
},
{
name: 'b1',
},
{
name: 'b2',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$notStartsWith': ['u', 'v'],
},
});
expect(res.length).toEqual(2);
});
it('should query $endWith', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'b1',
},
{
name: 'c1',
},
{
name: 'u2',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$endWith': ['1'],
},
});
expect(res.length).toEqual(3);
});
it('should query $notEndWith', async () => {
await db.getRepository('users').create({
values: [
{
name: 'u1',
},
{
name: 'b1',
},
{
name: 'c1',
},
{
name: 'u2',
},
],
});
const res = await db.getRepository('users').find({
filter: {
'name.$notEndWith': ['1'],
},
});
expect(res.length).toEqual(1);
});
it('should query with include operator', async () => { it('should query with include operator', async () => {
const u1 = await db.getRepository('users').create({ const u1 = await db.getRepository('users').create({
values: { values: {

View File

@ -0,0 +1,78 @@
import { Database, mockDatabase } from '@nocobase/database';
describe('query interface', async () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
logging: console.log,
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should get auto incr info', async () => {
const User = db.collection({
name: 'users',
autoGenId: false,
fields: [
{
type: 'bigInt',
name: 'id',
primaryKey: true,
autoIncrement: true,
},
{
type: 'string',
name: 'name',
},
],
});
await db.sync();
await User.repository.create({
values: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
});
const incrInfo = await db.queryInterface.getAutoIncrementInfo({
tableInfo: {
tableName: User.model.tableName,
schema: process.env['DB_DIALECT'] === 'postgres' ? User.options.schema || 'public' : undefined,
},
fieldName: 'id',
});
if (db.isMySQLCompatibleDialect()) {
expect(incrInfo.currentVal).toBe(4);
} else {
expect(incrInfo.currentVal).toBe(3);
}
await db.queryInterface.setAutoIncrementVal({
tableInfo: {
tableName: User.model.tableName,
schema: process.env['DB_DIALECT'] === 'postgres' ? User.options.schema || 'public' : undefined,
},
columnName: 'id',
currentVal: 100,
seqName: incrInfo.seqName,
});
const userD = await User.repository.create({
values: {
name: 'd',
},
});
if (db.isMySQLCompatibleDialect()) {
expect(userD.id).toBe(100);
} else {
expect(userD.id).toBe(101);
}
});
});

View File

@ -11,7 +11,7 @@ describe('infer fields', () => {
db.collection({ db.collection({
name: 'users', name: 'users',
schema: db.inDialect('postgres') ? 'public' : undefined, schema: db.inDialect('postgres') ? process.env.DB_SCHEMA : undefined,
fields: [ fields: [
{ name: 'id', type: 'bigInt', interface: 'id' }, { name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'nickname', type: 'string', interface: 'input' }, { name: 'nickname', type: 'string', interface: 'input' },
@ -19,7 +19,7 @@ describe('infer fields', () => {
}); });
db.collection({ db.collection({
name: 'roles', name: 'roles',
schema: db.inDialect('postgres') ? 'public' : undefined, schema: db.inDialect('postgres') ? process.env.DB_SCHEMA : undefined,
fields: [ fields: [
{ name: 'id', type: 'bigInt', interface: 'id' }, { name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'title', type: 'string', interface: 'input' }, { name: 'title', type: 'string', interface: 'input' },
@ -28,7 +28,7 @@ describe('infer fields', () => {
}); });
db.collection({ db.collection({
name: 'roles_users', name: 'roles_users',
schema: db.inDialect('postgres') ? 'public' : undefined, schema: db.inDialect('postgres') ? process.env.DB_SCHEMA : undefined,
fields: [ fields: [
{ name: 'id', type: 'bigInt', interface: 'id' }, { name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'userId', type: 'bigInt', interface: 'id' }, { name: 'userId', type: 'bigInt', interface: 'id' },

View File

@ -1,5 +1,5 @@
import { mockDatabase } from '../../mock-database'; import { mockDatabase } from '../../mock-database';
import { SqlCollection } from '../sql-collection'; import { SqlCollection } from '../../sql-collection';
test('sql-collection', async () => { test('sql-collection', async () => {
const db = mockDatabase({ tablePrefix: '' }); const db = mockDatabase({ tablePrefix: '' });

View File

@ -0,0 +1,20 @@
import Database, { mockDatabase } from '@nocobase/database';
describe('database utils', () => {
let db: Database;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase({});
await db.clean({ drop: true });
});
it.runIf(process.env['DB_DIALECT'] === 'postgres')('should get database schema', async () => {
const schema = process.env['DB_SCHEMA'] || 'public';
expect(db.utils.schema()).toEqual(schema);
});
});

View File

@ -0,0 +1,20 @@
import { ArrayValueParser } from '@nocobase/database';
describe('array value parser', () => {
let parser: ArrayValueParser;
beforeEach(() => {
parser = new ArrayValueParser({}, {});
});
const expectValue = (value) => {
parser = new ArrayValueParser({}, {});
parser.setValue(value);
return expect(parser.getValue());
};
test('array value parser', async () => {
expectValue('tag1').toEqual(['tag1']);
expectValue('tag1,tag2').toEqual(['tag1', 'tag2']);
});
});

View File

@ -0,0 +1,33 @@
import { BooleanValueParser } from '../../value-parsers';
describe('boolean value parser', () => {
let parser: BooleanValueParser;
beforeEach(() => {
parser = new BooleanValueParser({}, {});
});
const expectValue = (value) => {
parser = new BooleanValueParser({}, {});
parser.setValue(value);
return expect(parser.getValue());
};
test('falsy value', async () => {
expectValue('n').toBe(false);
expectValue('false').toBe(false);
expectValue('0').toBe(false);
expectValue('false').toBe(false);
expectValue(false).toBe(false);
expectValue(0).toBe(false);
});
test('truthy value', async () => {
expectValue('y').toBe(true);
expectValue('true').toBe(true);
expectValue('1').toBe(true);
expectValue('yes').toBe(true);
expectValue(true).toBe(true);
expectValue(1).toBe(true);
});
});

View File

@ -0,0 +1,21 @@
import { JsonValueParser } from '../../value-parsers';
describe('array value parser', () => {
let parser: JsonValueParser;
beforeEach(() => {
parser = new JsonValueParser({}, {});
});
const expectValue = (value) => {
parser = new JsonValueParser({}, {});
parser.setValue(value);
return expect(parser.getValue());
};
test('json value parser', async () => {
expectValue('{"a":1}').toEqual({ a: 1 });
expectValue('{"a":1,"b":2}').toEqual({ a: 1, b: 2 });
expectValue('{"a":1,"b":2,"c":3}').toEqual({ a: 1, b: 2, c: 3 });
});
});

View File

@ -0,0 +1,21 @@
import { StringValueParser } from '../../value-parsers';
describe('array value parser', () => {
let parser: StringValueParser;
beforeEach(() => {
parser = new StringValueParser({}, {});
});
const expectValue = (value) => {
parser = new StringValueParser({}, {});
parser.setValue(value);
return expect(parser.getValue());
};
test('string value parser', async () => {
expectValue('{"a":1}').toEqual('{"a":1}');
expectValue('{"a":1,"b":2}').toEqual('{"a":1,"b":2}');
expectValue('{"a":1,"b":2,"c":3}').toEqual('{"a":1,"b":2,"c":3}');
});
});

View File

@ -201,7 +201,7 @@ export class Collection<
} }
/** /**
* TODO * @internal
*/ */
modelInit() { modelInit() {
if (this.model) { if (this.model) {
@ -307,6 +307,9 @@ export class Collection<
} }
} }
/**
* @internal
*/
correctOptions(options) { correctOptions(options) {
if (options.primaryKey && options.autoIncrement) { if (options.primaryKey && options.autoIncrement) {
delete options.defaultValue; delete options.defaultValue;
@ -555,9 +558,6 @@ export class Collection<
return field as Field; return field as Field;
} }
/**
* TODO
*/
updateOptions(options: CollectionOptions, mergeOptions?: any) { updateOptions(options: CollectionOptions, mergeOptions?: any) {
let newOptions = lodash.cloneDeep(options); let newOptions = lodash.cloneDeep(options);
newOptions = merge(this.options, newOptions, mergeOptions); newOptions = merge(this.options, newOptions, mergeOptions);
@ -594,12 +594,6 @@ export class Collection<
} }
} }
/**
* TODO
*
* @param name
* @param options
*/
updateField(name: string, options: FieldOptions) { updateField(name: string, options: FieldOptions) {
if (!this.hasField(name)) { if (!this.hasField(name)) {
throw new Error(`field ${name} not exists`); throw new Error(`field ${name} not exists`);
@ -701,6 +695,9 @@ export class Collection<
this.refreshIndexes(); this.refreshIndexes();
} }
/**
* @internal
*/
refreshIndexes() { refreshIndexes() {
// @ts-ignore // @ts-ignore
const indexes: any[] = this.model._indexes; const indexes: any[] = this.model._indexes;

View File

@ -148,34 +148,6 @@ export const DialectVersionAccessors = {
}, },
}; };
class DatabaseVersion {
db: Database;
constructor(db: Database) {
this.db = db;
}
async satisfies(versions) {
const accessors = DialectVersionAccessors;
for (const dialect of Object.keys(accessors)) {
if (this.db.inDialect(dialect)) {
if (!versions?.[dialect]) {
return false;
}
const [result] = (await this.db.sequelize.query(accessors[dialect].sql)) as any;
const versionResult = accessors[dialect].get(result?.[0]?.version);
if (lodash.isPlainObject(versionResult) && versionResult.dialect) {
return semver.satisfies(versionResult.version, versions[versionResult.dialect]);
}
return semver.satisfies(versionResult, versions[dialect]);
}
}
return false;
}
}
export class Database extends EventEmitter implements AsyncEmitter { export class Database extends EventEmitter implements AsyncEmitter {
sequelize: Sequelize; sequelize: Sequelize;
migrator: Umzug; migrator: Umzug;
@ -197,7 +169,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
inheritanceMap = new InheritanceMap(); inheritanceMap = new InheritanceMap();
importedFrom = new Map<string, Set<string>>(); importedFrom = new Map<string, Set<string>>();
modelHook: ModelHook; modelHook: ModelHook;
version: DatabaseVersion;
delayCollectionExtend = new Map<string, { collectionOptions: CollectionOptions; mergeOptions?: any }[]>(); delayCollectionExtend = new Map<string, { collectionOptions: CollectionOptions; mergeOptions?: any }[]>();
logger: Logger; logger: Logger;
collectionGroupManager = new CollectionGroupManager(this); collectionGroupManager = new CollectionGroupManager(this);
@ -208,8 +179,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
constructor(options: DatabaseOptions) { constructor(options: DatabaseOptions) {
super(); super();
this.version = new DatabaseVersion(this);
const opts = { const opts = {
sync: { sync: {
alter: { alter: {
@ -350,6 +319,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
return this._instanceId; return this._instanceId;
} }
/**
* @internal
*/
createMigrator({ migrations }) { createMigrator({ migrations }) {
const migratorOptions: any = this.options.migrator || {}; const migratorOptions: any = this.options.migrator || {};
const context = { const context = {
@ -371,10 +343,16 @@ export class Database extends EventEmitter implements AsyncEmitter {
}); });
} }
/**
* @internal
*/
setContext(context: any) { setContext(context: any) {
this.context = context; this.context = context;
} }
/**
* @internal
*/
sequelizeOptions(options) { sequelizeOptions(options) {
if (options.dialect === 'postgres') { if (options.dialect === 'postgres') {
if (!options.hooks) { if (!options.hooks) {
@ -393,6 +371,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
return options; return options;
} }
/**
* @internal
*/
initListener() { initListener() {
this.on('afterConnect', async (client) => { this.on('afterConnect', async (client) => {
if (this.inDialect('postgres')) { if (this.inDialect('postgres')) {
@ -644,6 +625,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
return this.getCollection(name)?.repository; return this.getCollection(name)?.repository;
} }
/**
* @internal
*/
addPendingField(field: RelationField) { addPendingField(field: RelationField) {
const associating = this.pendingFields; const associating = this.pendingFields;
const items = this.pendingFields.get(field.target) || []; const items = this.pendingFields.get(field.target) || [];
@ -651,6 +635,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
associating.set(field.target, items); associating.set(field.target, items);
} }
/**
* @internal
*/
removePendingField(field: RelationField) { removePendingField(field: RelationField) {
const items = this.pendingFields.get(field.target) || []; const items = this.pendingFields.get(field.target) || [];
const index = items.indexOf(field); const index = items.indexOf(field);
@ -693,6 +680,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
} }
/**
* @internal
*/
initOperators() { initOperators() {
const operators = new Map(); const operators = new Map();
@ -717,6 +707,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
} }
/**
* @internal
*/
buildField(options, context: FieldContext) { buildField(options, context: FieldContext) {
const { type } = options; const { type } = options;
@ -794,10 +787,11 @@ export class Database extends EventEmitter implements AsyncEmitter {
return await this.queryInterface.collectionTableExists(collection, options); return await this.queryInterface.collectionTableExists(collection, options);
} }
public isSqliteMemory() { isSqliteMemory() {
return this.sequelize.getDialect() === 'sqlite' && lodash.get(this.options, 'storage') == ':memory:'; return this.sequelize.getDialect() === 'sqlite' && lodash.get(this.options, 'storage') == ':memory:';
} }
/* istanbul ignore next -- @preserve */
async auth(options: Omit<QueryOptions, 'retry'> & { retry?: number | Pick<QueryOptions, 'retry'> } = {}) { async auth(options: Omit<QueryOptions, 'retry'> & { retry?: number | Pick<QueryOptions, 'retry'> } = {}) {
const { retry = 10, ...others } = options; const { retry = 10, ...others } = options;
const startingDelay = 50; const startingDelay = 50;
@ -833,10 +827,16 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
} }
/**
* @internal
*/
async checkVersion() { async checkVersion() {
return await checkDatabaseVersion(this); return await checkDatabaseVersion(this);
} }
/**
* @internal
*/
async prepare() { async prepare() {
if (this.isMySQLCompatibleDialect()) { if (this.isMySQLCompatibleDialect()) {
const result = await this.sequelize.query(`SHOW VARIABLES LIKE 'lower_case_table_names'`, { plain: true }); const result = await this.sequelize.query(`SHOW VARIABLES LIKE 'lower_case_table_names'`, { plain: true });

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Database, IDatabaseOptions } from './database'; import { Database, IDatabaseOptions } from './database';
import fs from 'fs'; import fs from 'fs';
import semver from 'semver'; import semver from 'semver';

View File

@ -1,3 +1,4 @@
/* istanbul ignore file -- @preserve */
import { merge } from '@nocobase/utils'; import { merge } from '@nocobase/utils';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@ -43,7 +44,7 @@ export function getConfigByEnv() {
function customLogger(queryString, queryObject) { function customLogger(queryString, queryObject) {
console.log(queryString); // outputs a string console.log(queryString); // outputs a string
if (queryObject.bind) { if (queryObject?.bind) {
console.log(queryObject.bind); // outputs an array console.log(queryObject.bind); // outputs an array
} }
} }

View File

@ -1,52 +0,0 @@
import { Database } from './database';
import FilterParser from './filter-parser';
const db = new Database({
dialect: 'sqlite',
dialectModule: require('sqlite3'),
storage: ':memory:',
});
(async () => {
const User = db.collection({
name: 'users',
fields: [{ type: 'string', name: 'name' }],
});
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{
type: 'belongsTo',
name: 'user',
},
],
});
await db.sync();
const repository = User.repository;
await repository.createMany({
records: [
{ name: 'u1', posts: [{ title: 'u1t1' }] },
{ name: 'u2', posts: [{ title: 'u2t1' }] },
{ name: 'u3', posts: [{ title: 'u3t1' }] },
],
});
const Model = User.model;
const user = await Model.findOne({
subQuery: false,
where: {
'$posts.title$': 'u1t1',
},
include: { association: 'posts', attributes: [] },
attributes: {
include: [],
},
});
console.log(user.toJSON());
})();

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Transaction, Transactionable } from 'sequelize'; import { Transaction, Transactionable } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import sqlParser from '../sql-parser'; import sqlParser from '../sql-parser';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Collection } from '../collection'; import { Collection } from '../collection';
import sqlParser from '../sql-parser'; import sqlParser from '../sql-parser';
import QueryInterface, { TableInfo } from './query-interface'; import QueryInterface, { TableInfo } from './query-interface';

View File

@ -184,18 +184,6 @@ export class BelongsToManyRepository extends MultipleRelationRepository {
return; return;
} }
extendFindOptions(findOptions) {
let joinTableAttributes;
if (lodash.get(findOptions, 'fields')) {
joinTableAttributes = [];
}
return {
...findOptions,
joinTableAttributes,
};
}
throughName() { throughName() {
return this.throughModel().name; return this.throughModel().name;
} }

View File

@ -4,6 +4,9 @@ import { SingleRelationFindOption, SingleRelationRepository } from './single-rel
type BelongsToFindOptions = SingleRelationFindOption; type BelongsToFindOptions = SingleRelationFindOption;
export class BelongsToRepository extends SingleRelationRepository { export class BelongsToRepository extends SingleRelationRepository {
/**
* @internal
*/
async filterOptions(sourceModel) { async filterOptions(sourceModel) {
const association = this.association as BelongsTo; const association = this.association as BelongsTo;

View File

@ -1,8 +1,9 @@
import { omit } from 'lodash'; import { omit } from 'lodash';
import { HasMany, Op } from 'sequelize'; import { HasMany, Op } from 'sequelize';
import { AggregateOptions, DestroyOptions, FindOptions, TK, TargetKey } from '../repository'; import { AggregateOptions, DestroyOptions, FindOptions, TargetKey, TK } from '../repository';
import { AssociatedOptions, MultipleRelationRepository } from './multiple-relation-repository'; import { AssociatedOptions, MultipleRelationRepository } from './multiple-relation-repository';
import { transaction } from './relation-repository'; import { transaction } from './relation-repository';
export class HasManyRepository extends MultipleRelationRepository { export class HasManyRepository extends MultipleRelationRepository {
async find(options?: FindOptions): Promise<any> { async find(options?: FindOptions): Promise<any> {
const targetRepository = this.targetCollection.repository; const targetRepository = this.targetCollection.repository;
@ -120,6 +121,9 @@ export class HasManyRepository extends MultipleRelationRepository {
}); });
} }
/**
* @internal
*/
accessors() { accessors() {
return (<HasMany>this.association).accessors; return (<HasMany>this.association).accessors;
} }

View File

@ -2,6 +2,9 @@ import { HasOne } from 'sequelize';
import { SingleRelationRepository } from './single-relation-repository'; import { SingleRelationRepository } from './single-relation-repository';
export class HasOneRepository extends SingleRelationRepository { export class HasOneRepository extends SingleRelationRepository {
/**
* @internal
*/
filterOptions(sourceModel) { filterOptions(sourceModel) {
const association = this.association as HasOne; const association = this.association as HasOne;

View File

@ -8,8 +8,8 @@ import {
Filter, Filter,
FindOneOptions, FindOneOptions,
FindOptions, FindOptions,
TK,
TargetKey, TargetKey,
TK,
UpdateOptions, UpdateOptions,
} from '../repository'; } from '../repository';
import { updateModelByValues } from '../update-associations'; import { updateModelByValues } from '../update-associations';
@ -23,10 +23,6 @@ export interface AssociatedOptions extends Transactionable {
} }
export abstract class MultipleRelationRepository extends RelationRepository { export abstract class MultipleRelationRepository extends RelationRepository {
extendFindOptions(findOptions) {
return findOptions;
}
async find(options?: FindOptions): Promise<any> { async find(options?: FindOptions): Promise<any> {
const targetRepository = this.targetCollection.repository; const targetRepository = this.targetCollection.repository;

View File

@ -106,6 +106,9 @@ export abstract class SingleRelationRepository extends RelationRepository {
return target; return target;
} }
/**
* @internal
*/
accessors() { accessors() {
return <SingleAssociationAccessors>super.accessors(); return <SingleAssociationAccessors>super.accessors();
} }

View File

@ -28,6 +28,60 @@ export class SQLModel extends Model {
static async sync(): Promise<any> {} static async sync(): Promise<any> {}
static inferFields(): {
[field: string]: {
type: string;
source: string;
collection: string;
interface: string;
};
} {
const tables = this.parseTablesAndColumns();
return tables.reduce((fields, { table, columns }) => {
const tableName = this.getTableNameWithSchema(table);
const collection = this.database.tableNameCollectionMap.get(tableName);
if (!collection) {
return fields;
}
const attributes = collection.model.getAttributes();
const sourceFields = {};
if (columns === '*') {
Object.values(attributes).forEach((attribute) => {
const field = collection.getField((attribute as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[field.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
} else {
(columns as { name: string; as: string }[]).forEach((column) => {
const modelField = Object.values(attributes).find((attribute) => attribute.field === column.name);
if (!modelField) {
return;
}
const field = collection.getField((modelField as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[column.as || column.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
}
return { ...fields, ...sourceFields };
}, {});
}
private static parseTablesAndColumns(): { private static parseTablesAndColumns(): {
table: string; table: string;
columns: string | { name: string; as: string }[]; columns: string | { name: string; as: string }[];
@ -102,63 +156,9 @@ export class SQLModel extends Model {
private static getTableNameWithSchema(table: string) { private static getTableNameWithSchema(table: string) {
if (this.database.inDialect('postgres') && !table.includes('.')) { if (this.database.inDialect('postgres') && !table.includes('.')) {
return `public.${table}`; const schema = process.env.DB_SCHEMA || 'public';
return `${schema}.${table}`;
} }
return table; return table;
} }
static inferFields(): {
[field: string]: {
type: string;
source: string;
collection: string;
interface: string;
};
} {
const tables = this.parseTablesAndColumns();
const fields = tables.reduce((fields, { table, columns }) => {
const tableName = this.getTableNameWithSchema(table);
const collection = this.database.tableNameCollectionMap.get(tableName);
if (!collection) {
return fields;
}
const attributes = collection.model.getAttributes();
const sourceFields = {};
if (columns === '*') {
Object.values(attributes).forEach((attribute) => {
const field = collection.getField((attribute as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[field.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
} else {
(columns as { name: string; as: string }[]).forEach((column) => {
const modelField = Object.values(attributes).find((attribute) => attribute.field === column.name);
if (!modelField) {
return;
}
const field = collection.getField((modelField as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[column.as || column.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
}
return { ...fields, ...sourceFields };
}, {});
return fields;
}
} }

View File

@ -0,0 +1,19 @@
import { mockServer, MockServer } from '@nocobase/test';
describe('application version', () => {
let app: MockServer;
afterEach(async () => {
if (app) {
await app.destroy();
}
});
it('should get application version', async () => {
app = mockServer();
await app.db.sync();
const appVersion = app.version;
await appVersion.update();
expect(await appVersion.get()).toBeDefined();
});
});

View File

@ -110,40 +110,6 @@ describe('application', () => {
expect(response.body).toEqual([1, 2]); expect(response.body).toEqual([1, 2]);
}); });
it.skip('db.middleware', async () => {
const index = app.middleware.findIndex((m) => m.name === 'db2resource');
app.middleware.splice(index, 0, async (ctx, next) => {
app.collection({
name: 'tests',
});
await next();
});
const response = await agent.get('/api/tests');
expect(response.body).toEqual([1, 2]);
});
it.skip('db.middleware', async () => {
const index = app.middleware.findIndex((m) => m.name === 'db2resource');
app.middleware.splice(index, 0, async (ctx, next) => {
app.collection({
name: 'bars',
});
app.collection({
name: 'foos',
fields: [
{
type: 'hasMany',
name: 'bars',
},
],
});
await next();
});
const response = await agent.get('/api/foos/1/bars');
expect(response.body).toEqual([1, 2]);
});
it('should call application with command', async () => { it('should call application with command', async () => {
await app.runCommand('start'); await app.runCommand('start');
const jestFn = vi.fn(); const jestFn = vi.fn();

View File

@ -1,58 +0,0 @@
import supertest from 'supertest';
import { Application } from '../application';
describe('application', () => {
let app: Application;
let agent;
beforeEach(() => {
app = new Application({
database: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT as any,
dialect: process.env.DB_DIALECT as any,
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
},
acl: false,
resourcer: {
prefix: '/api',
},
dataWrapping: true,
});
app.resourcer.registerActionHandlers({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
get: async (ctx, next) => {
ctx.body = [3, 4];
await next();
},
'foo2s.bar2s:list': async (ctx, next) => {
ctx.body = [5, 6];
await next();
},
});
agent = supertest.agent(app.callback());
});
afterEach(async () => {
return app.destroy();
});
it('resourcer.define', async () => {
app.resourcer.define({
name: 'test',
});
const response = await agent.get('/api/test');
expect(response.body).toEqual({
data: [1, 2],
});
});
});

View File

@ -5,6 +5,7 @@ import { AppSupervisor } from '../app-supervisor';
import Application from '../application'; import Application from '../application';
import { Gateway } from '../gateway'; import { Gateway } from '../gateway';
import { errors } from '../gateway/errors'; import { errors } from '../gateway/errors';
describe('gateway', () => { describe('gateway', () => {
let gateway: Gateway; let gateway: Gateway;
beforeEach(() => { beforeEach(() => {
@ -14,6 +15,7 @@ describe('gateway', () => {
await gateway.destroy(); await gateway.destroy();
await AppSupervisor.getInstance().destroy(); await AppSupervisor.getInstance().destroy();
}); });
describe('app selector', () => { describe('app selector', () => {
it('should get app as default main app', async () => { it('should get app as default main app', async () => {
expect( expect(
@ -45,6 +47,7 @@ describe('gateway', () => {
expect(gateway.getAppSelectorMiddlewares().nodes.length).toBe(2); expect(gateway.getAppSelectorMiddlewares().nodes.length).toBe(2);
}); });
}); });
describe('http api', () => { describe('http api', () => {
it('should return error when app not found', async () => { it('should return error when app not found', async () => {
const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo'); const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');

View File

@ -0,0 +1,39 @@
import { mkdtemp } from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'node:os';
import { IPCSocketServer } from '../../gateway/ipc-socket-server';
import { IPCSocketClient } from '../../gateway/ipc-socket-client';
import { AppSupervisor } from '@nocobase/server';
describe('ipc test', () => {
it('should create ipc socket server', async () => {
const socketPath = join(await mkdtemp(join(tmpdir(), 'ipc-socket-server')), '/test.sock');
const socketServer = IPCSocketServer.buildServer(socketPath);
const client = await IPCSocketClient.getConnection(socketPath);
const appHandler = vi.fn();
vi.spyOn(AppSupervisor, 'getInstance').mockImplementation(() => {
return {
getApp() {
return {
runAsCLI() {
appHandler();
},
cli: {
hasCommand() {
return true;
},
parseHandleByIPCServer() {
return true;
},
},
};
},
};
});
await client.write({ type: 'passCliArgv', payload: { argv: ['node', 'test', 'nocobase'] } });
expect(appHandler).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,124 @@
import supertest from 'supertest';
import { Application } from '../../application';
import { dataWrapping } from '../../middlewares';
describe('data wrapping middleware', () => {
it('should not wrap when ctx.withoutDataWrapping is true', async () => {
const wrappingMiddleware = dataWrapping();
const ctx: any = {
withoutDataWrapping: true,
body: [1, 2],
};
await wrappingMiddleware(ctx, async () => {});
expect(ctx.body).toEqual([1, 2]);
});
it('should wrap ctx.body', async () => {
const wrappingMiddleware = dataWrapping();
const ctx: any = {
body: [1, 2],
state: {},
};
await wrappingMiddleware(ctx, async () => {});
expect(ctx.body).toEqual({
data: [1, 2],
});
});
it('should wrap with pagination data', async () => {
const wrappingMiddleware = dataWrapping();
const ctx: any = {
body: {
rows: [1, 2],
count: 2,
},
state: {},
};
await wrappingMiddleware(ctx, async () => {});
expect(ctx.body).toEqual({
data: [1, 2],
meta: {
count: 2,
},
});
});
it('should set body meta through ctx.bodyMeta', async () => {
const wrappingMiddleware = dataWrapping();
const ctx: any = {
body: {
key1: 'value1',
},
bodyMeta: {
foo: 'bar',
},
state: {},
};
await wrappingMiddleware(ctx, async () => {});
expect(ctx.body).toMatchObject({
data: {
key1: 'value1',
},
meta: {
foo: 'bar',
},
});
});
});
describe('application', () => {
let app: Application;
let agent;
beforeEach(() => {
app = new Application({
database: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT as any,
dialect: process.env.DB_DIALECT as any,
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
},
acl: false,
resourcer: {
prefix: '/api',
},
dataWrapping: true,
});
app.resourcer.registerActionHandlers({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
get: async (ctx, next) => {
ctx.body = [3, 4];
await next();
},
'foo2s.bar2s:list': async (ctx, next) => {
ctx.body = [5, 6];
await next();
},
});
agent = supertest.agent(app.callback());
});
afterEach(async () => {
return app.destroy();
});
it('resourcer.define', async () => {
app.resourcer.define({
name: 'test',
});
const response = await agent.get('/api/test');
expect(response.body).toEqual({
data: [1, 2],
});
});
});

View File

@ -1,18 +1,6 @@
import { ACL } from '@nocobase/acl'; import { ACL } from '@nocobase/acl';
import { availableActions } from './available-action'; import { availableActions } from './available-action';
const configureResources = [
'roles',
'users',
'collections',
'fields',
'collections.fields',
'roles.collections',
'roles.resources',
'rolesResourcesScopes',
'availableActions',
];
export function createACL() { export function createACL() {
const acl = new ACL(); const acl = new ACL();
@ -20,7 +8,5 @@ export function createACL() {
acl.setAvailableAction(actionName, actionParams); acl.setAvailableAction(actionName, actionParams);
} }
acl.registerConfigResources(configureResources);
return acl; return acl;
} }

View File

@ -144,6 +144,7 @@ interface LoadOptions {
reload?: boolean; reload?: boolean;
hooks?: boolean; hooks?: boolean;
sync?: boolean; sync?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -201,7 +202,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
protected plugins = new Map<string, Plugin>(); protected plugins = new Map<string, Plugin>();
protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance(); protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance();
protected _started: boolean; protected _started: boolean;
protected _logger: SystemLogger;
private _authenticated = false; private _authenticated = false;
private _maintaining = false; private _maintaining = false;
private _maintainingCommandStatus: MaintainingCommandStatus; private _maintainingCommandStatus: MaintainingCommandStatus;
@ -217,6 +217,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this._appSupervisor.addApp(this); this._appSupervisor.addApp(this);
} }
protected _logger: SystemLogger;
get logger() {
return this._logger;
}
protected _loaded: boolean; protected _loaded: boolean;
/** /**
@ -254,10 +260,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.mainDataSource.collectionManager.db; return this.mainDataSource.collectionManager.db;
} }
get logger() {
return this._logger;
}
get resourceManager() { get resourceManager() {
return this.mainDataSource.resourceManager; return this.mainDataSource.resourceManager;
} }
@ -587,15 +589,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.pm.get(name) as P; return this.pm.get(name) as P;
} }
/**
* This method is deprecated and should not be used.
* Use {@link this.runAsCLI()} instead.
* @deprecated
*/
async parse(argv = process.argv) {
return this.runAsCLI(argv);
}
async authenticate() { async authenticate() {
if (this._authenticated) { if (this._authenticated) {
return; return;
@ -614,46 +607,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return await this.runAsCLI([command, ...args], { from: 'user', throwError: true }); return await this.runAsCLI([command, ...args], { from: 'user', throwError: true });
} }
protected createCLI() {
const command = new AppCommand('nocobase')
.usage('[command] [options]')
.hook('preAction', async (_, actionCommand) => {
this._actionCommand = actionCommand;
this.activatedCommand = {
name: getCommandFullName(actionCommand),
};
this.setMaintaining({
status: 'command_begin',
command: this.activatedCommand,
});
this.setMaintaining({
status: 'command_running',
command: this.activatedCommand,
});
if (actionCommand['_authenticate']) {
await this.authenticate();
}
if (actionCommand['_preload']) {
await this.load();
}
})
.hook('postAction', async (_, actionCommand) => {
if (this._maintainingStatusBeforeCommand?.error && this._started) {
await this.restart();
}
});
command.exitOverride((err) => {
throw err;
});
return command;
}
/** /**
* @internal * @internal
*/ */
@ -716,14 +669,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}; };
} }
/**
* @internal
*/
async loadPluginCommands() {
this.log.debug('load plugin commands');
await this.pm.loadCommands();
}
/** /**
* @internal * @internal
*/ */
@ -1049,6 +994,46 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}); });
} }
protected createCLI() {
const command = new AppCommand('nocobase')
.usage('[command] [options]')
.hook('preAction', async (_, actionCommand) => {
this._actionCommand = actionCommand;
this.activatedCommand = {
name: getCommandFullName(actionCommand),
};
this.setMaintaining({
status: 'command_begin',
command: this.activatedCommand,
});
this.setMaintaining({
status: 'command_running',
command: this.activatedCommand,
});
if (actionCommand['_authenticate']) {
await this.authenticate();
}
if (actionCommand['_preload']) {
await this.load();
}
})
.hook('postAction', async (_, actionCommand) => {
if (this._maintainingStatusBeforeCommand?.error && this._started) {
await this.restart();
}
});
command.exitOverride((err) => {
throw err;
});
return command;
}
protected init() { protected init() {
const options = this.options; const options = this.options;

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
const REPL = require('repl'); const REPL = require('repl');

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import fs from 'fs'; import fs from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
import createMigration from './create-migration'; import createMigration from './create-migration';
import dbAuth from './db-auth'; import dbAuth from './db-auth';
@ -12,6 +14,7 @@ import start from './start';
import stop from './stop'; import stop from './stop';
import upgrade from './upgrade'; import upgrade from './upgrade';
import consoleCommand from './console'; import consoleCommand from './console';
export function registerCli(app: Application) { export function registerCli(app: Application) {
consoleCommand(app); consoleCommand(app);
dbAuth(app); dbAuth(app);

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import _ from 'lodash'; import _ from 'lodash';
import Application from '../application'; import Application from '../application';
import { PluginCommandError } from '../errors/plugin-command-error'; import { PluginCommandError } from '../errors/plugin-command-error';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { fsExists } from '@nocobase/utils'; import { fsExists } from '@nocobase/utils';
import fs from 'fs'; import fs from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
export default (app: Application) => { export default (app: Application) => {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import Application from '../application'; import Application from '../application';
/** /**

View File

@ -1,4 +1,4 @@
import { SystemLogger, createSystemLogger, getLoggerFilePath } from '@nocobase/logger'; import { createSystemLogger, getLoggerFilePath, SystemLogger } from '@nocobase/logger';
import { Registry, Toposort, ToposortOptions, uid } from '@nocobase/utils'; import { Registry, Toposort, ToposortOptions, uid } from '@nocobase/utils';
import { createStoragePluginsSymlink } from '@nocobase/utils/plugin-symlink'; import { createStoragePluginsSymlink } from '@nocobase/utils/plugin-symlink';
import { Command } from 'commander'; import { Command } from 'commander';
@ -55,13 +55,12 @@ export class Gateway extends EventEmitter {
public server: http.Server | null = null; public server: http.Server | null = null;
public ipcSocketServer: IPCSocketServer | null = null; public ipcSocketServer: IPCSocketServer | null = null;
loggers = new Registry<SystemLogger>();
private port: number = process.env.APP_PORT ? parseInt(process.env.APP_PORT) : null; private port: number = process.env.APP_PORT ? parseInt(process.env.APP_PORT) : null;
private host = '0.0.0.0'; private host = '0.0.0.0';
private wsServer: WSServer; private wsServer: WSServer;
private socketPath = resolve(process.cwd(), 'storage', 'gateway.sock'); private socketPath = resolve(process.cwd(), 'storage', 'gateway.sock');
loggers = new Registry<SystemLogger>();
private constructor() { private constructor() {
super(); super();
this.reset(); this.reset();
@ -78,6 +77,15 @@ export class Gateway extends EventEmitter {
return Gateway.instance; return Gateway.instance;
} }
static async getIPCSocketClient() {
const socketPath = resolve(process.cwd(), process.env.SOCKET_PATH || 'storage/gateway.sock');
try {
return await IPCSocketClient.getConnection(socketPath);
} catch (error) {
return false;
}
}
destroy() { destroy() {
this.reset(); this.reset();
Gateway.instance = null; Gateway.instance = null;
@ -284,6 +292,7 @@ export class Gateway extends EventEmitter {
return this.requestHandler.bind(this); return this.requestHandler.bind(this);
} }
/* istanbul ignore next -- @preserve */
async watch() { async watch() {
if (!process.env.IS_DEV_CMD) { if (!process.env.IS_DEV_CMD) {
return; return;
@ -295,6 +304,7 @@ export class Gateway extends EventEmitter {
require(file); require(file);
} }
/* istanbul ignore next -- @preserve */
async run(options: RunOptions) { async run(options: RunOptions) {
const isStart = this.isStart(); const isStart = this.isStart();
let ipcClient: IPCSocketClient | false; let ipcClient: IPCSocketClient | false;
@ -359,7 +369,6 @@ export class Gateway extends EventEmitter {
} }
getStartOptions() { getStartOptions() {
const argv = process.argv;
const program = new Command(); const program = new Command();
program program
@ -369,9 +378,7 @@ export class Gateway extends EventEmitter {
.option('-h, --host [host]') .option('-h, --host [host]')
.option('--db-sync') .option('--db-sync')
.parse(process.argv); .parse(process.argv);
const options = program.opts(); return program.opts();
return options;
} }
start(options: StartHttpServerOptions) { start(options: StartHttpServerOptions) {
@ -439,13 +446,4 @@ export class Gateway extends EventEmitter {
this.server?.close(); this.server?.close();
this.wsServer?.close(); this.wsServer?.close();
} }
static async getIPCSocketClient() {
const socketPath = resolve(process.cwd(), process.env.SOCKET_PATH || 'storage/gateway.sock');
try {
return await IPCSocketClient.getConnection(socketPath);
} catch (error) {
return false;
}
}
} }

View File

@ -99,7 +99,6 @@ export class IPCSocketServer {
const mainApp = await AppSupervisor.getInstance().getApp('main'); const mainApp = await AppSupervisor.getInstance().getApp('main');
if (!mainApp.cli.hasCommand(argv[2])) { if (!mainApp.cli.hasCommand(argv[2])) {
// console.log('passCliArgv', argv[2]);
await mainApp.pm.loadCommands(); await mainApp.pm.loadCommands();
} }
const cli = mainApp.cli; const cli = mainApp.cli;

View File

@ -10,10 +10,8 @@ import bodyParser from 'koa-bodyparser';
import { resolve } from 'path'; import { resolve } from 'path';
import { createHistogram, RecordableHistogram } from 'perf_hooks'; import { createHistogram, RecordableHistogram } from 'perf_hooks';
import Application, { ApplicationOptions } from './application'; import Application, { ApplicationOptions } from './application';
import { parseVariables } from './middlewares';
import { dateTemplate } from './middlewares/data-template';
import { dataWrapping } from './middlewares/data-wrapping'; import { dataWrapping } from './middlewares/data-wrapping';
import { db2resource } from './middlewares/db2resource';
import { i18n } from './middlewares/i18n'; import { i18n } from './middlewares/i18n';
export function createI18n(options: ApplicationOptions) { export function createI18n(options: ApplicationOptions) {
@ -112,11 +110,13 @@ export const getCommandFullName = (command: Command) => {
return names.join('.'); return names.join('.');
}; };
/* istanbul ignore next -- @preserve */
export const tsxRerunning = async () => { export const tsxRerunning = async () => {
const file = resolve(process.cwd(), 'storage/app.watch.ts'); const file = resolve(process.cwd(), 'storage/app.watch.ts');
await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8'); await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8');
}; };
/* istanbul ignore next -- @preserve */
export const enablePerfHooks = (app: Application) => { export const enablePerfHooks = (app: Application) => {
app.context.getPerfHistogram = (name: string) => { app.context.getPerfHistogram = (name: string) => {
if (!app.perfHistograms.has(name)) { if (!app.perfHistograms.has(name)) {

View File

@ -1,13 +0,0 @@
export class MultipleInstanceManager<Item> {
map: Map<string, Item> = new Map();
constructor() {}
get(key: string) {
return this.map.get(key);
}
set(key: string, value: Item) {
this.map.set(key, value);
}
}

View File

@ -1,5 +1,5 @@
import { DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager'; import { DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager';
import { db2resource, parseVariables } from './middlewares'; import { parseVariables } from './middlewares';
import { dateTemplate } from './middlewares/data-template'; import { dateTemplate } from './middlewares/data-template';
export class MainDataSource extends SequelizeDataSource { export class MainDataSource extends SequelizeDataSource {

View File

@ -1,3 +1,2 @@
export * from './data-wrapping'; export * from './data-wrapping';
export * from './db2resource';
export { parseVariables } from './parse-variables'; export { parseVariables } from './parse-variables';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Migration as DbMigration } from '@nocobase/database'; import { Migration as DbMigration } from '@nocobase/database';
import Application from './application'; import Application from './application';
import Plugin from './plugin'; import Plugin from './plugin';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { DataTypes } from '@nocobase/database'; import { DataTypes } from '@nocobase/database';
import { Migration } from '../migration'; import { Migration } from '../migration';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Migration } from '../migration'; import { Migration } from '../migration';
import { PluginManager } from '../plugin-manager'; import { PluginManager } from '../plugin-manager';

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Migration } from '../migration'; import { Migration } from '../migration';
export default class extends Migration { export default class extends Migration {

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';

View File

@ -71,11 +71,6 @@ export class PluginManager {
*/ */
server: net.Server; server: net.Server;
/**
* @internal
*/
_repository: PluginManagerRepository;
/** /**
* @internal * @internal
*/ */
@ -104,6 +99,11 @@ export class PluginManager {
this.app.resourcer.use(uploadMiddleware); this.app.resourcer.use(uploadMiddleware);
} }
/**
* @internal
*/
_repository: PluginManagerRepository;
get repository() { get repository() {
return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository; return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository;
} }
@ -234,6 +234,7 @@ export class PluginManager {
} }
} }
/* istanbul ignore next -- @preserve */
async create(pluginName: string, options?: { forceRecreate?: boolean }) { async create(pluginName: string, options?: { forceRecreate?: boolean }) {
const createPlugin = async (name) => { const createPlugin = async (name) => {
const pluginDir = resolve(process.cwd(), 'packages/plugins', name); const pluginDir = resolve(process.cwd(), 'packages/plugins', name);
@ -482,20 +483,6 @@ export class PluginManager {
}); });
} }
private sort(names: string | string[]) {
const pluginNames = _.castArray(names);
if (pluginNames.length === 1) {
return pluginNames;
}
const sorter = new Topo.Sorter<string>();
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
const peerDependencies = Object.keys(plugin.options?.packageJson?.peerDependencies || {});
sorter.add(pluginName, { after: peerDependencies, group: plugin.options?.packageName || pluginName });
}
return sorter.nodes;
}
async enable(name: string | string[]) { async enable(name: string | string[]) {
let pluginNames = name; let pluginNames = name;
if (name === '*') { if (name === '*') {
@ -705,27 +692,6 @@ export class PluginManager {
await execa('yarn', ['nocobase', 'refresh']); await execa('yarn', ['nocobase', 'refresh']);
} }
/**
* @deprecated
*/
async loadOne(plugin: Plugin) {
this.app.setMaintainingMessage(`loading plugin ${plugin.name}...`);
if (plugin.state.loaded || !plugin.enabled) {
return;
}
const name = plugin.getName();
await plugin.beforeLoad();
await this.app.emitAsync('beforeLoadPlugin', plugin, {});
this.app.logger.debug(`loading plugin...`, { submodule: 'plugin-manager', method: 'loadOne', name });
await plugin.load();
plugin.state.loaded = true;
await this.app.emitAsync('afterLoadPlugin', plugin, {});
this.app.logger.debug(`after load plugin...`, { submodule: 'plugin-manager', method: 'loadOne', name });
this.app.setMaintainingMessage(`loaded plugin ${plugin.name}`);
}
/** /**
* @internal * @internal
*/ */
@ -1086,6 +1052,20 @@ export class PluginManager {
} }
this['_initPresetPlugins'] = true; this['_initPresetPlugins'] = true;
} }
private sort(names: string | string[]) {
const pluginNames = _.castArray(names);
if (pluginNames.length === 1) {
return pluginNames;
}
const sorter = new Topo.Sorter<string>();
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
const peerDependencies = Object.keys(plugin.options?.packageJson?.peerDependencies || {});
sorter.add(pluginName, { after: peerDependencies, group: plugin.options?.packageName || pluginName });
}
return sorter.nodes;
}
} }
export default PluginManager; export default PluginManager;

View File

@ -1,3 +1,5 @@
/* istanbul ignore next -- @preserve */
import { importModule, isURL } from '@nocobase/utils'; import { importModule, isURL } from '@nocobase/utils';
import { createStoragePluginSymLink } from '@nocobase/utils/plugin-symlink'; import { createStoragePluginSymLink } from '@nocobase/utils/plugin-symlink';
import axios, { AxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
@ -277,6 +279,7 @@ export function getServerPackages(packageDir: string) {
const exts = ['.js', '.ts', '.jsx', '.tsx']; const exts = ['.js', '.ts', '.jsx', '.tsx'];
const importRegex = /import\s+.*?\s+from\s+['"]([^'"\s.].+?)['"];?/g; const importRegex = /import\s+.*?\s+from\s+['"]([^'"\s.].+?)['"];?/g;
const requireRegex = /require\s*\(\s*[`'"]([^`'"\s.].+?)[`'"]\s*\)/g; const requireRegex = /require\s*\(\s*[`'"]([^`'"\s.].+?)[`'"]\s*\)/g;
function setPluginsFromContent(reg: RegExp, content: string) { function setPluginsFromContent(reg: RegExp, content: string) {
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = reg.exec(content))) { while ((match = reg.exec(content))) {
@ -428,6 +431,7 @@ async function getExternalVersionFromDistFile(packageName: string): Promise<fals
return false; return false;
} }
} }
export function isNotBuiltinModule(packageName: string) { export function isNotBuiltinModule(packageName: string) {
return !builtinModules.includes(packageName); return !builtinModules.includes(packageName);
} }
@ -503,6 +507,7 @@ export interface DepCompatible {
versionRange: string; versionRange: string;
packageVersion: string; packageVersion: string;
} }
export async function getCompatible(packageName: string) { export async function getCompatible(packageName: string) {
let externalVersion: Record<string, string>; let externalVersion: Record<string, string>;
const hasSrc = fs.existsSync(path.join(getPackageDir(packageName), 'src')); const hasSrc = fs.existsSync(path.join(getPackageDir(packageName), 'src'));

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import { LoggerOptions } from '@nocobase/logger'; import { LoggerOptions } from '@nocobase/logger';
import { fsExists, importModule } from '@nocobase/utils'; import { fsExists, importModule } from '@nocobase/utils';
@ -6,7 +8,7 @@ import glob from 'glob';
import type { TFuncKey, TOptions } from 'i18next'; import type { TFuncKey, TOptions } from 'i18next';
import { basename, resolve } from 'path'; import { basename, resolve } from 'path';
import { Application } from './application'; import { Application } from './application';
import { InstallOptions, getExposeChangelogUrl, getExposeReadmeUrl } from './plugin-manager'; import { getExposeChangelogUrl, getExposeReadmeUrl, InstallOptions } from './plugin-manager';
import { checkAndGetCompatible } from './plugin-manager/utils'; import { checkAndGetCompatible } from './plugin-manager/utils';
export interface PluginInterface { export interface PluginInterface {
@ -135,22 +137,6 @@ export abstract class Plugin<O = any> implements PluginInterface {
this.options = options || {}; this.options = options || {};
} }
/**
* @internal
*/
protected async getSourceDir() {
if (this._sourceDir) {
return this._sourceDir;
}
if (await this.isDev()) {
return (this._sourceDir = 'src');
}
if (basename(__dirname) === 'src') {
return (this._sourceDir = 'src');
}
return (this._sourceDir = this.isPreset ? 'lib' : 'dist');
}
/** /**
* @internal * @internal
*/ */
@ -232,22 +218,6 @@ export abstract class Plugin<O = any> implements PluginInterface {
return this.app.i18n.t(text, { ns: this.options['packageName'], ...(options as any) }); return this.app.i18n.t(text, { ns: this.options['packageName'], ...(options as any) });
} }
/**
* @internal
*/
protected async isDev() {
if (!this.options.packageName) {
return false;
}
const file = await fs.promises.realpath(
resolve(process.env.NODE_MODULES_PATH || resolve(process.cwd(), 'node_modules'), this.options.packageName),
);
if (file.startsWith(resolve(process.cwd(), 'packages'))) {
return !!process.env.IS_DEV_CMD;
}
return false;
}
/** /**
* @experimental * @experimental
*/ */
@ -286,6 +256,38 @@ export abstract class Plugin<O = any> implements PluginInterface {
return results; return results;
} }
/**
* @internal
*/
protected async getSourceDir() {
if (this._sourceDir) {
return this._sourceDir;
}
if (await this.isDev()) {
return (this._sourceDir = 'src');
}
if (basename(__dirname) === 'src') {
return (this._sourceDir = 'src');
}
return (this._sourceDir = this.isPreset ? 'lib' : 'dist');
}
/**
* @internal
*/
protected async isDev() {
if (!this.options.packageName) {
return false;
}
const file = await fs.promises.realpath(
resolve(process.env.NODE_MODULES_PATH || resolve(process.cwd(), 'node_modules'), this.options.packageName),
);
if (file.startsWith(resolve(process.cwd(), 'packages'))) {
return !!process.env.IS_DEV_CMD;
}
return false;
}
} }
export default Plugin; export default Plugin;

View File

@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react';
import fs from 'fs'; import fs from 'fs';
import path, { resolve } from 'path'; import path, { resolve } from 'path';
import { URL } from 'url'; import { URL } from 'url';
import { mergeConfig, defineConfig as vitestConfig } from 'vitest/config'; import { defineConfig as vitestConfig, mergeConfig } from 'vitest/config';
const CORE_CLIENT_PACKAGES = ['sdk', 'client']; const CORE_CLIENT_PACKAGES = ['sdk', 'client'];
@ -157,7 +157,7 @@ export const getFilterInclude = (isServer, isCoverage) => {
if (!item.startsWith('-')) { if (!item.startsWith('-')) {
const pre = argv[index - 1]; const pre = argv[index - 1];
if (pre && pre.startsWith('--') && !pre.includes('=')) { if (pre && pre.startsWith('--') && ['--reporter'].includes(pre)) {
return false; return false;
} }
@ -173,6 +173,7 @@ export const getFilterInclude = (isServer, isCoverage) => {
if (!filterFileOrDir) return {}; if (!filterFileOrDir) return {};
const absPath = path.join(process.cwd(), filterFileOrDir); const absPath = path.join(process.cwd(), filterFileOrDir);
const isDir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory(); const isDir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory();
// 如果是文件,则只测试当前文件 // 如果是文件,则只测试当前文件
if (!isDir) { if (!isDir) {
return { return {
@ -185,6 +186,7 @@ export const getFilterInclude = (isServer, isCoverage) => {
// 判断是否为包目录,如果不是包目录,则只测试当前目录 // 判断是否为包目录,如果不是包目录,则只测试当前目录
const isPackage = fs.existsSync(path.join(absPath, 'package.json')); const isPackage = fs.existsSync(path.join(absPath, 'package.json'));
if (!isPackage) { if (!isPackage) {
return { return {
include: [`${filterFileOrDir}/${suffix}`], include: [`${filterFileOrDir}/${suffix}`],
@ -235,6 +237,7 @@ export const defineConfig = () => {
if (filterInclude) { if (filterInclude) {
config.test.include = filterInclude; config.test.include = filterInclude;
if (isFile) { if (isFile) {
// 减少收集的文件 // 减少收集的文件
config.test.exclude = []; config.test.exclude = [];
@ -248,14 +251,17 @@ export const defineConfig = () => {
} }
const isCoverage = process.argv.includes('--coverage'); const isCoverage = process.argv.includes('--coverage');
if (!isCoverage) { if (!isCoverage) {
return config; return config;
} }
const { include: coverageInclude } = getFilterInclude(isServer, true); const { include: coverageInclude } = getFilterInclude(isServer, true);
if (coverageInclude) { if (coverageInclude) {
config.test.coverage.include = coverageInclude; config.test.coverage.include = coverageInclude;
} }
const reportsDirectory = getReportsDirectory(isServer); const reportsDirectory = getReportsDirectory(isServer);
if (reportsDirectory) { if (reportsDirectory) {
config.test.coverage.reportsDirectory = reportsDirectory; config.test.coverage.reportsDirectory = reportsDirectory;

View File

@ -1,7 +1,8 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { NoPermissionError } from '@nocobase/acl';
import { snakeCase } from '@nocobase/database'; import { snakeCase } from '@nocobase/database';
class NoPermissionError extends Error {}
function createWithACLMetaMiddleware() { function createWithACLMetaMiddleware() {
return async (ctx: any, next) => { return async (ctx: any, next) => {
await next(); await next();

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Migration } from '@nocobase/server'; import { Migration } from '@nocobase/server';
export default class extends Migration { export default class extends Migration {

View File

@ -1,19 +1,9 @@
import { ACL, ACLRole } from '@nocobase/acl'; import { ACL, ACLRole } from '@nocobase/acl';
import { Database, Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server';
export class RoleResourceActionModel extends Model { export class RoleResourceActionModel extends Model {
async writeToACL(options: { async writeToACL(options: { acl: ACL; role: ACLRole; resourceName: string }) {
acl: ACL; const { resourceName, role } = options;
role: ACLRole;
resourceName: string;
associationFieldsActions: AssociationFieldsActions;
grantHelper: GrantHelper;
}) {
// @ts-ignore
const db: Database = this.constructor.database;
const { resourceName, role, acl, associationFieldsActions, grantHelper } = options;
const actionName = this.get('name') as string; const actionName = this.get('name') as string;
@ -33,63 +23,5 @@ export class RoleResourceActionModel extends Model {
} }
role.grantAction(actionPath, actionParams); role.grantAction(actionPath, actionParams);
const collection = db.getCollection(resourceName);
if (!collection) {
return;
}
const availableAction = acl.resolveActionAlias(actionName);
for (const field of fields) {
const collectionField = collection.getField(field);
if (!collectionField) {
console.log(`field ${field} does not exist at ${collection.name}`);
continue;
}
const fieldType = collectionField.get('type') as string;
const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction];
const fieldTarget = collectionField.get('target');
if (fieldActions) {
// grant association actions to role
const associationActions = fieldActions.associationActions || [];
associationActions.forEach((associationAction) => {
const actionName = `${resourceName}.${collectionField.get('name')}:${associationAction}`;
role.grantAction(actionName);
});
const targetActions = fieldActions.targetActions || [];
targetActions.forEach((targetAction) => {
const targetActionPath = `${fieldTarget}:${targetAction}`;
const existsAction = role.getActionParams(targetActionPath);
if (existsAction) {
return;
}
// set resource target action with current resourceName
grantHelper.resourceTargetActionMap.set(`${role.name}.${resourceName}`, [
...(grantHelper.resourceTargetActionMap.get(resourceName) || []),
targetActionPath,
]);
grantHelper.targetActionResourceMap.set(targetActionPath, [
...(grantHelper.targetActionResourceMap.get(targetActionPath) || []),
`${role.name}.${resourceName}`,
]);
role.grantAction(targetActionPath);
});
}
}
} }
} }

View File

@ -1,37 +1,15 @@
import { ACL, ACLResource, ACLRole } from '@nocobase/acl'; import { ACL, ACLResource, ACLRole } from '@nocobase/acl';
import { Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import { AssociationFieldsActions, GrantHelper } from '../server';
import { RoleResourceActionModel } from './RoleResourceActionModel'; import { RoleResourceActionModel } from './RoleResourceActionModel';
export class RoleResourceModel extends Model { export class RoleResourceModel extends Model {
async revoke(options: { role: ACLRole; resourceName: string; grantHelper: GrantHelper }) { async revoke(options: { role: ACLRole; resourceName: string }) {
const { role, resourceName, grantHelper } = options; const { role, resourceName } = options;
role.revokeResource(resourceName); role.revokeResource(resourceName);
const targetActions = grantHelper.resourceTargetActionMap.get(`${role.name}.${resourceName}`) || [];
for (const targetAction of targetActions) {
const targetActionResource = (grantHelper.targetActionResourceMap.get(targetAction) || []).filter(
(item) => `${role.name}.${resourceName}` !== item,
);
grantHelper.targetActionResourceMap.set(targetAction, targetActionResource);
if (targetActionResource.length == 0) {
role.revokeAction(targetAction);
}
}
grantHelper.resourceTargetActionMap.set(`${role.name}.${resourceName}`, []);
} }
async writeToACL(options: { async writeToACL(options: { acl: ACL; transaction: any }) {
acl: ACL; const { acl } = options;
associationFieldsActions: AssociationFieldsActions;
grantHelper: GrantHelper;
transaction: any;
}) {
const { acl, associationFieldsActions, grantHelper } = options;
const resourceName = this.get('name') as string; const resourceName = this.get('name') as string;
const roleName = this.get('roleName') as string; const roleName = this.get('roleName') as string;
const role = acl.getRole(roleName); const role = acl.getRole(roleName);
@ -42,7 +20,7 @@ export class RoleResourceModel extends Model {
} }
// revoke resource of role // revoke resource of role
await this.revoke({ role, resourceName, grantHelper }); await this.revoke({ role, resourceName });
// @ts-ignore // @ts-ignore
if (this.usingActionsConfig === false) { if (this.usingActionsConfig === false) {
@ -66,8 +44,6 @@ export class RoleResourceModel extends Model {
acl, acl,
role, role,
resourceName, resourceName,
associationFieldsActions,
grantHelper: options.grantHelper,
}); });
} }
} }

View File

@ -1,6 +1,6 @@
import { Context, utils as actionUtils } from '@nocobase/actions'; import { Context, utils as actionUtils } from '@nocobase/actions';
import { Cache } from '@nocobase/cache'; import { Cache } from '@nocobase/cache';
import { Collection, RelationField } from '@nocobase/database'; import { Collection, RelationField, Transaction } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { Mutex } from 'async-mutex'; import { Mutex } from 'async-mutex';
import lodash from 'lodash'; import lodash from 'lodash';
@ -15,112 +15,25 @@ import { RoleModel } from './model/RoleModel';
import { RoleResourceActionModel } from './model/RoleResourceActionModel'; import { RoleResourceActionModel } from './model/RoleResourceActionModel';
import { RoleResourceModel } from './model/RoleResourceModel'; import { RoleResourceModel } from './model/RoleResourceModel';
export interface AssociationFieldAction {
associationActions: string[];
targetActions?: string[];
}
interface AssociationFieldActions {
[availableActionName: string]: AssociationFieldAction;
}
export interface AssociationFieldsActions {
[associationType: string]: AssociationFieldActions;
}
export class GrantHelper {
resourceTargetActionMap = new Map<string, string[]>();
targetActionResourceMap = new Map<string, string[]>();
constructor() {}
}
export class PluginACLServer extends Plugin { export class PluginACLServer extends Plugin {
// association field actions config
associationFieldsActions: AssociationFieldsActions = {};
grantHelper = new GrantHelper();
get acl() { get acl() {
return this.app.acl; return this.app.acl;
} }
registerAssociationFieldAction(associationType: string, value: AssociationFieldActions) { async writeResourceToACL(resourceModel: RoleResourceModel, transaction: Transaction) {
this.associationFieldsActions[associationType] = value;
}
registerAssociationFieldsActions() {
// if grant create action to role, it should
// also grant add action and association target's view action
this.registerAssociationFieldAction('hasOne', {
view: {
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create', 'set'],
},
update: {
associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('hasMany', {
view: {
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create', 'set', 'add'],
},
update: {
associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('belongsTo', {
view: {
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create', 'set'],
},
update: {
associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('belongsToMany', {
view: {
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create', 'set', 'add'],
},
update: {
associationActions: ['update', 'remove', 'set', 'toggle'],
},
});
}
async writeResourceToACL(resourceModel: RoleResourceModel, transaction) {
await resourceModel.writeToACL({ await resourceModel.writeToACL({
acl: this.acl, acl: this.acl,
associationFieldsActions: this.associationFieldsActions,
transaction: transaction, transaction: transaction,
grantHelper: this.grantHelper,
}); });
} }
async writeActionToACL(actionModel: RoleResourceActionModel, transaction) { async writeActionToACL(actionModel: RoleResourceActionModel, transaction: Transaction) {
const resource = actionModel.get('resource') as RoleResourceModel; const resource = actionModel.get('resource') as RoleResourceModel;
const role = this.acl.getRole(resource.get('roleName') as string); const role = this.acl.getRole(resource.get('roleName') as string);
await actionModel.writeToACL({ await actionModel.writeToACL({
acl: this.acl, acl: this.acl,
role, role,
resourceName: resource.get('name') as string, resourceName: resource.get('name') as string,
associationFieldsActions: this.associationFieldsActions,
grantHelper: this.grantHelper,
}); });
} }

View File

@ -1,5 +0,0 @@
import { CollectionGroup } from '@nocobase/database';
export class CollectionGroupManager {
static collectionGroups: CollectionGroup[] = [];
}

View File

@ -1,3 +1,5 @@
/* istanbul ignore file -- @preserve */
import { Application, AppSupervisor } from '@nocobase/server'; import { Application, AppSupervisor } from '@nocobase/server';
import { Restorer } from '../restorer'; import { Restorer } from '../restorer';
import { DumpRulesGroupType } from '@nocobase/database'; import { DumpRulesGroupType } from '@nocobase/database';

View File

@ -0,0 +1,225 @@
import { Database, SQLModel } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('sql collection', () => {
let app: MockServer;
let db: Database;
let agent: any;
beforeEach(async () => {
app = await createMockServer({
plugins: ['collection-manager', 'error-handler'],
});
db = app.db;
db.options.underscored = false;
agent = app.agent();
});
afterEach(async () => {
await app.db.clean({ drop: true });
await app.destroy();
});
it('sqlCollection:execute: should check sql', async () => {
let res = await agent.resource('sqlCollection').execute();
expect(res.status).toBe(400);
expect(res.body.errors[0].message).toMatch('Please enter a SQL statement');
res = await agent.resource('sqlCollection').execute({
values: {
sql: 'insert into users (username) values ("test")',
},
});
expect(res.status).toBe(400);
expect(res.body.errors[0].message).toMatch('Only supports SELECT statements or WITH clauses');
});
it('sqlCollection:execute', async () => {
await agent.resource('collections').create({
values: {
schema: process.env.DB_SCHEMA,
name: 'testSqlCollection',
fields: [
{
name: 'testField',
type: 'string',
interface: 'input',
},
],
},
});
await agent.resource('testSqlCollection').create({
values: {
testField: 'test',
},
});
const schema = process.env.DB_SCHEMA ? `${process.env.DB_SCHEMA}.` : ``;
const res = await agent.resource('sqlCollection').execute({
values: {
sql: `select * from ${schema}${db.queryInterface.quoteIdentifier('testSqlCollection')}`,
},
});
expect(res.status).toBe(200);
expect(res.body.data.data.length).toBe(1);
expect(res.body.data.fields).toMatchObject({
testField: {
type: 'string',
source: 'testSqlCollection.testField',
collection: 'testSqlCollection',
interface: 'input',
},
});
expect(res.body.data.sources).toEqual(['testSqlCollection']);
});
it('sqlCollection:update', async () => {
await agent.resource('collections').create({
values: {
name: 'fakeCollection',
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
{
name: 'testField2',
type: 'string',
interface: 'input',
},
],
},
});
await agent.resource('collections').create({
values: {
name: 'sqlCollection',
sql: 'select * from "fakeCollection"',
template: 'sql',
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
{
name: 'testField2',
type: 'string',
interface: 'input',
},
],
},
});
const collection = await db.getRepository('collections').findOne({
filter: {
name: 'sqlCollection',
},
});
expect(collection.options.sql).toBe('select * from "fakeCollection"');
const loadedModel = db.getModel('sqlCollection') as typeof SQLModel;
expect(loadedModel.sql).toBe('select * from "fakeCollection"');
const fields = await db.getRepository('fields').find({
filter: {
collectionName: 'sqlCollection',
},
});
expect(fields.length).toBe(2);
const loadedFields = db.getCollection('sqlCollection').fields;
expect(loadedFields.size).toBe(2);
await agent.resource('sqlCollection').update({
filterByTk: 'sqlCollection',
values: {
sql: 'select "testField1" from "fakeCollection"',
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
],
},
});
const collection2 = await db.getRepository('collections').findOne({
filter: {
name: 'sqlCollection',
},
});
expect(collection2.options.sql).toBe('select "testField1" from "fakeCollection"');
const loadedModel2 = db.getModel('sqlCollection') as typeof SQLModel;
expect(loadedModel2.sql).toBe('select "testField1" from "fakeCollection"');
const fields2 = await db.getRepository('fields').find({
filter: {
collectionName: 'sqlCollection',
},
});
expect(fields2.length).toBe(1);
const loadedFields2 = db.getCollection('sqlCollection').fields;
expect(loadedFields2.size).toBe(1);
});
it('sqlCollection:setFields', async () => {
await agent.resource('collections').create({
values: {
name: 'fakeCollection',
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
{
name: 'testField2',
type: 'string',
interface: 'input',
},
],
},
});
await agent.resource('collections').create({
values: {
name: 'sqlCollection',
sql: 'select * from "fakeCollection"',
template: 'sql',
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
{
name: 'testField2',
type: 'string',
interface: 'input',
},
],
},
});
const fields = await db.getRepository('fields').find({
filter: {
collectionName: 'sqlCollection',
},
});
expect(fields.length).toBe(2);
const loadedFields = db.getCollection('sqlCollection').fields;
expect(loadedFields.size).toBe(2);
await agent.resource('sqlCollection').setFields({
filterByTk: 'sqlCollection',
values: {
fields: [
{
name: 'testField1',
type: 'string',
interface: 'input',
},
],
},
});
const fields2 = await db.getRepository('fields').find({
filter: {
collectionName: 'sqlCollection',
},
});
expect(fields2.length).toBe(1);
const loadedFields2 = db.getCollection('sqlCollection').fields;
expect(loadedFields2.size).toBe(1);
});
});

View File

@ -1,69 +0,0 @@
import Database, { FindOneOptions, FindOptions, Model, Transaction } from '@nocobase/database';
async function destroyFields(db: Database, transaction: Transaction, fieldRecords: Model[]) {
const fieldsRepo = db.getRepository('fields');
for (const fieldRecord of fieldRecords) {
await fieldsRepo.destroy({
filter: {
name: fieldRecord.get('name'),
collectionName: fieldRecord.get('collectionName'),
},
transaction,
});
}
}
export function afterDestroyForForeignKeyField(db: Database) {
return async (model, opts) => {
const { transaction } = opts;
const options = model.get('options');
if (!options?.isForeignKey) {
return;
}
const collectionRepo = db.getRepository('collections');
const foreignKey = model.get('name');
const foreignKeyCollectionName = model.get('collectionName');
const collectionRecord = await collectionRepo.findOne({
filter: {
name: foreignKeyCollectionName,
},
transaction,
} as FindOneOptions);
const collectionOptions = collectionRecord.get('options');
const fieldsRepo = db.getRepository('fields');
if (collectionOptions?.isThrough) {
// through collection
const fieldRecords = await fieldsRepo.find({
filter: {
options: { through: foreignKeyCollectionName, foreignKey: foreignKey },
},
transaction,
} as FindOptions);
await destroyFields(db, transaction, fieldRecords);
} else {
await destroyFields(
db,
transaction,
await fieldsRepo.find({
filter: {
collectionName: foreignKeyCollectionName,
options: { foreignKey: foreignKey },
},
transaction,
} as FindOptions),
);
await destroyFields(
db,
transaction,
await fieldsRepo.find({
filter: {
options: { foreignKey: foreignKey, target: foreignKeyCollectionName },
},
transaction,
} as FindOptions),
);
}
};
}

Some files were not shown because too many files have changed in this diff Show More