feat: multiple apps (#1540)

* chore: skip yarn install in pm command

* feat: dump sub app by sub app name

* feat: dump & restore by sub app

* chore: enable application name to edit

* chore: field belongsTo uiSchema

* test: drop schema

* feat: uiSchema migrator

* fix: test

* fix: remove uiSchema

* fix: rerun migration

* chore: migrate fieldsHistory uiSchema

* fix: set uiSchema options

* chore: transaction params

* fix: sql error in mysql

* fix: sql compatibility

* feat: collection group api

* chore: restore & dump action template

* chore: tmp commit

* chore: collectionGroupAction

* feat: dumpableCollection api

* refactor: dump command

* fix: remove uiSchemaUid

* chore: get uiSchemaUid from tmp field

* feat: return dumped file url in dumper.dump

* feat: dump api

* refactor: collection groyoup

* chore: comment

* feat: restore command force option

* feat: dump with collection groups

* refactor: restore command

* feat: restore http api

* fix: test

* fix: test

* fix: restore test

* chore: volta pin

* fix: sub app load collection options

* fix: stop sub app

* feat: add stopped status to application to prevent duplicate application stop

* chore: tmp commit

* test: upgrade

* feat: pass upgrade event to sub app

* fix: app manager client

* fix: remove stopped status

* fix: emit beforeStop event

* feat: support dump & restore subApp through api

* chore: dumpable collections api

* refactor: getTableNameWithSchema

* fix: schema name

* feat:  cname

* refactor: collection 同步实现方式

* refactor: move collection group manager to database

* fix: test

* fix: remove uiSchema

* fix: uiSchema

* fix: remove settings

* chore: plugin enable & disable event

* feat: modal warning

* fix: users_jobs namespace

* fix: rolesUischemas namespace

* fix: am snippet

* feat: beforeSubAppInstall event

* fix: improve NOCOBASE_LOCALE_KEY & NOCOBASE_ROLE_KEY

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-03-10 19:16:00 +08:00 committed by GitHub
parent 243f9e2448
commit 0832a56868
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
129 changed files with 2618 additions and 927 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ dist/
docker/**/storage
cache/diskstore-*
*.nbdump
storage/duplicator/*

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-multi-app-manager/client';

View File

@ -3,14 +3,13 @@ import { i18n as i18next } from 'i18next';
import React, { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import { ACLProvider, ACLShortcut } from '../acl';
import { ACLProvider } from '../acl';
import { AntdConfigProvider } from '../antd-config-provider';
import { APIClient, APIClientProvider } from '../api-client';
import { BlockSchemaComponentProvider } from '../block-provider';
import { CollectionManagerShortcut } from '../collection-manager';
import { RemoteDocumentTitleProvider } from '../document-title';
import { i18n } from '../i18n';
import { PluginManagerProvider } from '../plugin-manager';
import { PinnedPluginListProvider } from '../plugin-manager';
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm';
import {
AdminLayout,
@ -18,17 +17,17 @@ import {
RemoteRouteSwitchProvider,
RouteSchemaComponent,
RouteSwitch,
useRoutes,
useRoutes
} from '../route-switch';
import {
AntdSchemaComponentProvider,
DesignableSwitch,
MenuItemInitializers,
SchemaComponentProvider,
SchemaComponentProvider
} from '../schema-component';
import { SchemaInitializerProvider } from '../schema-initializer';
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsProvider } from '../system-settings';
import { SigninPage, SignupPage } from '../user';
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
import { compose } from './compose';
@ -94,18 +93,16 @@ export class Application {
},
});
this.use(SystemSettingsProvider);
this.use(PluginManagerProvider, {
components: {
ACLShortcut,
DesignableSwitch,
CollectionManagerShortcut,
SystemSettingsShortcut,
SchemaTemplateShortcut,
PluginManagerLink,
SettingsCenterDropdown,
this.use(PinnedPluginListProvider, {
items: {
ui: { order: 100, component: 'DesignableSwitch', pin: true, snippet: 'ui.*' },
pm: { order: 200, component: 'PluginManagerLink', pin: true, snippet: 'pm' },
sc: { order: 300, component: 'SettingsCenterDropdown', pin: true, snippet: 'pm.*' },
},
});
this.use(SchemaComponentProvider, { components: { Link, NavLink } });
this.use(SchemaComponentProvider, {
components: { Link, NavLink, DesignableSwitch, PluginManagerLink, SettingsCenterDropdown },
});
this.use(SchemaInitializerProvider, {
initializers: {
MenuItemInitializers,

View File

@ -21,7 +21,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
action: 'list',
params: {
paginate: false,
appends: ['fields', 'fields.uiSchema'],
appends: ['fields'],
filter: {
// inherit: false,
},

View File

@ -38,7 +38,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
action: 'list',
params: {
paginate: false,
appends: ['fields', 'fields.uiSchema', 'category'],
appends: ['fields', 'category'],
filter: {
// inherit: false,
},

View File

@ -6,7 +6,7 @@ import set from 'lodash/set';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord, RecordProvider } from '../../record-provider';
import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction, useUpdateAction } from '../action-hooks';
import { useCollectionManager } from '../hooks';
@ -131,7 +131,7 @@ export const EditFieldAction = (props) => {
onClick={async () => {
const { data } = await api.resource('collections.fields', record.collectionName).get({
filterByTk: record.name,
appends: ['uiSchema', 'reverseField'],
appends: ['reverseField'],
});
setData(data?.data);
const interfaceConf = getInterface(record.interface);

View File

@ -156,7 +156,7 @@ export const OverridingFieldAction = (props) => {
if (!disabled) {
const { data } = await api.resource('collections.fields', record.collectionName).get({
filterByTk: record.name,
appends: ['uiSchema', 'reverseField'],
appends: ['reverseField'],
});
setData(data?.data);
const interfaceConf = getInterface(record.interface);

View File

@ -6,7 +6,7 @@ import set from 'lodash/set';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord, RecordProvider } from '../../record-provider';
import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContext, SchemaComponent, useCompile } from '../../schema-component';
import { useCollectionManager } from '../hooks';
import { IField } from '../interfaces/types';
@ -82,7 +82,7 @@ export const ViewFieldAction = (props) => {
onClick={async () => {
const { data } = await api.resource('collections.fields', record.collectionName).get({
filterByTk: record.name,
appends: ['uiSchema', 'reverseField'],
appends: ['reverseField'],
});
setData(data?.data);
const interfaceConf = getInterface(record.interface);

View File

@ -76,7 +76,7 @@ export const collectionFieldSchema: ISchema = {
'interface.$not': null,
},
sort: ['sort'],
appends: ['uiSchema'],
// appends: ['uiSchema'],
},
},
},

View File

@ -1,4 +1,4 @@
import { useForm } from '@formily/react';
import { useField, useForm } from '@formily/react';
import { message } from 'antd';
import { useEffect } from 'react';
import { useCollection, useCollectionManager } from '.';
@ -185,15 +185,19 @@ export const useFilterAction = () => {
export const useCreateAction = () => {
const form = useForm();
const field = useField();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
return {
async run() {
await form.submit();
field.data = field.data || {};
field.data.loading = true;
await resource.create({ values: form.values });
ctx.setVisible(false);
await form.reset();
field.data.loading = false;
refresh();
},
};
@ -242,6 +246,7 @@ export const useMoveAction = () => {
};
export const useUpdateAction = () => {
const field = useField();
const form = useForm();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
@ -250,9 +255,12 @@ export const useUpdateAction = () => {
return {
async run() {
await form.submit();
field.data = field.data || {};
field.data.loading = true;
await resource.update({ filterByTk, values: form.values });
ctx.setVisible(false);
await form.reset();
field.data.loading = false;
refresh();
},
};

View File

@ -8,7 +8,7 @@ import {
useCollectionManager,
useRecord,
useRecordIndex,
useRequest,
useRequest
} from '../';
import { useAPIClient } from '../api-client';
@ -183,7 +183,7 @@ export const SubFieldDataSourceProvider = observer((props) => {
.resource('fields')
.list({
paginate: false,
appends: ['uiSchema'],
// appends: ['uiSchema'],
sort: 'sort',
filter: {
parentKey: record.key,

View File

@ -0,0 +1,51 @@
import { css } from '@emotion/css';
import { SchemaOptionsContext } from '@formily/react';
import { get } from 'lodash';
import React, { useContext } from 'react';
import { useACLRoleContext } from '../acl/ACLProvider';
import { PinnedPluginListContext } from './context';
export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
const { children, items } = props;
const ctx = useContext(PinnedPluginListContext);
return (
<PinnedPluginListContext.Provider value={{ items: { ...ctx.items, ...items } }}>
{children}
</PinnedPluginListContext.Provider>
);
};
export const PinnedPluginList = () => {
const { allowAll, snippets } = useACLRoleContext();
const getSnippetsAllow = (aclKey) => {
return allowAll || snippets?.includes(aclKey);
};
const ctx = useContext(PinnedPluginListContext);
const { components } = useContext(SchemaOptionsContext);
return (
<div
className={css`
.ant-btn {
border: 0;
height: 46px;
width: 46px;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.65);
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
`}
style={{ display: 'inline-block' }}
>
{Object.values<any>(ctx.items)
.sort((a, b) => a.order - b.order)
.filter((v) => getSnippetsAllow(v.snippet))
.map((item) => {
const Action = get(components, item.component);
return Action ? <Action /> : null;
})}
</div>
);
};

View File

@ -1,3 +1,4 @@
import { createContext } from 'react';
export const PluginManagerContext = createContext<any>({});
export const PinnedPluginListContext = createContext({ items: {} });

View File

@ -1,3 +1,5 @@
export * from './context';
export * from './PinnedPluginListProvider';
export * from './PluginManager';
export * from './PluginManagerProvider';

View File

@ -1,27 +1,23 @@
import { AppstoreAddOutlined, SettingOutlined } from '@ant-design/icons';
import { Dropdown, Menu } from 'antd';
import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Dropdown, Menu } from 'antd';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useACLRoleContext } from '../acl/ACLProvider';
import { PluginManager } from '../plugin-manager';
import { ActionContext, useCompile } from '../schema-component';
import { getPluginsTabs, SettingsCenterContext } from './index';
export const PluginManagerLink = () => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const history = useHistory();
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<PluginManager.Toolbar.Item
icon={<AppstoreAddOutlined />}
title={t('Plugin manager')}
onClick={() => {
history.push('/admin/pm/list');
}}
/>
</ActionContext.Provider>
<Button
icon={<ApiOutlined />}
title={t('Plugin manager')}
onClick={() => {
history.push('/admin/pm/list');
}}
/>
);
};
@ -50,20 +46,18 @@ export const SettingsCenterDropdown = () => {
placement="bottom"
overlay={
<Menu>
<Menu.ItemGroup title={t('Bookmark')}>
{bookmarkTabs.map((tab) => {
return (
<Menu.Item
onClick={() => {
history.push('/admin/settings/' + tab.path);
}}
key={tab.path}
>
{compile(tab.title)}
</Menu.Item>
);
})}
</Menu.ItemGroup>
{bookmarkTabs.map((tab) => {
return (
<Menu.Item
onClick={() => {
history.push('/admin/settings/' + tab.path);
}}
key={tab.path}
>
{compile(tab.title)}
</Menu.Item>
);
})}
<Menu.Divider></Menu.Divider>
<Menu.Item
onClick={() => {
@ -76,10 +70,10 @@ export const SettingsCenterDropdown = () => {
</Menu>
}
>
<PluginManager.Toolbar.Item
<Button
icon={<SettingOutlined />}
// title={t('All plugin settings')}
></PluginManager.Toolbar.Item>
/>
</Dropdown>
</ActionContext.Provider>
);

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Avatar, Card, Layout, Menu, message, PageHeader, Popconfirm, Result, Spin, Switch, Tabs } from 'antd';
import { Avatar, Card, Layout, Menu, message, Modal, PageHeader, Popconfirm, Result, Spin, Switch, Tabs } from 'antd';
import { sortBy } from 'lodash';
import React, { createContext, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -53,11 +53,20 @@ const PluginCard = (props) => {
<Switch
size={'small'}
onChange={async (checked) => {
Modal.warn({
title: checked ? t('Plugin staring') : t('Plugin stopping'),
content: t('The application is reloading, please do not close the page.'),
okButtonProps: {
style: {
display: 'none',
},
},
});
await api.request({
url: `pm:${checked ? 'enable' : 'disable'}/${data.name}`,
});
message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
window.location.reload();
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
}}
defaultChecked={data.enabled}
></Switch>,

View File

@ -10,15 +10,15 @@ import {
CurrentUserProvider,
findByUid,
findMenuItem,
PinnedPluginList,
RemoteCollectionManagerProvider,
RemotePluginManagerToolbar,
RemoteSchemaTemplateManagerProvider,
SchemaComponent,
useACLRoleContext,
useDocumentTitle,
useRequest,
useRoute,
useSystemSettings,
useSystemSettings
} from '../../../';
import { useCollectionManager } from '../../../collection-manager';
@ -213,7 +213,7 @@ export const InternalAdminLayout = (props: any) => {
z-index: 10;
`}
>
<RemotePluginManagerToolbar />
<PinnedPluginList />
<CurrentUser />
</div>
</div>

View File

@ -1,9 +1,9 @@
import { HighlightOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useDesignable } from '..';
import { PluginManager } from '../../plugin-manager';
import { useHotkeys } from 'react-hotkeys-hook'
export const DesignableSwitch = () => {
const { designable, setDesignable } = useDesignable();
@ -14,18 +14,18 @@ export const DesignableSwitch = () => {
}
// 快捷键切换编辑状态
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable])
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable]);
return (
<PluginManager.Toolbar.Item
selected={designable}
<Button
// selected={designable}
icon={<HighlightOutlined />}
title={t('UI Editor')}
subtitle={'Ctrl+Shift+U'}
// subtitle={'Ctrl+Shift+U'}
style={style}
onClick={() => {
setDesignable(!designable);
}}
></PluginManager.Toolbar.Item>
/>
);
};

View File

@ -1,22 +1,21 @@
import { css } from '@emotion/css';
import { Dropdown, Menu } from 'antd';
import React, { createContext, useState, useContext } from 'react';
import React, { createContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useAPIClient, useCurrentUserContext } from '..';
import { useRequest } from '../api-client';
import { useCurrentAppInfo } from '../appInfo/CurrentAppInfoProvider';
import { ChangePassword } from './ChangePassword';
import { EditProfile } from './EditProfile';
import { LanguageSettings } from './LanguageSettings';
import { SwitchRole } from './SwitchRole';
import {useCurrentAppInfo} from '../appInfo/CurrentAppInfoProvider'
const ApplicationVersion = () => {
const data=useCurrentAppInfo();
const data = useCurrentAppInfo();
return (
<Menu.Item key="version" disabled>
Version {data?.data?.version}
</Menu.Item>
<Menu.Item key="version" disabled>
Version {data?.data?.version}
</Menu.Item>
);
};
@ -58,7 +57,17 @@ export const CurrentUser = () => {
</Menu>
}
>
<span style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}>
<span
className={css`
max-width: 160px;
overflow: hidden;
display: inline-block;
line-height: 12px;
white-space: nowrap;
text-overflow: ellipsis;
`}
style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}
>
{data?.data?.nickname || data?.data?.email}
</span>
</Dropdown>

View File

@ -1,6 +1,7 @@
import { Menu, Select } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import { useAPIClient } from '../api-client';
import { useCompile } from '../schema-component';
@ -29,6 +30,7 @@ export const SwitchRole = () => {
const api = useAPIClient();
const roles = useCurrentRoles();
const { t } = useTranslation();
const history = useHistory();
if (roles.length <= 1) {
return null;
}
@ -47,7 +49,8 @@ export const SwitchRole = () => {
onChange={async (roleName) => {
api.auth.setRole(roleName);
await api.resource('users').setDefaultRole({ values: { roleName } });
window.location.href = '/';
history.push('/');
window.location.reload();
}}
/>
</Menu.Item>

View File

@ -0,0 +1,94 @@
import Database from './database';
import { isString, castArray } from 'lodash';
export interface CollectionGroup {
namespace: string;
collections: string[];
function: string;
dumpable: 'required' | 'optional' | 'skip';
delayRestore?: any;
}
export class CollectionGroupManager {
constructor(public db: Database) {}
getGroups() {
const collections = [...this.db.collections.values()];
const groups = new Map<string, CollectionGroup>();
const skipped = [];
for (const collection of collections) {
const groupKey = collection.options.namespace;
if (!groupKey) {
continue;
}
const [namespace, groupFunc] = groupKey.split('.');
if (!groupFunc) {
skipped.push({
name: collection.name,
reason: 'no-group-function',
});
continue;
}
if (!groups.has(groupKey)) {
const dumpable = (() => {
if (!collection.options.duplicator) {
return undefined;
}
if (isString(collection.options.duplicator)) {
return {
dumpable: collection.options.duplicator,
};
}
return collection.options.duplicator;
})();
if (!dumpable) {
skipped.push({
name: collection.name,
reason: 'no-dumpable',
});
continue;
}
const group: CollectionGroup = {
namespace,
function: groupFunc,
collections: dumpable.with ? castArray(dumpable.with) : [],
dumpable: dumpable.dumpable,
};
if (dumpable.delayRestore) {
group.delayRestore = dumpable.delayRestore;
}
groups.set(groupKey, group);
}
const group = groups.get(groupKey);
group.collections.push(collection.name);
}
const results = [...groups.values()];
const groupCollections = results.map((i) => i.collections).flat();
for (const skipItem of skipped) {
if (groupCollections.includes(skipItem.name)) {
continue;
}
this.db.logger.warn(`collection ${skipItem.name} is not in any collection group, reason: ${skipItem.reason}.`);
}
return results;
}
}

View File

@ -19,8 +19,19 @@ export type RepositoryType = typeof Repository;
export type CollectionSortable = string | boolean | { name?: string; scopeKey?: string };
type dumpable = 'required' | 'optional' | 'skip';
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
name: string;
namespace?: string;
duplicator?:
| dumpable
| {
dumpable: dumpable;
with?: string[] | string;
delayRestore?: any;
};
tableName?: string;
inherits?: string[] | string;
filterTargetKey?: string;
@ -181,6 +192,7 @@ export class Collection<
this.on('field.afterRemove', (field: Field) => {
field.unbind();
this.db.emit('field.afterRemove', field);
});
}
@ -308,7 +320,7 @@ export class Collection<
})
) {
const queryInterface = this.db.sequelize.getQueryInterface();
await queryInterface.dropTable(this.addSchemaTableName(), options);
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
}
this.remove();
}
@ -539,7 +551,7 @@ export class Collection<
return this.context.database.inheritanceMap.isParentNode(this.name);
}
public addSchemaTableName() {
public getTableNameWithSchema() {
const tableName = this.model.tableName;
if (this.collectionSchema()) {
@ -550,7 +562,7 @@ export class Collection<
}
public quotedTableName() {
return this.db.utils.quoteTable(this.addSchemaTableName());
return this.db.utils.quoteTable(this.getTableNameWithSchema());
}
public collectionSchema() {

View File

@ -67,6 +67,7 @@ import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
import buildQueryInterface from './query-interface/query-interface-builder';
import QueryInterface from './query-interface/query-interface';
import { Logger } from '@nocobase/logger';
import { CollectionGroupManager } from './collection-group-manager';
export interface MergeOptions extends merge.Options {}
@ -184,6 +185,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
logger: Logger;
collectionGroupManager = new CollectionGroupManager(this);
constructor(options: DatabaseOptions) {
super();
@ -274,7 +277,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
name: 'migrations',
autoGenId: false,
timestamps: false,
namespace: 'core',
namespace: 'core.migration',
duplicator: 'required',
fields: [{ type: 'string', name: 'name' }],
});
@ -398,6 +401,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
collection<Attributes = any, CreateAttributes = Attributes>(
options: CollectionOptions,
): Collection<Attributes, CreateAttributes> {
options = lodash.cloneDeep(options);
if (this.options.underscored) {
options.underscored = true;
}

View File

@ -169,7 +169,7 @@ export abstract class Field {
columnReferencesCount == 1
) {
const queryInterface = this.database.sequelize.getQueryInterface();
await queryInterface.removeColumn(this.collection.addSchemaTableName(), this.columnName(), options);
await queryInterface.removeColumn(this.collection.getTableNameWithSchema(), this.columnName(), options);
}
this.remove();
@ -181,22 +181,24 @@ export abstract class Field {
};
let sql;
if (this.database.sequelize.getDialect() === 'sqlite') {
sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.columnName()}'`;
sql = `SELECT *
from pragma_table_info('${this.collection.model.tableName}')
WHERE name = '${this.columnName()}'`;
} else if (this.database.inDialect('mysql')) {
sql = `
select column_name
from INFORMATION_SCHEMA.COLUMNS
where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${
this.collection.model.tableName
}' AND column_name='${this.columnName()}'
where TABLE_SCHEMA = '${this.database.options.database}'
AND TABLE_NAME = '${this.collection.model.tableName}'
AND column_name = '${this.columnName()}'
`;
} else {
sql = `
select column_name
from INFORMATION_SCHEMA.COLUMNS
where TABLE_NAME='${
this.collection.model.tableName
}' AND column_name='${this.columnName()}' AND table_schema='${this.collection.collectionSchema() || 'public'}'
where TABLE_NAME = '${this.collection.model.tableName}'
AND column_name = '${this.columnName()}'
AND table_schema = '${this.collection.collectionSchema() || 'public'}'
`;
}
const [rows] = await this.database.sequelize.query(sql, opts);

View File

@ -20,4 +20,4 @@ export * from './repository';
export * from './update-associations';
export { snakeCase } from './utils';
export * from './value-parsers';
export * from './collection-group-manager';

View File

@ -26,7 +26,7 @@ export class SyncRunner {
);
}
const tableName = inheritedCollection.addSchemaTableName();
const tableName = inheritedCollection.getTableNameWithSchema();
const attributes = model.tableAttributes;
@ -44,7 +44,7 @@ export class SyncRunner {
`SELECT column_default
FROM information_schema.columns
WHERE table_name = '${parent.model.tableName}'
and table_schema = '${parent.collectionSchema()}'
and table_schema = '${parent.collectionSchema()}'
and "column_name" = 'id';`,
{
transaction,
@ -88,7 +88,7 @@ export class SyncRunner {
// if we have max sequence, set it to child table
if (maxSequenceName) {
const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map((parent) =>
db.getCollection(parent).addSchemaTableName(),
db.getCollection(parent).getTableNameWithSchema(),
);
const sequenceTables = [...parentsDeep, tableName];
@ -101,8 +101,10 @@ export class SyncRunner {
const idColumnQuery = await queryInterface.sequelize.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schemaName}';
FROM information_schema.columns
WHERE table_name = '${queryName}'
and column_name = 'id'
and table_schema = '${schemaName}';
`,
{
transaction,
@ -163,7 +165,7 @@ WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schem
';',
` INHERITS (${parents
.map((t) => {
return t.addSchemaTableName();
return t.getTableNameWithSchema();
})
.join(', ')});`,
);

View File

@ -25,6 +25,10 @@ export interface IResource {
export class Auth {
protected api: APIClient;
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE';
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE';
protected options = {
token: null,
locale: null,
@ -33,12 +37,26 @@ export class Auth {
constructor(api: APIClient) {
this.api = api;
this.initKeys();
this.locale = this.getLocale();
this.role = this.getRole();
this.token = this.getToken();
this.api.axios.interceptors.request.use(this.middleware.bind(this));
}
initKeys() {
if (!window) {
return;
}
const match = window.location.pathname.match(/^\/apps\/([^/]*)\//);
if (!match) {
return;
}
const appName = match[1];
this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`;
this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`;
}
get locale() {
return this.getLocale();
}
@ -77,12 +95,12 @@ export class Auth {
}
getLocale() {
return this.api.storage.getItem('NOCOBASE_LOCALE');
return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY);
}
setLocale(locale: string) {
this.options.locale = locale;
this.api.storage.setItem('NOCOBASE_LOCALE', locale || '');
this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || '');
}
getToken() {
@ -99,12 +117,12 @@ export class Auth {
}
getRole() {
return this.api.storage.getItem('NOCOBASE_ROLE');
return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY);
}
setRole(role: string) {
this.options.role = role;
this.api.storage.setItem('NOCOBASE_ROLE', role || '');
this.api.storage.setItem(this.NOCOBASE_ROLE_KEY, role || '');
}
async signIn(values, authenticator: string = 'password'): Promise<AxiosResponse<any>> {

View File

@ -20,6 +20,7 @@ describe('application', () => {
dialect: 'sqlite',
dialectModule: require('sqlite3'),
storage: ':memory:',
logging: false,
},
resourcer: {
prefix: '/api',

View File

@ -40,8 +40,6 @@ describe('multiple apps', () => {
await app.stop();
expect(subApp1StopFn).toBeCalledTimes(1);
await app.destroy();
});
});
@ -57,6 +55,27 @@ describe('multiple application', () => {
await app.destroy();
});
it('should upgrade sub apps when main app upgraded', async () => {
const subApp1 = app.appManager.createApplication('sub1', {
database: app.db,
});
const subApp2 = app.appManager.createApplication('sub2', {
database: app.db,
});
const subApp1UpgradeFn = jest.fn();
const subApp2UpgradeFn = jest.fn();
subApp1.on('afterUpgrade', subApp1UpgradeFn);
subApp2.on('afterUpgrade', subApp2UpgradeFn);
await app.upgrade();
expect(subApp1UpgradeFn).toBeCalledTimes(1);
expect(subApp2UpgradeFn).toBeCalledTimes(1);
await subApp2.stop();
await subApp1.stop();
});
it('should create multiple apps', async () => {
const sub1 = `a_${uid()}`;
const sub2 = `a_${uid()}`;

View File

@ -0,0 +1,33 @@
import Application from '../application';
import Plugin from '../plugin';
class TestPlugin extends Plugin {
}
describe('upgrade test', () => {
let app: Application;
beforeEach(async () => {
app = new Application({
database: {
dialect: 'sqlite',
dialectModule: require('sqlite3'),
storage: ':memory:',
logging: false,
},
resourcer: {
prefix: '/api',
},
acl: false,
dataWrapping: false,
registerActions: false,
});
app.plugin(TestPlugin, { name: 'test-plugin' });
});
it('should call upgrade', async () => {
await app.upgrade();
console.log('1231');
});
});

View File

@ -3,7 +3,9 @@ import EventEmitter from 'events';
import http, { IncomingMessage, ServerResponse } from 'http';
import Application, { ApplicationOptions } from './application';
type AppSelector = (req: IncomingMessage) => Application | string | undefined | null;
type AppSelectorReturn = Application | string | undefined | null;
type AppSelector = (req: IncomingMessage) => AppSelectorReturn | Promise<AppSelectorReturn>;
export class AppManager extends EventEmitter {
public applications: Map<string, Application> = new Map<string, Application>();
@ -11,23 +13,30 @@ export class AppManager extends EventEmitter {
constructor(public app: Application) {
super();
app.on('beforeStop', async (mainApp, options) => {
return await Promise.all(
[...this.applications.values()].map((application: Application) => application.stop(options)),
);
});
const passEventToSubApps = (eventName, method) => {
app.on(eventName, async (mainApp, options) => {
console.log(`receive event ${eventName} from ${mainApp.name}`);
for (const application of this.applications.values()) {
console.log(`pass ${eventName} to ${application.name} `);
await application[method](options);
}
});
};
app.on('afterDestroy', async (mainApp, options) => {
return await Promise.all(
[...this.applications.values()].map((application: Application) => application.destroy(options)),
);
});
passEventToSubApps('beforeDestroy', 'destroy');
passEventToSubApps('beforeStop', 'stop');
passEventToSubApps('afterUpgrade', 'upgrade');
passEventToSubApps('afterReload', 'reload');
}
appSelector: AppSelector = (req: IncomingMessage) => this.app;
appSelector: AppSelector = async (req: IncomingMessage) => this.app;
createApplication(name: string, options: ApplicationOptions): Application {
const application = new Application(options);
const application = new Application({
...options,
name,
});
this.applications.set(name, application);
return application;
}
@ -66,7 +75,7 @@ export class AppManager extends EventEmitter {
return async (req: IncomingMessage, res: ServerResponse) => {
const appManager = this.app.appManager;
let handleApp: any = appManager.appSelector(req) || appManager.app;
let handleApp: any = (await appManager.appSelector(req)) || appManager.app;
if (typeof handleApp === 'string') {
handleApp = await appManager.getApplication(handleApp);

View File

@ -102,7 +102,7 @@ export class ApplicationVersion {
if (!app.db.hasCollection('applicationVersion')) {
app.db.collection({
name: 'applicationVersion',
namespace: 'core',
namespace: 'core.applicationVersion',
duplicator: 'required',
timestamps: false,
fields: [{ name: 'value', type: 'string' }],
@ -229,13 +229,17 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
protected init() {
const options = this.options;
const logger = createAppLogger(options.logger);
this._logger = logger.instance;
// @ts-ignore
this._events = [];
// @ts-ignore
this._eventsCount = [];
this.removeAllListeners();
this.middleware = new Toposort<any>();
this.plugins = new Map<string, Plugin>();
this._acl = createACL();
@ -293,6 +297,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
});
db.setLogger(this._logger);
return db;
}
@ -350,7 +355,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
async load(options?: any) {
if (options?.reload) {
console.log(`Reload the application configuration`);
console.log(`Reload the ${this.name} application configuration`);
const oldDb = this._db;
this.init();
await oldDb.close();
@ -366,6 +371,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
...options,
reload: true,
});
await this.emitAsync('afterReload', this, options);
}
getPlugin<P extends Plugin>(name: string) {
@ -412,6 +419,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
if (options?.listen?.port) {
const pmServer = await this.pm.listen();
const listen = () =>
new Promise((resolve, reject) => {
const Server = this.listen(options?.listen, () => {
@ -452,6 +460,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.emitAsync('beforeStop', this, options);
// close http server
if (this.listenServer) {
await promisify(this.listenServer.close).call(this.listenServer);
this.listenServer = null;
}
try {
// close database connection
// silent if database already closed
@ -462,14 +476,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
console.log(e);
}
// close http server
if (this.listenServer) {
await promisify(this.listenServer.close).call(this.listenServer);
this.listenServer = null;
}
await this.emitAsync('afterStop', this, options);
this.stopped = true;
console.log(`${this.name} is stopped`);
}
async destroy(options: any = {}) {

View File

@ -5,12 +5,14 @@ export default (app: Application) => {
.command('pm')
.argument('<method>')
.arguments('<plugins...>')
.option('-S, --skip-yarn-install', 'skip yarn install')
.action(async (method, plugins, options, ...args) => {
if (method === 'add') {
if (method === 'add' && !options.skipYarnInstall) {
const { run } = require('@nocobase/cli/src/util');
console.log('Install dependencies and rebuild workspaces');
await run('yarn', ['install']);
}
app.pm.clientWrite({ method, plugins });
});
};

View File

@ -1,2 +1 @@
export * from './PluginManager';
export * from './plugin-manager';

View File

@ -1,6 +1,8 @@
export default {
import { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'applicationPlugins',
namespace: 'core',
namespace: 'core.applicationPlugins',
duplicator: 'required',
repository: 'PluginManagerRepository',
fields: [
@ -11,4 +13,4 @@ export default {
{ type: 'boolean', name: 'builtIn' },
{ type: 'json', name: 'options' },
],
};
});

View File

@ -1,5 +1,5 @@
import { Repository } from '@nocobase/database';
import { PluginManager } from './PluginManager';
import { PluginManager } from './plugin-manager';
export class PluginManagerRepository extends Repository {
pm: PluginManager;

View File

@ -9,7 +9,7 @@ import Application from '../application';
import { Plugin } from '../plugin';
import collectionOptions from './options/collection';
import resourceOptions from './options/resource';
import { PluginManagerRepository } from './PluginManagerRepository';
import { PluginManagerRepository } from './plugin-manager-repository';
export interface PluginManagerOptions {
app: Application;
@ -38,7 +38,9 @@ export class PluginManager {
this.app.db.registerRepositories({
PluginManagerRepository,
});
this.collection = this.app.db.collection(collectionOptions);
this.repository = this.collection.repository as PluginManagerRepository;
this.repository.setPluginManager(this);
this.app.resourcer.define(resourceOptions);
@ -48,19 +50,6 @@ export class PluginManager {
actions: ['pm:*', 'applicationPlugins:list'],
});
this.server = net.createServer((socket) => {
socket.on('data', async (data) => {
const { method, plugins } = JSON.parse(data.toString());
try {
console.log(method, plugins);
await this[method](plugins);
} catch (error) {
console.error(error.message);
}
});
socket.pipe(socket);
});
this.app.on('beforeLoad', async (app, options) => {
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
await this.collection.sync();
@ -133,6 +122,19 @@ export class PluginManager {
}
async listen(): Promise<net.Server> {
this.server = net.createServer((socket) => {
socket.on('data', async (data) => {
const { method, plugins } = JSON.parse(data.toString());
try {
console.log(method, plugins);
await this[method](plugins);
} catch (error) {
console.error(error.message);
}
});
socket.pipe(socket);
});
if (fs.existsSync(this.pmSock)) {
await fs.promises.unlink(this.pmSock);
}
@ -308,6 +310,7 @@ export class PluginManager {
try {
const pluginNames = await this.repository.enable(name);
await this.app.reload();
await this.app.db.sync();
for (const pluginName of pluginNames) {
const plugin = this.app.getPlugin(pluginName);
@ -317,6 +320,8 @@ export class PluginManager {
await plugin.install();
await plugin.afterEnable();
}
await this.app.emitAsync('afterEnablePlugin', name);
} catch (error) {
throw error;
}
@ -333,6 +338,8 @@ export class PluginManager {
}
await plugin.afterDisable();
}
await this.app.emitAsync('afterDisablePlugin', name);
} catch (error) {
throw error;
}

View File

@ -27,6 +27,7 @@ interface ActionParams {
* @deprecated
*/
associatedIndex?: string;
[key: string]: any;
}
@ -41,6 +42,7 @@ interface SortActionParams {
method?: string;
target?: any;
sticky?: boolean;
[key: string]: any;
}
@ -51,6 +53,7 @@ interface Resource {
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
sort: (params?: SortActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
@ -58,6 +61,10 @@ export class MockServer extends Application {
async loadAndInstall(options: any = {}) {
await this.load({ method: 'install' });
if (options.afterLoad) {
await options.afterLoad(this);
}
await this.install({
...options,
sync: {

View File

@ -3,6 +3,6 @@ import { CollectionOptions } from '@nocobase/database';
export default {
name: 'rolesUsers',
duplicator: 'optional',
namespace: 'acl',
namespace: 'acl.acl',
fields: [{ type: 'boolean', name: 'default' }],
} as CollectionOptions;

View File

@ -1,8 +1,11 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'acl',
duplicator: 'required',
namespace: 'acl.acl',
duplicator: {
dumpable: 'required',
with: 'uiSchemas',
},
name: 'roles',
title: '{{t("Roles")}}',
autoGenId: false,

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'acl',
namespace: 'acl.acl',
duplicator: 'required',
name: 'rolesResources',
model: 'RoleResourceModel',

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'acl',
namespace: 'acl.acl',
duplicator: 'required',
name: 'rolesResourcesActions',
model: 'RoleResourceActionModel',

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'acl',
namespace: 'acl.acl',
duplicator: 'required',
name: 'rolesResourcesScopes',
fields: [

View File

@ -817,7 +817,7 @@ export class PluginACL extends Plugin {
await this.importCollections(resolve(__dirname, 'collections'));
this.db.extendCollection({
name: 'rolesUischemas',
namespace: 'acl',
namespace: 'acl.acl',
duplicator: 'required',
});
}

View File

@ -1,7 +1,7 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'audit-logs',
namespace: 'audit-logs.auditLogs',
duplicator: 'optional',
name: 'auditChanges',
title: '变动值',

View File

@ -1,7 +1,7 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'audit-logs',
namespace: 'audit-logs.auditLogs',
duplicator: 'optional',
name: 'auditLogs',
createdBy: false,

View File

@ -1,6 +1,8 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'charts.chartsQueries',
duplicator: 'optional',
name: 'chartsQueries',
fields: [
{

View File

@ -1,7 +1,7 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'china-region',
namespace: 'china-region.china-region',
duplicator: 'skip',
name: 'chinaRegions',
title: '中国行政区划',

View File

@ -14,7 +14,8 @@ describe('action test', () => {
afterEach(async () => {
await app.destroy();
});
it('should append uiSchema', async () => {
it('should get uiSchema', async () => {
await db.getRepository('collections').create({
values: {
name: 'posts',
@ -41,10 +42,14 @@ describe('action test', () => {
.resource('collections.fields', 'posts')
.list({
pageSize: 5,
appends: ['uiSchema'],
sort: ['sort'],
});
expect(response.statusCode).toEqual(200);
const data = response.body.data;
expect(data[0].uiSchema).toMatchObject({
'x-uid': 'test',
});
});
});

View File

@ -249,7 +249,7 @@ describe('collections repository', () => {
const testCollection = db.getCollection('tests');
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
const tableInfo0 = await getTableInfo();
expect(tableInfo0['date_a']).toBeDefined();
@ -286,7 +286,7 @@ describe('collections repository', () => {
const testCollection = db.getCollection('tests');
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
const tableInfo0 = await getTableInfo();
expect(tableInfo0[createdAt]).toBeDefined();
@ -339,7 +339,7 @@ describe('collections repository', () => {
testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field,
).toBe(true);
const getTableInfo = async () =>
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
const tableInfo0 = await getTableInfo();

View File

@ -59,7 +59,7 @@ describe('field defaultValue', () => {
const response2 = await app.agent().resource('test1').create();
expect(response2.body.data.field1).toBe('cba');
const results = await app.db.sequelize.getQueryInterface().describeTable(TestCollection.addSchemaTableName());
const results = await app.db.sequelize.getQueryInterface().describeTable(TestCollection.getTableNameWithSchema());
expect(results.field1.defaultValue).toBe('cba');
});

View File

@ -168,11 +168,10 @@ describe('reverseField options', () => {
filter: {
key: reverseField.get('key'),
},
appends: ['uiSchema'],
});
const uiSchema = reverseField.get('uiSchema');
expect(uiSchema['schema']).toEqual({ title: '123' });
expect(uiSchema).toEqual({ title: '123' });
});
it('should update uiSchema', async () => {
@ -211,13 +210,9 @@ describe('reverseField options', () => {
},
});
const f2 = await app
.agent()
.resource('collections.fields', 'a')
.get({
filterByTk: 'f_i02fjvduwmv',
appends: ['uiSchema'],
});
const f2 = await app.agent().resource('collections.fields', 'a').get({
filterByTk: 'f_i02fjvduwmv',
});
expect(f2.body.data.uiSchema.title).toBe('A2');
});
@ -270,13 +265,9 @@ describe('reverseField options', () => {
},
});
const f1 = await app
.agent()
.resource('collections.fields', 'b')
.get({
filterByTk: 'f_dctw6v5gsio',
appends: ['uiSchema'],
});
const f1 = await app.agent().resource('collections.fields', 'b').get({
filterByTk: 'f_dctw6v5gsio',
});
expect(f1.body.data.uiSchema.title).toBe('A');
});

View File

@ -97,7 +97,7 @@ describe('collections repository', () => {
const testCollection = app.db.getCollection('test');
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
expect(tableInfo['field']).toBeDefined();
});
@ -460,7 +460,7 @@ describe('collections repository', () => {
const columnName = collection.model.rawAttributes.testField.field;
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.addSchemaTableName());
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.getTableNameWithSchema());
expect(tableInfo[columnName]).toBeDefined();
});
@ -504,7 +504,7 @@ describe('collections repository', () => {
const indexes = (await app.db.sequelize
.getQueryInterface()
.showIndex(app.db.getCollection('test').addSchemaTableName())) as any;
.showIndex(app.db.getCollection('test').getTableNameWithSchema())) as any;
const columnName = app.db.getCollection('test').model.rawAttributes.testField.field;
@ -528,7 +528,7 @@ describe('collections repository', () => {
const afterIndexes = (await app.db.sequelize
.getQueryInterface()
.showIndex(app.db.getCollection('test').addSchemaTableName())) as any;
.showIndex(app.db.getCollection('test').getTableNameWithSchema())) as any;
expect(
afterIndexes.find(

View File

@ -1,9 +1,11 @@
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
import { mockServer } from '@nocobase/test';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '../';
export async function createApp(options = {}) {
export async function createApp(
options: { beforeInstall?: (app: MockServer) => void; beforePlugin?: (app: MockServer) => void } & any = {},
) {
const app = mockServer({
acl: false,
...options,
@ -12,11 +14,17 @@ export async function createApp(options = {}) {
await app.db.clean({ drop: true });
await app.db.sync({});
options.beforePlugin && options.beforePlugin(app);
app.plugin(PluginErrorHandler, { name: 'error-handler' });
app.plugin(Plugin, { name: 'collection-manager' });
app.plugin(PluginUiSchema, { name: 'ui-schema-storage' });
await app.loadAndInstall({ clean: true });
if (options.beforeInstall) {
await options.beforeInstall(app);
}
await app.install({ clean: true });
await app.start();
return app;
}

View File

@ -0,0 +1,162 @@
import { MockServer } from '@nocobase/test';
import { Plugin } from '@nocobase/server';
import { Database, MigrationContext } from '@nocobase/database';
import { createApp } from '../index';
import Migrator from '../../migrations/20230225111111-drop-ui-schema-relation';
class AddBelongsToPlugin extends Plugin {
beforeLoad() {
this.app.db.on('beforeDefineCollection', (options) => {
if (options.name == 'fields') {
options.fields.push({
type: 'belongsTo',
name: 'uiSchema',
target: 'uiSchemas',
foreignKey: 'uiSchemaUid',
});
}
});
}
}
describe('skip if already migrated', function () {
let app: MockServer;
let db: Database;
beforeEach(async () => {
app = await createApp({});
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should not run migration', async () => {
await db.getRepository('collections').create({
values: {
name: 'testCollection',
fields: [
{
name: 'testField',
type: 'string',
uiSchema: {
title: '{{t("Collection display name")}}',
type: 'number',
'x-component': 'Input',
required: true,
},
},
{
name: 'fieldWithoutSchema',
type: 'string',
},
],
},
context: {},
});
let error;
try {
const migration = new Migrator({ db } as MigrationContext);
migration.context.app = app;
await migration.up();
} catch (e) {
error = e;
}
expect(error).toBeFalsy();
});
});
describe('drop ui schema', () => {
let app: MockServer;
let db: Database;
beforeEach(async () => {
app = await createApp({
beforePlugin(app) {
app.plugin(AddBelongsToPlugin, { name: 'test' });
},
});
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should update uiSchema to options field', async () => {
const schemaContent = {
title: '{{t("Collection display name")}}',
type: 'number',
'x-component': 'Input',
required: true,
};
await db.getRepository('collections').create({
values: {
name: 'testCollection',
fields: [
{
name: 'testField',
type: 'string',
uiSchema: {
title: '{{t("Collection display name")}}',
type: 'number',
'x-component': 'Input',
required: true,
},
},
{
name: 'fieldWithoutSchema',
type: 'string',
},
],
},
context: {},
});
const testFieldRecord = await db.getRepository('fields').findOne({
filter: {
name: 'testField',
},
});
expect(testFieldRecord.rawAttributes['uiSchemaUid']).toBeTruthy();
const options = testFieldRecord.get('options');
expect(options.uiSchema).toBeFalsy();
// remove uiSchema field
const fieldCollection = db.getCollection('fields');
fieldCollection.removeField('uiSchema');
await fieldCollection.sync();
const testFieldRecord1 = await db.getRepository('fields').findOne({
filter: {
name: 'testField',
},
});
expect(testFieldRecord1.rawAttributes['uiSchemaUid']).toBeFalsy();
expect(testFieldRecord1.get('options').uiSchema).toBeFalsy();
// do migrate
const migration = new Migrator({ db } as MigrationContext);
migration.context.app = app;
await migration.up();
const testFieldRecord2 = await db.getRepository('fields').findOne({
filter: {
name: 'testField',
},
});
expect(testFieldRecord2.rawAttributes['uiSchemaUid']).toBeFalsy();
expect(testFieldRecord2.get('options').uiSchema).toMatchObject(schemaContent);
});
});

View File

@ -1,12 +1,11 @@
import { Database, MigrationContext } from '@nocobase/database';
import lodash from 'lodash';
import Migrator from '../../migrations/20221121111113-update-id-to-bigint';
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe.skip : describe.skip);
import { MockServer } from '@nocobase/test';
import { createApp } from '../index';
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe.skip : describe.skip);
excludeSqlite()('update id to bigint test', () => {
let app: MockServer;
let db: Database;
@ -69,7 +68,7 @@ excludeSqlite()('update id to bigint test', () => {
const assertBigInt = async (collectionName, fieldName) => {
const tableName = db.getCollection(collectionName)
? db.getCollection(collectionName).addSchemaTableName()
? db.getCollection(collectionName).getTableNameWithSchema()
: collectionName;
const tableInfo = await db.sequelize.getQueryInterface().describeTable(tableName);
@ -91,7 +90,7 @@ excludeSqlite()('update id to bigint test', () => {
let usersTableInfo = await db.sequelize
.getQueryInterface()
.describeTable(db.getCollection('users').addSchemaTableName());
.describeTable(db.getCollection('users').getTableNameWithSchema());
assertInteger(usersTableInfo.id.type);

View File

@ -1,8 +1,11 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
duplicator: 'required',
namespace: 'collection-manager.collections',
duplicator: {
dumpable: 'required',
with: 'collectionCategory',
},
name: 'collectionCategories',
autoGenId: true,
sortable: true,
@ -11,11 +14,6 @@ export default {
type: 'string',
name: 'name',
},
// {
// type: 'integer',
// name: 'sort',
// defaultValue: 0,
// },
{
type: 'string',
name: 'color',

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
namespace: 'collection-manager.collections',
duplicator: 'required',
name: 'collections',
title: '数据表配置',

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
namespace: 'collection-manager.collections',
duplicator: 'required',
name: 'fields',
autoGenId: false,
@ -59,12 +59,6 @@ export default {
sourceKey: 'key',
foreignKey: 'reverseKey',
},
{
type: 'belongsTo',
name: 'uiSchema',
target: 'uiSchemas',
foreignKey: 'uiSchemaUid',
},
{
type: 'json',
name: 'options',

View File

@ -0,0 +1,82 @@
import { Migration } from '@nocobase/server';
import { FieldModel } from '../models';
import { Collection } from '@nocobase/database';
export default class extends Migration {
async up() {
const migratedFieldsCount = await this.db.getRepository('fields').count({
filter: {
'options.uiSchema': { $exists: true },
},
});
if (migratedFieldsCount > 0) {
return;
}
const transaction = await this.db.sequelize.transaction();
const migrateFieldsSchema = async (collection: Collection) => {
this.app.log.info(`Start to migrate ${collection.name} collection's ui schema`);
collection.setField('uiSchemaUid', {
type: 'string',
});
const fieldRecords: Array<FieldModel> = await collection.repository.find({
transaction,
});
const fieldsCount = await collection.repository.count({
transaction,
});
this.app.log.info(`Total ${fieldsCount} fields need to be migrated`);
let i = 0;
for (const fieldRecord of fieldRecords) {
i++;
this.app.log.info(
`Migrate field ${fieldRecord.get('collectionName')}.${fieldRecord.get('name')}, ${i}/${fieldsCount}`,
);
const uiSchemaUid = fieldRecord.get('uiSchemaUid');
if (!uiSchemaUid) {
continue;
}
const uiSchemaRecord = await this.db.getRepository('uiSchemas').findOne({
filterByTk: uiSchemaUid,
transaction,
});
const uiSchema = uiSchemaRecord.get('schema');
fieldRecord.set('uiSchema', uiSchema);
await fieldRecord.save({
transaction,
});
}
await transaction.commit();
collection.removeField('uiSchemaUid');
this.app.log.info('Migrate uiSchema to options field done');
};
try {
await migrateFieldsSchema(this.db.getCollection('fields'));
if (this.db.getCollection('fieldsHistory')) {
await migrateFieldsSchema(this.db.getCollection('fieldsHistory'));
}
} catch (error) {
await transaction.rollback();
this.app.log.error(error);
throw error;
}
}
}

View File

@ -41,6 +41,11 @@ export class CollectionModel extends MagicAttributeModel {
await this.loadFields({ transaction });
}
await this.db.emitAsync('collection:loaded', {
collection,
transaction,
});
return collection;
}

View File

@ -20,7 +20,7 @@ export class FieldModel extends MagicAttributeModel {
}
async load(loadOptions?: LoadOptions) {
const { skipExist = false } = loadOptions || {};
const { skipExist = false, transaction } = loadOptions || {};
const collectionName = this.get('collectionName');
if (!this.db.hasCollection(collectionName)) {
@ -36,16 +36,14 @@ export class FieldModel extends MagicAttributeModel {
const options = this.get();
if (options.uiSchemaUid) {
const UISchema = this.db.getModel('uiSchemas');
const uiSchema = await UISchema.findByPk(options.uiSchemaUid, {
transaction: loadOptions.transaction,
});
const field = collection.setField(name, options);
Object.assign(options, { uiSchema: uiSchema.get() });
}
await this.db.emitAsync('field:loaded', {
fieldKey: this.get('key'),
transaction,
});
return collection.setField(name, options);
return field;
}
async migrate({ isNew, ...options }: MigrateOptions = {}) {
@ -107,7 +105,7 @@ export class FieldModel extends MagicAttributeModel {
const queryInterface = this.db.sequelize.getQueryInterface() as any;
const existsIndexes = await queryInterface.showIndex(collection.addSchemaTableName(), {
const existsIndexes = await queryInterface.showIndex(collection.getTableNameWithSchema(), {
transaction: options.transaction,
});
@ -121,7 +119,7 @@ export class FieldModel extends MagicAttributeModel {
if (existUniqueIndex) {
const existsUniqueConstraints = await queryInterface.showConstraint(
collection.addSchemaTableName(),
collection.getTableNameWithSchema(),
constraintName,
{},
);
@ -133,7 +131,7 @@ export class FieldModel extends MagicAttributeModel {
// @ts-ignore
await collection.sync({ ...options, force: false, alter: { drop: false } });
await queryInterface.addConstraint(collection.addSchemaTableName(), {
await queryInterface.addConstraint(collection.getTableNameWithSchema(), {
type: 'unique',
fields: [columnName],
name: constraintName,
@ -144,7 +142,7 @@ export class FieldModel extends MagicAttributeModel {
}
if (!unique && existsUniqueConstraint) {
await queryInterface.removeConstraint(collection.addSchemaTableName(), constraintName, {
await queryInterface.removeConstraint(collection.getTableNameWithSchema(), constraintName, {
transaction: options.transaction,
});
@ -168,7 +166,7 @@ export class FieldModel extends MagicAttributeModel {
const queryInterface = collection.db.sequelize.getQueryInterface();
await queryInterface.changeColumn(
collection.addSchemaTableName(),
collection.getTableNameWithSchema(),
collection.model.rawAttributes[this.get('name')].field,
{
type: field.dataType,

View File

@ -11,13 +11,13 @@ import {
afterCreateForReverseField,
beforeCreateForReverseField,
beforeDestroyForeignKey,
beforeInitOptions,
beforeInitOptions
} from './hooks';
import { InheritedCollection } from '@nocobase/database';
import { CollectionModel, FieldModel } from './models';
import * as process from 'process';
import lodash from 'lodash';
import * as process from 'process';
import { CollectionModel, FieldModel } from './models';
export class CollectionManagerPlugin extends Plugin {
public schema: string;
@ -255,7 +255,7 @@ export class CollectionManagerPlugin extends Plugin {
this.app.resourcer.use(async (ctx, next) => {
if (ctx.action.resourceName === 'collections.fields' && ['create', 'update'].includes(ctx.action.actionName)) {
ctx.action.mergeParams({
updateAssociationValues: ['uiSchema', 'reverseField'],
updateAssociationValues: ['reverseField'],
});
}
await next();

View File

@ -6,6 +6,7 @@
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"dependencies": {
"@koa/multer": "^3.0.2",
"@nocobase/client": "0.9.1-alpha.2",
"@nocobase/database": "0.9.1-alpha.2",
"@nocobase/server": "0.9.1-alpha.2",
@ -13,6 +14,7 @@
"dayjs": "^1.11.7",
"decompress": "^4.2.1",
"inquirer": "^8.0.0",
"koa-send": "^5.0.1",
"lodash": "^4.17.21",
"mkdirp": "^1.0.4",
"tar": "^6.1.13"

View File

@ -0,0 +1,73 @@
import { mockServer, MockServer } from '@nocobase/test';
import path from 'path';
describe('duplicator api', () => {
let app: MockServer;
beforeEach(async () => {
app = mockServer();
app.plugin(require('../server').default, { name: 'duplicator' });
app.plugin('error-handler');
app.plugin('collection-manager');
await app.loadAndInstall({ clean: true });
});
afterEach(async () => {
await app.destroy();
});
it('should get collection groups', async () => {
await app.db.getRepository('collections').create({
values: {
name: 'test_collection',
title: '测试Collection',
fields: [
{
name: 'test_field1',
type: 'string',
},
],
},
context: {},
});
const collectionGroupsResponse = await app.agent().resource('duplicator').dumpableCollections();
expect(collectionGroupsResponse.status).toBe(200);
const data = collectionGroupsResponse.body;
expect(data['requiredGroups']).toBeTruthy();
expect(data['optionalGroups']).toBeTruthy();
expect(data['userCollections']).toBeTruthy();
});
it('should request dump api', async () => {
const dumpResponse = await app.agent().post('/duplicator:dump').send({
selectedCollectionGroups: [],
selectedUserCollections: [],
});
expect(dumpResponse.status).toBe(200);
});
it('should request restore api', async () => {
const packageInfoResponse = await app
.agent()
.post('/duplicator:upload')
.attach('file', path.resolve(__dirname, './fixtures/dump.nbdump.fixture'));
console.log(packageInfoResponse.body);
expect(packageInfoResponse.status).toBe(200);
const data = packageInfoResponse.body.data;
expect(data['key']).toBeTruthy();
expect(data['meta']).toBeTruthy();
const restoreResponse = await app.agent().post('/duplicator:restore').send({
restoreKey: data['key'],
selectedOptionalGroups: [],
selectedUserCollections: [],
});
expect(restoreResponse.status).toBe(200);
});
});

View File

@ -0,0 +1,37 @@
import { mockServer, MockServer } from '@nocobase/test';
import { CollectionGroupManager } from '../collection-group-manager';
describe('collection group manager', () => {
let app: MockServer;
beforeEach(async () => {
app = mockServer({
plugins: ['error-handler', 'collection-manager'],
});
await app.loadAndInstall({
clean: true,
});
});
afterEach(async () => {
await app.destroy();
});
it('should list collection groups from db collections', async () => {
const collectionGroups = CollectionGroupManager.getGroups(app);
expect(collectionGroups.map((i) => i.function)).toMatchObject([
'migration',
'applicationPlugins',
'applicationVersion',
'collections',
]);
expect(collectionGroups.find((i) => i.function === 'collections')).toMatchObject({
namespace: 'collection-manager',
function: 'collections',
collections: ['collectionCategory', 'collectionCategories', 'collections', 'fields'],
dumpable: 'required',
});
});
});

View File

@ -0,0 +1,35 @@
import { MockServer } from '@nocobase/test';
import createApp from './index';
import { Dumper } from '../dumper';
describe('dumper', () => {
let app: MockServer;
beforeEach(async () => {
app = await createApp();
});
afterEach(async () => {
await app.destroy();
});
it('should get collection groups', async () => {
await app.db.getRepository('collections').create({
values: {
name: 'test_collection',
fields: [
{
name: 'test_field1',
type: 'string',
},
],
},
context: {},
});
const dump = new Dumper(app);
const dumpableCollections = await dump.dumpableCollections();
expect((dumpableCollections.requiredGroups || []).length).toBeGreaterThan(0);
expect(dumpableCollections.userCollections[0]['name']).toEqual('test_collection');
});
});

View File

@ -0,0 +1,11 @@
import { mockServer } from '@nocobase/test';
export default async function createApp() {
const app = mockServer();
app.plugin(require('../server').default, { name: 'duplicator' });
app.plugin('error-handler');
app.plugin('collection-manager');
await app.loadAndInstall({ clean: true });
return app;
}

View File

@ -0,0 +1,25 @@
import { Dumper } from '../dumper';
import send from 'koa-send';
import { getApp } from './get-app';
export default async function dumpAction(ctx, next) {
const data = <
{
selectedOptionalGroupNames: string[];
selectedUserCollections: string[];
app?: string;
}
>ctx.request.body;
const app = await getApp(ctx, data.app);
const dumper = new Dumper(app);
const { filePath, dirname } = await dumper.dump(data);
await send(ctx, filePath.replace(dirname, ''), {
root: dirname,
});
await next();
}

View File

@ -0,0 +1,13 @@
import { Dumper } from '../dumper';
import { getApp } from './get-app';
export default async function dumpableCollections(ctx, next) {
ctx.withoutDataWrapping = true;
const app = await getApp(ctx, ctx.request.query.app);
const dumper = new Dumper(app);
ctx.body = await dumper.dumpableCollections();
await next();
}

View File

@ -0,0 +1,14 @@
export async function getApp(ctx, subAppName) {
let app = ctx.app;
if (subAppName) {
const subApp = await app.appManager.getApplication(subAppName);
if (!subApp) {
throw new Error(`app ${subAppName} not found`);
}
app = subApp;
}
return app;
}

View File

@ -0,0 +1,34 @@
export default async function getDictAction(ctx, next) {
ctx.withoutDataWrapping = true;
let collectionNames = await ctx.db.getRepository('collections').find();
collectionNames = collectionNames.map((item) => item.get('name'));
const collections: any[] = [];
for (const [name, collection] of ctx.db.collections) {
const columns: any[] = [];
for (const key in collection.model.rawAttributes) {
if (Object.prototype.hasOwnProperty.call(collection.model.rawAttributes, key)) {
const attribute = collection.model.rawAttributes[key];
columns.push({
realName: attribute.field,
name: key,
});
}
}
const item = {
name,
title: collection.options.title,
namespace: collection.options.namespace,
duplicator: collection.options.duplicator,
// columns,
};
if (!item.namespace && collectionNames.includes(name)) {
item.namespace = 'collection-manager';
if (!item.duplicator) {
item.duplicator = 'optional';
}
}
collections.push(item);
}
ctx.body = collections;
await next();
}

View File

@ -0,0 +1,43 @@
import { Restorer } from '../restorer';
import * as os from 'os';
import path from 'path';
import { getApp } from './get-app';
export async function restoreAction(ctx, next) {
const { restoreKey, selectedOptionalGroups, selectedUserCollections } = ctx.request.body;
const appName = ctx.request.body.app;
const tmpDir = os.tmpdir();
const filePath = path.resolve(tmpDir, restoreKey);
const app = await getApp(ctx, appName);
const restorer = new Restorer(app, {
backUpFilePath: filePath,
});
await restorer.restore({
selectedOptionalGroupNames: selectedOptionalGroups,
selectedUserCollections,
});
await next();
}
export const getPackageContent = async (ctx, next) => {
const file = ctx.file;
const fileName = file.filename;
const restorer = new Restorer(ctx.app, {
backUpFilePath: file.path,
});
const restoreMeta = await restorer.parseBackupFile();
ctx.body = {
key: fileName,
meta: restoreMeta,
};
await next();
};

View File

@ -3,11 +3,14 @@ import { applyMixins, AsyncEmitter } from '@nocobase/utils';
import crypto from 'crypto';
import EventEmitter from 'events';
import fsPromises from 'fs/promises';
import inquirer from 'inquirer';
import lodash from 'lodash';
import * as os from 'os';
import path from 'path';
import { CollectionGroupManager } from './collection-group-manager';
export type AppMigratorOptions = {
workDir?: string;
};
abstract class AppMigrator extends EventEmitter {
protected workDir: string;
public app: Application;
@ -16,12 +19,7 @@ abstract class AppMigrator extends EventEmitter {
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
constructor(
app,
options?: {
workDir?: string;
},
) {
constructor(app, options?: AppMigratorOptions) {
super();
this.app = app;
@ -44,7 +42,14 @@ abstract class AppMigrator extends EventEmitter {
async getAppPlugins() {
const plugins = await this.app.db.getCollection('applicationPlugins').repository.find();
return ['core', ...plugins.map((plugin) => plugin.get('name'))];
return lodash.uniq(['core', ...this.app.pm.plugins.keys(), ...plugins.map((plugin) => plugin.get('name'))]);
}
async getAppPluginCollectionGroups() {
const plugins = await this.getAppPlugins();
return CollectionGroupManager.collectionGroups.filter((collectionGroup) =>
plugins.includes(collectionGroup.namespace),
);
}
async getCustomCollections() {
@ -60,64 +65,6 @@ abstract class AppMigrator extends EventEmitter {
await this.rmDir(this.workDir);
}
buildInquirerPluginQuestion(requiredGroups, optionalGroups) {
return {
type: 'checkbox',
name: 'collectionGroups',
message: `Select the plugin collections to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 20,
choices: [
new inquirer.Separator('== Required =='),
...requiredGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
checked: true,
disabled: true,
})),
new inquirer.Separator('== Optional =='),
...optionalGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
checked: this.direction === 'dump',
})),
],
};
}
buildInquirerCollectionQuestion(
collections: {
name: string;
title: string;
}[],
) {
return {
type: 'checkbox',
name: 'userCollections',
message: `Select the collection records to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 30,
choices: collections.map((collection) => {
return {
name: collection.title,
value: collection.name,
checked: this.direction === 'dump',
};
}),
};
}
buildInquirerQuestions(requiredGroups, optionalGroups, optionalCollections) {
const questions = [this.buildInquirerPluginQuestion(requiredGroups, optionalGroups)];
if (optionalCollections.length > 0) {
questions.push(this.buildInquirerCollectionQuestion(optionalCollections));
}
return questions;
}
findThroughCollections(collections: string[]) {
return [
...new Set(

View File

@ -1,40 +1,19 @@
import lodash from 'lodash';
import { Restorer } from './restorer';
interface CollectionGroup {
pluginName: string;
collections: string[];
function: string;
dumpable: 'required' | 'optional' | 'skip';
delayRestore?: any;
}
import { Application } from '@nocobase/server';
import { CollectionGroup } from '@nocobase/database';
export class CollectionGroupManager {
static collectionGroups: CollectionGroup[] = [];
static registerCollectionGroup(collectionGroup: CollectionGroup) {
this.collectionGroups.push(collectionGroup);
static getGroups(app: Application) {
return app.db.collectionGroupManager.getGroups();
}
static getGroupsCollections(groups: string[] | CollectionGroup[]) {
if (groups.length == 0) {
static getGroupsCollections(groups: CollectionGroup[]) {
if (!groups || groups.length == 0) {
return [];
}
if (lodash.isPlainObject(groups[0])) {
groups = (groups as CollectionGroup[]).map(
(collectionGroup) => `${collectionGroup.pluginName}.${collectionGroup.function}`,
);
}
return this.collectionGroups
.filter((collectionGroup) => {
const groupKey = `${collectionGroup.pluginName}.${collectionGroup.function}`;
return (groups as string[]).includes(groupKey);
})
.map((collectionGroup) => collectionGroup.collections)
.flat();
return groups.map((collectionGroup) => collectionGroup.collections).flat();
}
static classifyCollectionGroups(collectionGroups: CollectionGroup[]) {
@ -46,210 +25,4 @@ export class CollectionGroupManager {
optionalGroups,
};
}
static getDelayRestoreCollectionGroups() {
return this.collectionGroups.filter((collectionGroup) => collectionGroup.delayRestore);
}
}
CollectionGroupManager.registerCollectionGroup({
pluginName: 'core',
function: 'migration',
collections: ['migrations'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'multi-app-manager',
function: 'multi apps',
collections: ['applications'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'collection-manager',
function: 'collections',
collections: ['collections', 'fields', 'collectionCategories', 'collectionCategory'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'ui-schema-storage',
function: 'uiSchemas',
collections: ['uiSchemas', 'uiSchemaServerHooks', 'uiSchemaTemplates', 'uiSchemaTreePath'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'ui-routes-storage',
function: 'uiRoutes',
collections: ['uiRoutes'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'acl',
function: 'acl',
collections: ['roles', 'rolesResources', 'rolesResourcesActions', 'rolesResourcesScopes', 'rolesUischemas'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'workflow',
function: 'workflowConfig',
collections: ['workflows', 'flow_nodes'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'snapshot-field',
function: 'snapshot-field',
collections: ['collectionsHistory', 'fieldsHistory'],
dumpable: 'required',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'workflow',
function: 'executionLogs',
collections: ['executions', 'jobs'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'sequence-field',
function: 'sequences',
collections: ['sequences'],
dumpable: 'required',
async delayRestore(restorer: Restorer) {
const app = restorer.app;
const importedCollections = restorer.importedCollections;
const sequenceFields = importedCollections
.map((collection) =>
[...app.db.getCollection(collection).fields.values()].filter((field) => field.type === 'sequence'),
)
.flat()
.filter(Boolean);
// a single sequence field refers to a single row in sequences table
const sequencesAttributes = sequenceFields
.map((field) => {
const patterns = field.get('patterns').filter((pattern) => pattern.type === 'integer');
return patterns.map((pattern) => {
return {
collection: field.collection.name,
field: field.name,
key: pattern.options.key,
};
});
})
.flat();
if (sequencesAttributes.length > 0) {
await app.db.getRepository('sequences').destroy({
filter: {
$or: sequencesAttributes,
},
});
}
await restorer.importCollection({
name: 'sequences',
clear: false,
rowCondition(row) {
const results = sequencesAttributes.some((attributes) => {
return (
row.collection === attributes.collection && row.field === attributes.field && row.key === attributes.key
);
});
return results;
},
});
},
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'users',
function: 'users',
collections: ['users', 'rolesUsers'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'file-manager',
function: 'storageSetting',
collections: ['storages'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'file-manager',
function: 'attachmentRecords',
collections: ['attachments'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'system-settings',
function: 'systemSettings',
collections: ['systemSettings'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'verification',
function: 'verificationProviders',
collections: ['verifications_providers'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'verification',
function: 'verificationData',
collections: ['verifications'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'oidc',
function: 'oidcProviders',
collections: ['oidcProviders'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'saml',
function: 'samlProviders',
collections: ['samlProviders'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'map',
function: 'mapConfiguration',
collections: ['mapConfiguration'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'audit-logs',
function: 'auditLogs',
collections: ['auditLogs', 'auditChanges'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'graph-collection-manager',
function: 'graphCollectionPositions',
collections: ['graphPositions'],
dumpable: 'optional',
});
CollectionGroupManager.registerCollectionGroup({
pluginName: 'iframe-block',
function: 'iframe html storage',
collections: ['iframeHtml'],
dumpable: 'required',
});

View File

@ -0,0 +1,49 @@
import inquirer from 'inquirer';
import { Application } from '@nocobase/server';
import { Dumper } from '../dumper';
import InquireQuestionBuilder from './inquire-question-builder';
export default function addDumpCommand(app: Application) {
app
.command('dump')
.option('-a, --app <appName>', 'sub app name if you dump sub app in multiple apps')
.action(async (options) => {
let dumpApp = app;
if (options.app) {
const subApp = await app.appManager.getApplication(options.app);
if (!subApp) {
app.log.error(`app ${options.app} not found`);
await app.stop();
return;
}
dumpApp = subApp;
}
await dumpCommandAction(dumpApp);
});
}
async function dumpCommandAction(app) {
const dumper = new Dumper(app);
const { requiredGroups, optionalGroups, userCollections } = await dumper.dumpableCollections();
const questions = InquireQuestionBuilder.buildInquirerQuestions({
requiredGroups,
optionalGroups,
optionalCollections: userCollections,
direction: 'dump',
});
const results = await inquirer.prompt(questions);
const { filePath } = await dumper.dump({
selectedOptionalGroupNames: results.collectionGroups,
selectedUserCollections: results.userCollections,
});
app.log.info(`dumped to ${filePath}`);
await app.stop();
}

View File

@ -1,15 +0,0 @@
import { Application } from '@nocobase/server';
import { Dumper } from '../dumper';
export default function addDumpCommand(app: Application) {
app.command('dump').action(async () => {
await dumpAction(app);
});
}
async function dumpAction(app) {
const dumper = new Dumper(app);
await dumper.dump();
await app.stop();
}

View File

@ -0,0 +1,72 @@
import inquirer from 'inquirer';
import { CollectionGroup } from '@nocobase/database';
export default class InquireQuestionBuilder {
static buildInquirerQuestions(options: {
requiredGroups: CollectionGroup[];
optionalGroups: CollectionGroup[];
optionalCollections: {
name: string;
title: string;
}[];
direction: 'dump' | 'restore';
}) {
const { requiredGroups, optionalGroups, optionalCollections, direction } = options;
const questions = [this.buildInquirerPluginQuestion(requiredGroups, optionalGroups, direction)];
if (optionalCollections.length > 0) {
questions.push(this.buildInquirerCollectionQuestion(optionalCollections, direction));
}
return questions;
}
static buildInquirerPluginQuestion(requiredGroups, optionalGroups, direction: 'dump' | 'restore') {
return {
type: 'checkbox',
name: 'collectionGroups',
message: `Select the plugin collections to be ${direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 20,
choices: [
new inquirer.Separator('== Required =='),
...requiredGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.namespace})`,
value: `${collectionGroup.namespace}.${collectionGroup.function}`,
checked: true,
disabled: true,
})),
new inquirer.Separator('== Optional =='),
...optionalGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.namespace})`,
value: `${collectionGroup.namespace}.${collectionGroup.function}`,
checked: direction === 'dump',
})),
],
};
}
static buildInquirerCollectionQuestion(
collections: {
name: string;
title: string;
}[],
direction: 'dump' | 'restore',
) {
return {
type: 'checkbox',
name: 'userCollections',
message: `Select the collection records to be ${direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 30,
choices: collections.map((collection) => {
return {
name: collection.title,
value: collection.name,
checked: direction === 'dump',
};
}),
};
}
}

View File

@ -0,0 +1,94 @@
import { Application } from '@nocobase/server';
import { Restorer } from '../restorer';
import inquirer from 'inquirer';
import InquireQuestionBuilder from './inquire-question-builder';
export default function addRestoreCommand(app: Application) {
app
.command('restore')
.argument('<string>', 'restore file path')
.option('-a, --app <appName>', 'sub app name if you want to restore into a sub app')
.option('-f, --force', 'force restore without warning')
.action(async (restoreFilePath, options) => {
let importApp = app;
if (options.app) {
if (
!(await app.db.getCollection('applications').repository.findOne({
filter: { name: options.app },
}))
) {
// create sub app if not exists
await app.db.getCollection('applications').repository.create({
values: {
name: options.app,
},
});
}
const subApp = await app.appManager.getApplication(options.app);
if (!subApp) {
app.log.error(`app ${options.app} not found`);
await app.stop();
return;
}
importApp = subApp;
}
// should confirm data will be overwritten
if (!options.force && !(await restoreWarning())) {
return;
}
await restoreActionCommand(importApp, restoreFilePath);
});
}
interface RestoreContext {
app: Application;
dir: string;
}
async function restoreWarning() {
const results = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Danger !!! This action will overwrite your current data, please make sure you have a backup❗',
default: false,
},
]);
return results.confirm;
}
async function restoreActionCommand(app: Application, restoreFilePath: string) {
const restorer = new Restorer(app, {
backUpFilePath: restoreFilePath,
});
const restoreMeta = await restorer.parseBackupFile();
const { requiredGroups, selectedOptionalGroups, selectedUserCollections } = restoreMeta;
const questions = InquireQuestionBuilder.buildInquirerQuestions({
requiredGroups,
optionalGroups: selectedOptionalGroups,
optionalCollections: await Promise.all(
selectedUserCollections.map(async (name) => {
return { name, title: await restorer.getImportCollectionTitle(name) };
}),
),
direction: 'restore',
});
const results = await inquirer.prompt(questions);
await restorer.restore({
selectedOptionalGroupNames: results.collectionGroups,
selectedUserCollections: results.userCollections,
});
await app.stop();
}

View File

@ -1,22 +0,0 @@
import { Application } from '@nocobase/server';
import { Restorer } from '../restorer';
export default function addRestoreCommand(app: Application) {
app
.command('restore')
.argument('<string>', 'restore file path')
.action(async (restoreFilePath, options) => {
await restoreAction(app, restoreFilePath, options);
});
}
interface RestoreContext {
app: Application;
dir: string;
}
async function restoreAction(app: Application, restoreFilePath: string, options) {
const restorer = new Restorer(app);
await restorer.restore(restoreFilePath);
await app.stop();
}

View File

@ -2,7 +2,6 @@ import archiver from 'archiver';
import dayjs from 'dayjs';
import fs from 'fs';
import fsPromises from 'fs/promises';
import inquirer from 'inquirer';
import lodash from 'lodash';
import mkdirp from 'mkdirp';
import path from 'path';
@ -10,6 +9,7 @@ import stream from 'stream';
import util from 'util';
import { AppMigrator } from './app-migrator';
import { CollectionGroupManager } from './collection-group-manager';
import { CollectionGroup } from '@nocobase/database';
import { FieldValueWriter } from './field-value-writer';
import { DUMPED_EXTENSION, humanFileSize, sqlAdapter } from './utils';
@ -18,63 +18,94 @@ const finished = util.promisify(stream.finished);
export class Dumper extends AppMigrator {
direction = 'dump' as const;
async dump() {
const appPlugins = await this.getAppPlugins();
async dumpableCollections(): Promise<{
requiredGroups: CollectionGroup[];
optionalGroups: CollectionGroup[];
userCollections: Array<{
name: string;
title: string;
}>;
}> {
const appCollectionGroups = CollectionGroupManager.getGroups(this.app);
// get system available collection groups
const collectionGroups = CollectionGroupManager.collectionGroups.filter((collectionGroup) =>
appPlugins.includes(collectionGroup.pluginName),
);
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(appCollectionGroups);
const pluginsCollections = CollectionGroupManager.getGroupsCollections(appCollectionGroups);
const coreCollections = ['applicationPlugins'];
const customCollections = await this.getCustomCollections();
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(collectionGroups);
const pluginsCollections = CollectionGroupManager.getGroupsCollections(collectionGroups);
const optionalCollections = [...customCollections.filter((collection) => !pluginsCollections.includes(collection))];
const questions = this.buildInquirerQuestions(
const userCollections = await this.getCustomCollections();
return lodash.cloneDeep({
requiredGroups,
optionalGroups,
await Promise.all(
optionalCollections.map(async (name) => {
const collectionInstance = await this.app.db.getRepository('collections').findOne({
filterByTk: name,
});
userCollections: await Promise.all(
userCollections
.filter((collection) => !pluginsCollections.includes(collection)) //remove collection that is in plugins
.map(async (name) => {
// map user collection to { name, title }
return {
name,
title: collectionInstance.get('title'),
};
}),
const collectionInstance = await this.app.db.getRepository('collections').findOne({
filterByTk: name,
});
return {
name,
title: collectionInstance.get('title'),
};
}),
),
});
}
async dump(options: { selectedOptionalGroupNames: string[]; selectedUserCollections: string[] }) {
const { requiredGroups, optionalGroups } = await this.dumpableCollections();
let { selectedOptionalGroupNames, selectedUserCollections = [] } = options;
const throughCollections = this.findThroughCollections(selectedUserCollections);
const selectedOptionalGroups = optionalGroups.filter((group) => {
return selectedOptionalGroupNames.some((selectedOptionalGroupName) => {
const [namespace, functionKey] = selectedOptionalGroupName.split('.');
return group.function === functionKey && group.namespace === namespace;
});
});
const dumpedCollections = lodash.uniq(
[
CollectionGroupManager.getGroupsCollections(requiredGroups),
CollectionGroupManager.getGroupsCollections(selectedOptionalGroups),
selectedUserCollections,
throughCollections,
].flat(),
);
const results = await inquirer.prompt(questions);
const userCollections = results.userCollections || [];
const throughCollections = this.findThroughCollections(userCollections);
const dumpedCollections = [
coreCollections,
CollectionGroupManager.getGroupsCollections(requiredGroups),
CollectionGroupManager.getGroupsCollections(results.collectionGroups),
userCollections,
throughCollections,
].flat();
for (const collection of dumpedCollections) {
await this.dumpCollection({
name: collection,
});
}
await this.dumpMeta();
const mapGroupToMetaJson = (groups) =>
groups.map((group: CollectionGroup) => {
const data = {
...group,
};
if (group.delayRestore) {
data['delayRestore'] = true;
}
return data;
});
await this.dumpMeta({
requiredGroups: mapGroupToMetaJson(requiredGroups),
selectedOptionalGroups: mapGroupToMetaJson(selectedOptionalGroups),
selectedUserCollections: selectedUserCollections,
});
await this.dumpDb();
await this.packDumpedDir();
const filePath = await this.packDumpedDir();
await this.clearWorkDir();
return filePath;
}
async dumpDb() {
@ -84,18 +115,14 @@ export class Dumper extends AppMigrator {
if (dialect === 'postgres') {
// get user defined functions in postgres
const functions = await db.sequelize.query(
`SELECT
n.nspname AS function_schema,
p.proname AS function_name,
pg_get_functiondef(p.oid) AS def
FROM
pg_proc p
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE
n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY
function_schema,
function_name;`,
`SELECT n.nspname AS function_schema,
p.proname AS function_name,
pg_get_functiondef(p.oid) AS def
FROM pg_proc p
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY function_schema,
function_name;`,
{
type: 'SELECT',
},
@ -106,9 +133,13 @@ function_name;`,
}
// get user defined triggers in postgres
const triggers = await db.sequelize.query(`select pg_get_triggerdef(oid) from pg_trigger`, {
type: 'SELECT',
});
const triggers = await db.sequelize.query(
`select pg_get_triggerdef(oid)
from pg_trigger`,
{
type: 'SELECT',
},
);
for (const t of triggers) {
sqlContent.push(t['pg_get_triggerdef']);
@ -116,10 +147,10 @@ function_name;`,
// get user defined views in postgres
const views = await db.sequelize.query(
`SELECT table_schema, table_name, pg_get_viewdef("table_name", true) as def
FROM information_schema.views
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
ORDER BY table_schema, table_name`,
`SELECT table_schema, table_name, pg_get_viewdef("table_name", true) as def
FROM information_schema.views
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
ORDER BY table_schema, table_name`,
{
type: 'SELECT',
},
@ -136,12 +167,16 @@ ORDER BY table_schema, table_name`,
}
}
async dumpMeta() {
async dumpMeta(additionalMeta: Object = {}) {
const metaPath = path.resolve(this.workDir, 'meta');
await fsPromises.writeFile(
metaPath,
JSON.stringify({ version: this.app.version.get(), dialect: this.app.db.sequelize.getDialect() }),
JSON.stringify({
version: await this.app.version.get(),
dialect: this.app.db.sequelize.getDialect(),
...additionalMeta,
}),
'utf8',
);
}
@ -177,7 +212,11 @@ ORDER BY table_schema, table_name`,
const dataStream = fs.createWriteStream(dataFilePath);
const rows = await app.db.sequelize.query(
sqlAdapter(app.db, `SELECT * FROM ${collection.isParent() ? 'ONLY' : ''} ${collection.quotedTableName()}`),
sqlAdapter(
app.db,
`SELECT *
FROM ${collection.isParent() ? 'ONLY' : ''} ${collection.quotedTableName()}`,
),
{
type: 'SELECT',
},
@ -248,5 +287,9 @@ ORDER BY table_schema, table_name`,
await archive.finalize();
console.log('dumped to', filePath);
return {
filePath,
dirname,
};
}
}

View File

@ -1,44 +1,52 @@
import decompress from 'decompress';
import fs from 'fs';
import fsPromises from 'fs/promises';
import inquirer from 'inquirer';
import path from 'path';
import { AppMigrator } from './app-migrator';
import { AppMigrator, AppMigratorOptions } from './app-migrator';
import { CollectionGroupManager } from './collection-group-manager';
import { FieldValueWriter } from './field-value-writer';
import { readLines, sqlAdapter } from './utils';
import { Application } from '@nocobase/server';
export class Restorer extends AppMigrator {
direction = 'restore' as const;
backUpFilePath: string;
decompressed: boolean = false;
importedCollections: string[] = [];
async restore(backupFilePath: string) {
let filePath: string;
constructor(
app: Application,
options: AppMigratorOptions & {
backUpFilePath?: string;
},
) {
super(app, options);
const { backUpFilePath } = options;
if (path.isAbsolute(backupFilePath)) {
filePath = backupFilePath;
} else if (path.basename(backupFilePath) === backupFilePath) {
if (backUpFilePath) {
this.setBackUpFilePath(backUpFilePath);
}
}
setBackUpFilePath(backUpFilePath) {
if (path.isAbsolute(backUpFilePath)) {
this.backUpFilePath = backUpFilePath;
} else if (path.basename(backUpFilePath) === backUpFilePath) {
const dirname = path.resolve(process.cwd(), 'storage', 'duplicator');
filePath = path.resolve(dirname, backupFilePath);
this.backUpFilePath = path.resolve(dirname, backUpFilePath);
} else {
filePath = path.resolve(process.cwd(), backupFilePath);
this.backUpFilePath = path.resolve(process.cwd(), backUpFilePath);
}
}
const results = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Danger !!! This action will overwrite your current data, please make sure you have a backup❗',
default: false,
},
]);
async parseBackupFile() {
await this.decompressBackup(this.backUpFilePath);
return await this.getImportMeta();
}
if (results.confirm !== true) {
return;
}
await this.decompressBackup(filePath);
await this.importCollections();
async restore(options: { selectedOptionalGroupNames: string[]; selectedUserCollections: string[] }) {
await this.decompressBackup(this.backUpFilePath);
await this.importCollections(options);
await this.importDb();
await this.clearWorkDir();
}
@ -67,6 +75,7 @@ export class Restorer extends AppMigrator {
const index = meta.columns.indexOf('name');
const row = data.find((row) => JSON.parse(row)[index] === collectionName);
if (!row) {
throw new Error(`Collection ${collectionName} not found`);
}
@ -78,8 +87,7 @@ export class Restorer extends AppMigrator {
async getImportCollections() {
const collectionsDir = path.resolve(this.workDir, 'collections');
const collections = await fsPromises.readdir(collectionsDir);
return collections;
return await fsPromises.readdir(collectionsDir);
}
async getImportCollectionData(collectionName) {
@ -89,47 +97,19 @@ export class Restorer extends AppMigrator {
async getImportCollectionMeta(collectionName) {
const metaData = path.resolve(this.workDir, 'collections', collectionName, 'meta');
const meta = JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
return meta;
return JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
}
async importCollections(options?: { ignore?: string | string[] }) {
const coreCollections = ['applicationPlugins'];
const collections = await this.getImportCollections();
const importCustomCollections = await this.getImportCustomCollections();
const importPlugins = await this.getImportPlugins();
const collectionGroups = CollectionGroupManager.collectionGroups.filter((collectionGroup) => {
return (
importPlugins.includes(collectionGroup.pluginName) &&
collectionGroup.collections.every((collectionName) => collections.includes(collectionName))
);
});
const delayGroups = CollectionGroupManager.getDelayRestoreCollectionGroups();
const delayCollections = CollectionGroupManager.getGroupsCollections(delayGroups);
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(collectionGroups);
const pluginsCollections = CollectionGroupManager.getGroupsCollections(collectionGroups);
const optionalCollections = importCustomCollections.filter(
(collection) => !pluginsCollections.includes(collection) && !coreCollections.includes(collection),
);
const questions = this.buildInquirerQuestions(
requiredGroups,
optionalGroups,
await Promise.all(
optionalCollections.map(async (name) => {
return { name, title: await this.getImportCollectionTitle(name) };
}),
),
);
const results = await inquirer.prompt(questions);
async getImportMeta() {
const metaFile = path.resolve(this.workDir, 'meta');
return JSON.parse(await fsPromises.readFile(metaFile, 'utf8')) as any;
}
async importCollections(options: {
ignore?: string | string[];
selectedOptionalGroupNames: string[];
selectedUserCollections: string[];
}) {
const importCollection = async (collectionName: string) => {
const collectionMetaPath = path.resolve(this.workDir, 'collections', collectionName, 'meta');
@ -157,16 +137,20 @@ export class Restorer extends AppMigrator {
}
};
// import applicationPlugins first
await importCollection('applicationPlugins');
// reload app
await this.app.reload();
const requiredCollections = CollectionGroupManager.getGroupsCollections(requiredGroups).filter(
(collection) => !delayCollections.includes(collection),
);
const { requiredGroups, selectedOptionalGroups } = await this.parseBackupFile();
const delayGroups = [...requiredGroups, ...selectedOptionalGroups].filter((group) => group.delay);
const delayCollections = CollectionGroupManager.getGroupsCollections(delayGroups);
// import required plugins collections
for (const collectionName of requiredCollections) {
for (const collectionName of CollectionGroupManager.getGroupsCollections(requiredGroups).filter(
(i) => !delayCollections.includes(i) && i != 'applicationPlugins',
)) {
await importCollection(collectionName);
}
@ -181,11 +165,18 @@ export class Restorer extends AppMigrator {
},
});
const userCollections = results.userCollections || [];
const userCollections = options.selectedUserCollections || [];
const throughCollections = this.findThroughCollections(userCollections);
const customCollections = [
...CollectionGroupManager.getGroupsCollections(results.collectionGroups),
...CollectionGroupManager.getGroupsCollections(
selectedOptionalGroups.filter((group) => {
return options.selectedOptionalGroupNames.some((selectedOptionalGroupName) => {
const [namespace, functionKey] = selectedOptionalGroupName.split('.');
return group.function === functionKey && group.namespace === namespace;
});
}),
),
...userCollections,
...throughCollections,
];
@ -196,15 +187,20 @@ export class Restorer extends AppMigrator {
}
// import delay groups
const appGroups = CollectionGroupManager.getGroups(this.app);
for (const collectionGroup of delayGroups) {
await collectionGroup.delayRestore(this);
const appCollectionGroup = appGroups.find(
(group) => group.namespace === collectionGroup.name && group.function === collectionGroup.function,
);
await appCollectionGroup.delayRestore(this);
}
await this.emitAsync('restoreCollectionsFinished');
}
async decompressBackup(backupFilePath: string) {
await decompress(backupFilePath, this.workDir);
if (!this.decompressed) await decompress(backupFilePath, this.workDir);
}
async importCollection(options: {
@ -313,10 +309,11 @@ export class Restorer extends AppMigrator {
this.on('restoreCollectionsFinished', async () => {
if (this.app.db.inDialect('postgres')) {
const sequenceNameResult = await app.db.sequelize.query(
`SELECT column_default FROM information_schema.columns WHERE
table_name='${collection.model.tableName}' and "column_name" = 'id' and table_schema = '${
app.db.options.schema || 'public'
}';`,
`SELECT column_default
FROM information_schema.columns
WHERE table_name = '${collection.model.tableName}'
and "column_name" = 'id'
and table_schema = '${app.db.options.schema || 'public'}';`,
);
if (sequenceNameResult[0].length) {
@ -327,7 +324,8 @@ export class Restorer extends AppMigrator {
const sequenceName = match[1];
const maxVal = await app.db.sequelize.query(
`SELECT MAX("${primaryKeyAttribute.field}") FROM ${tableName}`,
`SELECT MAX("${primaryKeyAttribute.field}")
FROM ${tableName}`,
{
type: 'SELECT',
},
@ -341,7 +339,9 @@ export class Restorer extends AppMigrator {
if (this.app.db.inDialect('sqlite')) {
await app.db.sequelize.query(
`UPDATE sqlite_sequence set seq = (SELECT MAX("${primaryKeyAttribute.field}") FROM "${collection.model.tableName}") WHERE name = "${collection.model.tableName}"`,
`UPDATE sqlite_sequence
set seq = (SELECT MAX("${primaryKeyAttribute.field}") FROM "${collection.model.tableName}")
WHERE name = "${collection.model.tableName}"`,
);
}
});

View File

@ -1,8 +1,14 @@
import { Plugin } from '@nocobase/server';
import addDumpCommand from './commands/dump';
import addRestoreCommand from './commands/restore';
import addDumpCommand from './commands/dump-command';
import addRestoreCommand from './commands/restore-command';
import zhCN from './locale/zh-CN';
import dumpAction from './actions/dump-action';
import { getPackageContent, restoreAction } from './actions/restore-action';
import getDictAction from './actions/get-dict-action';
import dumpableCollections from './actions/dumpable-collections-action';
import multer from '@koa/multer';
import * as os from 'os';
export default class Duplicator extends Plugin {
beforeLoad() {
@ -15,41 +21,27 @@ export default class Duplicator extends Plugin {
async load() {
this.app.resourcer.define({
name: 'duplicator',
middleware: async (ctx, next) => {
if (ctx.action.actionName !== 'upload') {
return next();
}
const storage = multer.diskStorage({
destination: os.tmpdir(), // 获取临时目录
filename: function (req, file, cb) {
const randomName = Date.now().toString() + Math.random().toString().slice(2); // 随机生成文件名
cb(null, randomName);
},
});
const upload = multer({ storage }).single('file');
return upload(ctx, next);
},
actions: {
getDict: async (ctx, next) => {
ctx.withoutDataWrapping = true;
let collectionNames = await this.db.getRepository('collections').find();
collectionNames = collectionNames.map((item) => item.get('name'));
const collections: any[] = [];
for (const [name, collection] of this.db.collections) {
const columns: any[] = [];
for (const key in collection.model.rawAttributes) {
if (Object.prototype.hasOwnProperty.call(collection.model.rawAttributes, key)) {
const attribute = collection.model.rawAttributes[key];
columns.push({
realName: attribute.field,
name: key,
});
}
}
const item = {
name,
title: collection.options.title,
namespace: collection.options.namespace,
duplicator: collection.options.duplicator,
// columns,
};
if (!item.namespace && collectionNames.includes(name)) {
item.namespace = 'collection-manager';
if (!item.duplicator) {
item.duplicator = 'optional';
}
}
collections.push(item);
}
ctx.body = collections;
await next();
},
restore: restoreAction,
upload: getPackageContent,
dump: dumpAction,
dumpableCollections: dumpableCollections,
getDict: getDictAction,
},
});

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'file-manager',
namespace: 'file-manager.attachmentRecords',
duplicator: 'optional',
name: 'attachments',
title: '文件管理器',

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'file-manager',
namespace: 'file-manager.storageSetting',
duplicator: 'optional',
name: 'storages',
title: '存储引擎',

View File

@ -11,8 +11,18 @@ export default class PluginFileManager extends Plugin {
async install() {
const defaultStorageConfig = getStorageConfig(this.storageType());
if (defaultStorageConfig) {
const Storage = this.db.getCollection('storages');
if (
await Storage.repository.findOne({
filter: {
name: defaultStorageConfig.defaults().name,
},
})
) {
return;
}
await Storage.repository.create({
values: {
...defaultStorageConfig.defaults(),

View File

@ -1,7 +1,7 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'graph-collection-manager',
namespace: 'graph-collection-manager.graphCollectionPositions',
duplicator: 'required',
name: 'graphPositions',
fields: [

View File

@ -1,7 +1,7 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'iframe-block',
namespace: 'iframe-block.iframe-html-storage',
duplicator: 'required',
name: 'iframeHtml',
createdBy: true,

View File

@ -1,8 +1,8 @@
import { CollectionOptions } from "@nocobase/client";
import { MapConfigurationCollectionName } from "../constants";
import { CollectionOptions } from '@nocobase/client';
import { MapConfigurationCollectionName } from '../constants';
export default {
namespace: 'map',
namespace: 'map.mapConfiguration',
duplicator: 'optional',
name: MapConfigurationCollectionName,
title: '{{t("Map Manager")}}',
@ -11,19 +11,19 @@ export default {
title: 'Access key',
comment: '访问密钥',
name: 'accessKey',
type: 'string'
type: 'string',
},
{
title: 'securityJsCode',
comment: 'securityJsCode or serviceHOST',
name: 'securityJsCode',
type: 'string'
type: 'string',
},
{
title: 'Map type',
comment: '地图类型',
name: 'type',
type: 'string',
}
]
} as CollectionOptions
},
],
} as CollectionOptions;

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,21 @@
import { SchemaComponent, useRecord } from '@nocobase/client';
import { Card } from 'antd';
import React from 'react';
import { schema } from './settings/schemas/applications';
const AppVisitor = () => {
const record = useRecord();
return (
<a href={`/apps/${record.name}/admin/`} target={'_blank'}>
View
</a>
);
};
export const AppManager = () => {
return (
<Card bordered={false}>
<SchemaComponent schema={schema} components={{ AppVisitor }} />
</Card>
);
};

View File

@ -0,0 +1,22 @@
import { connect, mapReadPretty } from '@formily/react';
import { Input as AntdInput } from 'antd';
import React from 'react';
const ReadPretty = (props) => {
const content = props.value && (
<a target={'_blank'} href={`/apps/${props.value}/admin`}>
{props.value}
</a>
);
return (
<div style={props.style}>
{props.addonBefore}
{props.prefix}
{content}
{props.suffix}
{props.addonAfter}
</div>
);
};
export const AppNameInput = connect(AntdInput, mapReadPretty(ReadPretty));

View File

@ -0,0 +1,15 @@
import { SchemaComponent } from '@nocobase/client';
import { Card } from 'antd';
import React from 'react';
const schema = {
type: 'object',
}
export const Settings = () => {
return (
<Card bordered={false}>
<SchemaComponent schema={schema} />
</Card>
);
};

View File

@ -0,0 +1,92 @@
import {
Icon,
PinnedPluginListProvider,
SchemaComponentOptions,
SettingsCenterProvider,
useRequest
} from '@nocobase/client';
import { Button, Dropdown, Menu } from 'antd';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { AppManager } from './AppManager';
import { AppNameInput } from './AppNameInput';
const MultiAppManager = () => {
const history = useHistory();
const { data, loading, run } = useRequest(
{
resource: 'applications',
action: 'listPinned',
},
{
manual: true,
},
);
const menu = (
<Menu>
{(data?.data || []).map((app) => {
return (
<Menu.Item
key={app.name}
onClick={() => {
window.open(`/apps/${app.name}/admin/`, '_blank');
}}
>
{app.displayName || app.name}
</Menu.Item>
);
})}
{(data?.data || []).length > 0 && <Menu.Divider />}
<Menu.Item
onClick={() => {
history.push('/admin/settings/multi-app-manager/applications');
}}
>
Manage applications
</Menu.Item>
</Menu>
);
return (
<Dropdown
onVisibleChange={(visible) => {
run();
}}
overlay={menu}
>
<Button title={'Apps'} icon={<Icon type={'AppstoreOutlined'} />} />
</Dropdown>
);
};
export default (props) => {
return (
<PinnedPluginListProvider
items={{
am: { order: 201, component: 'MultiAppManager', pin: true },
}}
>
<SchemaComponentOptions components={{ MultiAppManager, AppNameInput }}>
<SettingsCenterProvider
settings={{
'multi-app-manager': {
title: 'Multi-app manager',
icon: 'AppstoreOutlined',
tabs: {
applications: {
title: 'Applications',
component: () => <AppManager />,
},
// settings: {
// title: 'Settings',
// component: () => <Settings />,
// },
},
},
}}
>
{props.children}
</SettingsCenterProvider>
</SchemaComponentOptions>
</PinnedPluginListProvider>
);
};

View File

@ -0,0 +1,351 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import {
useActionContext,
useRecord,
useRequest,
useResourceActionContext,
useResourceContext
} from '@nocobase/client';
const collection = {
name: 'applications',
targetKey: 'name',
fields: [
{
type: 'uid',
name: 'name',
primaryKey: true,
prefix: 'a',
interface: 'input',
uiSchema: {
type: 'string',
title: '{{t("App ID")}}',
required: true,
'x-component': 'Input',
'x-validator': 'uid',
},
},
{
type: 'string',
name: 'displayName',
interface: 'input',
uiSchema: {
type: 'string',
title: '{{t("App display name")}}',
required: true,
'x-component': 'Input',
},
},
{
type: 'string',
name: 'pinned',
interface: 'checkbox',
uiSchema: {
type: 'boolean',
'x-content': '{{t("Pin to menu")}}',
'x-component': 'Checkbox',
},
},
{
type: 'string',
name: 'status',
interface: 'radioGroup',
defaultValue: 'pending',
uiSchema: {
type: 'string',
title: '{{t("App status")}}',
enum: [
{ label: 'Pending', value: 'pending' },
{ label: 'Running', value: 'running' },
],
'x-component': 'Radio.Group',
},
},
],
};
export const useDestroy = () => {
const { refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
return {
async run() {
await resource.destroy({ filterByTk });
refresh();
},
};
};
export const useDestroyAll = () => {
const { state, setState, refresh } = useResourceActionContext();
const { resource } = useResourceContext();
return {
async run() {
await resource.destroy({
filterByTk: state?.selectedRowKeys || [],
});
setState?.({ selectedRowKeys: [] });
refresh();
},
};
};
export const schema: ISchema = {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
resourceName: 'applications',
request: {
resource: 'applications',
action: 'list',
params: {
pageSize: 50,
sort: ['-createdAt'],
appends: [],
},
},
},
'x-component': 'CollectionProvider',
'x-component-props': {
collection,
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action',
'x-component-props': {
useAction: useDestroyAll,
confirm: {
title: "{{t('Delete')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
create: {
type: 'void',
title: '{{t("Add new")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
const ctx = useActionContext();
return useRequest(
() =>
Promise.resolve({
data: {
name: `a_${uid()}`,
},
}),
{ ...options, refreshDeps: [ctx.visible] },
);
},
},
title: '{{t("Add new")}}',
properties: {
displayName: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
pinned: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
cname: {
title: '{{t("Custom domain")}}',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useCreateAction }}',
},
},
},
},
},
},
},
},
},
},
table: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
'x-component-props': {
rowKey: 'name',
rowSelection: {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
displayName: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
displayName: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
name: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
name: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
pinned: {
type: 'void',
title: '{{t("Pin to menu")}}',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
pinned: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
actions: {
type: 'void',
title: '{{t("Actions")}}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
view: {
type: 'void',
'x-component': 'AppVisitor',
'x-component-props': {},
},
update: {
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'Action.Link',
'x-component-props': {},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{t("Edit")}}',
properties: {
displayName: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
pinned: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
cname: {
title: '{{t("Custom domain")}}',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useUpdateAction }}',
},
},
},
},
},
},
},
},
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: "{{t('Delete')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
useAction: '{{cm.useDestroyAction}}',
},
},
},
},
},
},
},
},
},
},
},
};

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