Merge branch 'main' into fix/pg-schema-with-inherit

This commit is contained in:
Chareice 2023-02-14 15:32:49 +08:00
commit 899a7f7141
203 changed files with 3818 additions and 624 deletions

View File

@ -77,3 +77,6 @@ INIT_ALI_SMS_VERIFY_CODE_SIGN=
# use any string name (no space)
DEFAULT_SMS_VERIFY_CODE_PROVIDER=
# in nodejs 17+ that SSL v3 causes some ecosystem libraries to become incompatible. Configuring this option can prevent upgrading SSL V3
# NODE_OPTIONS=--openssl-legacy-provider

View File

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

View File

@ -1,67 +1,67 @@
# Action
Action 是对资源的操作过程的描述,通常包含数据库处理等,类似其他框架中的 service 层,最简化的实现可以是一个 Koa 的中间件函数。在资源管理器里,针对特定资源定义的普通操作函数会被包装成 Action 类型的实例,当请求匹配对应资源的操作时,执行对应的操作过程。
Action is the description of the operation process on resource, including database processing and so on. It is like the service layer in other frameworks, and the most simplified implementation can be a Koa middleware function. In the resourcer, common action functions defined for particular resources are wrapped into instances of the type Action, and when the request matches the action of the corresponding resource, the corresponding action is executed.
## 构造函数
## Constructor
通常不需要直接实例化 Action而是由资源管理器自动调用 `Action` 的静态方法 `toInstanceMap()` 进行实例化。
Instead of being instantiated directly, the Action is usually instantiated automatically by the resourcer by calling the static method `toInstanceMap()` of `Action`.
### `constructor(options: ActionOptions)`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `handler` | `Function` | - | 操作函数 |
| `middlewares?` | `Middleware \| Middleware[]` | - | 针对操作的中间件 |
| `values?` | `Object` | - | 默认的操作数据 |
| `fields?` | `string[]` | - | 默认针对的字段组 |
| `appends?` | `string[]` | - | 默认附加的关联字段组 |
| `except?` | `string[]` | - | 默认排除的字段组 |
| `filter` | `FilterOptions` | - | 默认的过滤参数 |
| `sort` | `string[]` | - | 默认的排序参数 |
| `page` | `number` | - | 默认的页码 |
| `pageSize` | `number` | - | 默认的每页数量 |
| `maxPageSize` | `number` | - | 默认最大每页数量 |
| `handler` | `Function` | - | Handler function |
| `middlewares?` | `Middleware \| Middleware[]` | - | Middlewares for the action |
| `values?` | `Object` | - | Default action data |
| `fields?` | `string[]` | - | Default list of targeted fields |
| `appends?` | `string[]` | - | Default list of associated fields to append |
| `except?` | `string[]` | - | Default list of fields to exclude |
| `filter` | `FilterOptions` | - | Default filtering options |
| `sort` | `string[]` | - | Default sorting options |
| `page` | `number` | - | Default page number |
| `pageSize` | `number` | - | Default page size |
| `maxPageSize` | `number` | - | Default maximum page size |
## 实例成员
## Instance Members
### `actionName`
被实例化后对应的操作名称。在实例化时从请求中解析获取。
Name of the action that corresponds to when it is instantiated. It is parsed and fetched from the request at instantiation.
### `resourceName`
被实例化后对应的资源名称。在实例化时从请求中解析获取。
Name of the resource that corresponds to when the action is instantiated. It is parsed and fetched from the request at instantiation.
### `resourceOf`
被实例化后对应的关系资源的主键值。在实例化时从请求中解析获取。
Value of the primary key of the relational resource that corresponds to when the action is instantiated. It is parsed and fetched from the request at instantiation.
### `readonly middlewares`
针对操作的中间件列表。
List of middlewares targeting the action.
### `params`
操作参数。包含对应操作的所有相关参数,实例化时根据定义的 action 参数初始化,之后请求中解析前端传入的参数并根据对应参数的合并策略合并。如果有其他中间件的处理,也会有类似的合并过程。直到 handler 处理时,访问 params 得到的是经过多次合并的最终参数。
Action parameters. It contains all relevant parameters for the corresponding action, which are initialized at instantiation according to the defined action parameters. Later when parameters passed from the front-end are parsed in requests, the corresponding parameters are merged according to the merge strategy. Similar merging process is done if there is other middleware processing. When it comes to the hander, the `params` are the final parameters that have been merged for several times.
参数的合并过程提供了针对操作处理的可扩展性,可以通过自定义中间件的方式按业务需求进行参数的前置解析和处理,例如表单提交的参数验证就可以在此环节实现。
The merging process of parameters provides scalability for action processing, and the parameters can be pre-parsed and processed according to business requirements by means of custom middleware. For example, parameter validation for form submission can be implemented in this part.
预设的参数可以参考 [/api/actions] 中不同操作的参数。
Refer to [/api/actions] for the pre-defined parameters of different actions.
参数中还包含请求资源路由的描述部分,具体如下:
The parameters also contain a description of the request resource route:
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `resourceName` | `string` | - | 资源名称 |
| `resourceIndex` | `string \| number` | - | 资源的主键值 |
| `associatedName` | `string` | - | 所属关系资源的名称 |
| `associatedIndex` | `string \| number` | - | 所属关系资源的主键值 |
| `associated` | `Object` | - | 所属关系资源的实例 |
| `actionName` | `string` | - | 操作名称 |
| `resourceName` | `string` | - | Name of the resource |
| `resourceIndex` | `string \| number` | - | Value of the primary key of the resource |
| `associatedName` | `string` | - | Name of the associated resource it belongs to |
| `associatedIndex` | `string \| number` | - | Value of the primary key of the associated resource it belongs to |
| `associated` | `Object` | - | Instance of the associated resource it belongs to |
| `actionName` | `string` | - | Name of the action |
**示例**
**Example**
```ts
app.resourcer.define('books', {
@ -88,40 +88,40 @@ app.resourcer.define('books', {
});
```
## 实例方法
## Instance Methods
### `mergeParams()`
将额外的参数合并至当前参数集,且可以根据不同的策略进行合并。
Merge additional parameters to the current set of parameters according to different strategies.
**签名**
**Signature**
* `mergeParams(params: ActionParams, strategies: MergeStrategies = {})`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `params` | `ActionParams` | - | 额外的参数集 |
| `strategies` | `MergeStrategies` | - | 针对每个参数的合并策略 |
| `params` | `ActionParams` | - | Additional set of parameters |
| `strategies` | `MergeStrategies` | - | Merge strategies for each parameter |
内置操作的默认合并策略如下表:
The default merge strategy for built-in actions is as follows:
| 参数名 | 类型 | 默认值 | 合并策略 | 描述 |
| Name | Type | Default | Merge Strategy |Description |
| --- | --- | --- | --- | --- |
| `filterByTk` | `number \| string` | - | SQL `and` | 查询主键值 |
| `filter` | `FilterOptions` | - | SQL `and` | 查询过滤参数 |
| `fields` | `string[]` | - | 取并集 | 字段组 |
| `appends` | `string[]` | `[]` | 取并集 | 附加的关联字段组 |
| `except` | `string[]` | `[]` | 取并集 | 排除的字段组 |
| `whitelist` | `string[]` | `[]` | 取交集 | 可处理字段的白名单 |
| `blacklist` | `string[]` | `[]` | 取并集 | 可处理字段的黑名单 |
| `sort` | `string[]` | - | SQL `order by` | 查询排序参数 |
| `page` | `number` | - | 覆盖 | 页码 |
| `pageSize` | `number` | - | 覆盖 | 每页数量 |
| `values` | `Object` | - | 深度合并 | 操作提交的数据 |
| `filterByTk` | `number \| string` | - | SQL `and` | Get value of the primary key |
| `filter` | `FilterOptions` | - | SQL `and` | Get filtering options |
| `fields` | `string[]` | - | Take the union | List of fields |
| `appends` | `string[]` | `[]` | Take the union | List of associated fields to append |
| `except` | `string[]` | `[]` | Take the union | List of associated fields to exclude |
| `whitelist` | `string[]` | `[]` | Take the intersection | Whitelist of fields that can be handled |
| `blacklist` | `string[]` | `[]` | Take the union | Blacklist of fields that can be handled |
| `sort` | `string[]` | - | SQL `order by` | Get the sorting options |
| `page` | `number` | - | Override | Page number |
| `pageSize` | `number` | - | Override | Page size |
| `values` | `Object` | - | Deep merge | Operation of the submitted data |
**示例**
**Example**
```ts
ctx.action.mergeParams({

View File

@ -1,29 +1,29 @@
# Middleware
与 Koa 的中间件类似,但提供了更多增强的功能,可以方便的进行更多的扩展。
It is similar to the middleware of Koa, but with more enhanced features for easy extensions.
中间件定义后可以在资源管理器等多处进行插入使用,由开发者自行控制调用的时机。
The defined middleware can be inserted for use in multiple places, such as the resourcer, and it is up to the developer for when to invoke it.
## 构造函数
## Constructor
**签名**
**Signature**
* `constructor(options: Function)`
* `constructor(options: MiddlewareOptions)`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `Function` | - | 中间件处理函数 |
| `options` | `MiddlewareOptions ` | - | 中间件配置项 |
| `options.only` | `string[]` | - | 仅允许指定的操作 |
| `options.except` | `string[]` | - | 排除指定的操作 |
| `options.handler` | `Function` | - | 处理函数 |
| `options` | `Function` | - | Handler function of middlware |
| `options` | `MiddlewareOptions ` | - | Configuration items of middlware |
| `options.only` | `string[]` | - | Only the specified actions are allowed |
| `options.except` | `string[]` | - | The specified actions are excluded |
| `options.handler` | `Function` | - | Handler function |
**示例**
**Example**
简单定义:
Simple definition:
```ts
const middleware = new Middleware((ctx, next) => {
@ -31,7 +31,7 @@ const middleware = new Middleware((ctx, next) => {
});
```
使用相关参数:
Definition with relevant parameters:
```ts
const middleware = new Middleware({
@ -42,15 +42,15 @@ const middleware = new Middleware({
});
```
## 实例方法
## Instance Methods
### `getHandler()`
返回已经过编排的处理函数。
Get the orchestrated handler functions.
**示例**
**Example**
以下中间件在请求时会先输出 `1`,再输出 `2`
The following middleware will output `1` and then `2` when requested.
```ts
const middleware = new Middleware((ctx, next) => {
@ -68,35 +68,35 @@ app.resourcer.use(middleware.getHandler());
### `use()`
对当前中间件添加中间件函数。用于提供中间件的扩展点。示例见 `getHandler()`
Add a middleware function to the current middleware. Used to provide extension points for the middleware. See `getHandler()` for the examples.
**签名**
**Signature**
* `use(middleware: Function)`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `middleware` | `Function` | - | 中间件处理函数 |
| `middleware` | `Function` | - | Handler function of the middleware |
### `disuse()`
移除当前中间件已添加的中间件函数。
Remove the middleware functions that have been added to the current middleware.
**签名**
**Signature**
* `disuse(middleware: Function)`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `middleware` | `Function` | - | 中间件处理函数 |
| `middleware` | `Function` | - | Handler function of the middleware |
**示例**
**Example**
以下示例在请求处理是只输出 `1`,不执行 fn1 中的 `2` 输出。
The following example will only output `1` when requested, the output of `2` in fn1 will not be executed.
```ts
const middleware = new Middleware((ctx, next) => {
@ -118,41 +118,41 @@ middleware.disuse(fn1);
### `canAccess()`
判断当前中间件针对特定操作是否要被调用,通常由资源管理器内部处理。
Check whether the current middleware is to be invoked for a specific action, it is usually handled by the resourcer internally.
**签名**
**Signature**
* `canAccess(name: string): boolean`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | - | 操作名称 |
| `name` | `string` | - | Name of the action |
## 其他导出
## Other Exports
### `branch()`
创建一个分支中间件,用于在中间件中进行分支处理。
Create a branch middleware for branching in the middleware.
**签名**
**Signature**
* `branch(map: { [key: string]: Function }, reducer: Function, options): Function`
**参数**
**Parameter**
| 参数名 | 类型 | 默认值 | 描述 |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `map` | `{ [key: string]: Function }` | - | 分支处理函数映射表,键名由后续计算函数在调用时给出 |
| `reducer` | `(ctx) => string` | - | 计算函数,用于基于上下文计算出分支的键名 |
| `options?` | `Object` | - | 分支配置项 |
| `options.keyNotFound?` | `Function` | `ctx.throw(404)` | 未找到键名时的处理函数 |
| `options.handlerNotSet?` | `Function` | `ctx.throw(404)` | 未定义处理函数时的处理 |
| `map` | `{ [key: string]: Function }` | - | Mapping table of the branch handler function, key names are given by subsequent calculation functions when called |
| `reducer` | `(ctx) => string` | - | Calculation function, it is used to calculate the key name of the branch based on the context |
| `options?` | `Object` | - | Configuration items of the branch |
| `options.keyNotFound?` | `Function` | `ctx.throw(404)` | Handler function when key name is not found |
| `options.handlerNotSet?` | `Function` | `ctx.throw(404)` | The function when no handler function is defined |
**示例**
**Example**
用户验证时,根据请求 URL 中 query 部分的 `authenticator` 参数的值决定后续需要如何处理:
When authenticating user, determine what to do next according to the value of the `authenticator` parameter in the query section of the request URL.
```ts
app.resourcer.use(branch({

View File

@ -96,7 +96,7 @@ app.resourcer.use(middleware.getHandler());
**示例**
以下示例在请求处理只输出 `1`,不执行 fn1 中的 `2` 输出。
以下示例在请求处理只输出 `1`,不执行 fn1 中的 `2` 输出。
```ts
const middleware = new Middleware((ctx, next) => {

View File

@ -8,7 +8,7 @@
"licenses": [
{
"type": "Apache-2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0"
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
],
"scripts": {

View File

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

View File

@ -35,6 +35,21 @@ for (const key in env) {
}
}
if (require('semver').satisfies(process.version, '>16') && !process.env.UNSET_NODE_OPTIONS) {
if (process.env.NODE_OPTIONS) {
let opts = process.env.NODE_OPTIONS;
if (!opts.includes('--openssl-legacy-provider')) {
opts = opts + ' --openssl-legacy-provider';
}
if (!opts.includes('--no-experimental-fetch')) {
opts = opts + ' --no-experimental-fetch';
}
process.env.NODE_OPTIONS = opts;
} else {
process.env.NODE_OPTIONS = '--openssl-legacy-provider --no-experimental-fetch';
}
}
const cli = require('../src/cli');
cli.parse(process.argv);

View File

@ -92,7 +92,7 @@ export const useResourceAction = (props, opts = {}) => {
*/
const { resource, action, fieldName: tableFieldName } = props;
const { fields } = useCollection();
const appends = fields?.filter((field) => field.target && field.interface !== 'snapshot').map((field) => field.name);
const appends = fields?.filter((field) => field.target).map((field) => field.name);
const params = useActionParams(props);
const api = useAPIClient();
const fieldSchema = useFieldSchema();

View File

@ -75,7 +75,7 @@ const useAssociationNames = (collection) => {
const collectionFields = getCollectionFields(collection);
const associationFields = new Set();
for (const collectionField of collectionFields) {
if (collectionField.target && collectionField.interface !== 'snapshot') {
if (collectionField.target) {
associationFields.add(collectionField.name);
const fields = getCollectionFields(collectionField.target);
for (const field of fields) {

View File

@ -6,7 +6,6 @@ import { useCollectionManager } from '../collection-manager';
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
import { useFixedSchema } from '../schema-component';
export const TableBlockContext = createContext<any>({});
const InternalTableBlockProvider = (props) => {
@ -39,7 +38,7 @@ export const useAssociationNames = (collection) => {
const collectionFields = getCollectionFields(collection);
const associationFields = new Set();
for (const collectionField of collectionFields) {
if (collectionField.target && collectionField.interface !== 'snapshot') {
if (collectionField.target) {
associationFields.add(collectionField.name);
const fields = getCollectionFields(collectionField.target);
for (const field of fields) {

View File

@ -37,7 +37,7 @@ const InternalTableSelectorProvider = (props) => {
const useAssociationNames2 = (collection) => {
const { getCollectionFields } = useCollectionManager();
const names = getCollectionFields(collection)
?.filter((field) => field.target && field.interface !== 'snapshot')
?.filter((field) => field.target)
.map((field) => field.name);
return names;
};
@ -55,7 +55,7 @@ const useAssociationNames = (collection) => {
const collectionFields = getCollectionFields(collection);
const associationFields = new Set();
for (const collectionField of collectionFields) {
if (collectionField.target && collectionField.interface !== 'snapshot') {
if (collectionField.target) {
associationFields.add(collectionField.name);
const fields = getCollectionFields(collectionField.target);
for (const field of fields) {

View File

@ -1,13 +1,13 @@
import { Spin } from 'antd';
import React, { useContext, useState } from 'react';
import { keyBy } from 'lodash';
import React, { useContext, useState } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
import { CollectionManagerContext } from './context';
import { CollectionManagerOptions } from './types';
import { templateOptions } from '../collection-manager/Configuration/templates';
import * as defaultInterfaces from './interfaces';
import { useCollectionHistory } from './CollectionHistoryProvider';
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
import { CollectionCategroriesContext, CollectionManagerContext } from './context';
import * as defaultInterfaces from './interfaces';
import { CollectionManagerOptions } from './types';
export const CollectionManagerProvider: React.FC<CollectionManagerOptions> = (props) => {
const { service, interfaces, collections = [], refreshCM, templates } = props;
@ -38,7 +38,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
action: 'list',
params: {
paginate: false,
appends: ['fields', 'fields.uiSchema'],
appends: ['fields', 'fields.uiSchema','category'],
filter: {
// inherit: false,
},
@ -72,3 +72,33 @@ export const RemoteCollectionManagerProvider = (props: any) => {
/>
);
};
export const CollectionCategroriesProvider = (props) => {
const api = useAPIClient();
const options={
url: 'collectionCategories:list',
params: {
paginate: false,
sort:['sort']
},
}
const result = useRequest(options);
if (result.loading) {
return <Spin />;
}
return (
<CollectionCategroriesContext.Provider
value={{
...result,
data: result?.data?.data,
refresh:async ()=>{
const { data } = await api.request(options);
result.mutate(data);
return data?.data || [];
}
}}
>
{props.children}
</CollectionCategroriesContext.Provider>
);
};

View File

@ -19,10 +19,17 @@ import {
ViewFieldAction,
AddCollection,
AddCollectionAction,
AddCategoryAction,
AddCategory,
EditCollection,
EditCollectionAction,
ConfigurationTabs,
EditCategory,
EditCategoryAction,
} from './Configuration';
import { CollectionCategroriesProvider } from './CollectionManagerProvider';
const schema: ISchema = {
type: 'object',
properties: {
@ -43,6 +50,7 @@ const schema2: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-decorator': 'CollectionCategroriesProvider',
'x-component': 'ConfigurationTable',
},
},
@ -50,15 +58,19 @@ const schema2: ISchema = {
export const CollectionManagerPane = () => {
return (
<Card bordered={false}>
// <Card bordered={false}>
<SchemaComponent
schema={schema2}
components={{
CollectionCategroriesProvider,
ConfigurationTable,
ConfigurationTabs,
AddFieldAction,
AddCollectionField,
AddCollection,
AddCollectionAction,
AddCategoryAction,
AddCategory,
EditCollection,
EditCollectionAction,
EditFieldAction,
@ -67,9 +79,11 @@ export const CollectionManagerPane = () => {
OverridingFieldAction,
ViewCollectionField,
ViewFieldAction,
EditCategory,
EditCategoryAction,
}}
/>
</Card>
// </Card>
);
};
@ -103,12 +117,15 @@ export const CollectionManagerShortcut2 = () => {
schema={schema}
components={{
ConfigurationTable,
ConfigurationTabs,
AddFieldAction,
EditFieldAction,
OverridingFieldAction,
ViewFieldAction,
AddCollectionAction,
EditCollectionAction,
AddCategoryAction,
EditCategoryAction,
}}
/>
</ActionContext.Provider>

View File

@ -3,7 +3,9 @@ import { CollectionContext } from './context';
import { useCollectionManager } from './hooks';
import { CollectionOptions } from './types';
export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = (props) => {
export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = (
props,
) => {
const { allowNull, name, collection, children } = props;
const { getCollection } = useCollectionManager();
const value = getCollection(collection || name);

View File

@ -0,0 +1,60 @@
import { PlusOutlined } from '@ant-design/icons';
import { useForm } from '@formily/react';
import { cloneDeep } from 'lodash';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../api-client';
import { ActionContext, SchemaComponent, useActionContext } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { CollectionCategroriesContext } from '../context';
import * as components from './components';
import { collectionCategorySchema } from './schemas/collections';
const useCreateCategry = () => {
const form = useForm();
const ctx = useActionContext();
const { refresh } = useContext(CollectionCategroriesContext);
const api = useAPIClient();
return {
async run() {
await form.submit();
const values = cloneDeep(form.values);
await api.resource('collectionCategories').create({
values: {
...values,
},
});
ctx.setVisible(false);
await form.reset();
await refresh();
},
};
};
export const AddCategory = (props) => {
return <AddCategoryAction {...props} />;
};
export const AddCategoryAction = (props) => {
const { scope, getContainer, children } = props;
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<div onClick={() => setVisible(true)} title={t('Add category')}>
{children || <PlusOutlined />}
</div>
<SchemaComponent
schema={collectionCategorySchema}
components={{ ...components }}
scope={{
getContainer,
useCancelAction,
createOnly: true,
useCreateCategry,
...scope,
}}
/>
</ActionContext.Provider>
);
};

View File

@ -15,7 +15,7 @@ import { useResourceActionContext, useResourceContext } from '../ResourceActionP
import * as components from './components';
import { templateOptions } from './templates';
const getSchema = (schema, record: any, compile): ISchema => {
const getSchema = (schema, category, compile): ISchema => {
if (!schema) {
return;
}
@ -30,6 +30,7 @@ const getSchema = (schema, record: any, compile): ISchema => {
const initialValue: any = {
name: `t_${uid()}`,
template: schema.name,
category,
...cloneDeep(schema.default),
};
if (initialValue.reverseField) {
@ -234,6 +235,9 @@ export const AddCollectionAction = (props) => {
const items = templateOptions().map((option) => {
return { label: compile(option.title), key: option.name };
});
const {
state: { category },
} = useResourceActionContext();
return (
<RecordProvider record={record}>
<ActionContext.Provider value={{ visible, setVisible }}>
@ -248,7 +252,7 @@ export const AddCollectionAction = (props) => {
overflow: 'auto',
}}
onClick={(info) => {
const schema = getSchema(getTemplate(info.key), record, compile);
const schema = getSchema(getTemplate(info.key), category, compile);
setSchema(schema);
setVisible(true);
}}

View File

@ -7,6 +7,7 @@ import { useCurrentAppInfo } from '../../appInfo';
import { useRecord } from '../../record-provider';
import { SchemaComponent, SchemaComponentContext, useCompile } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { CollectionCategroriesContext } from '../context';
import { useCollectionManager } from '../hooks/useCollectionManager';
import { DataSourceContext } from '../sub-table';
import { AddSubFieldAction } from './AddSubFieldAction';
@ -76,6 +77,7 @@ export const ConfigurationTable = () => {
const {
data: { database },
} = useCurrentAppInfo();
const data = useContext(CollectionCategroriesContext);
const collectonsRef: any = useRef();
collectonsRef.current = collections;
const compile = useCompile();
@ -93,9 +95,14 @@ export const ConfigurationTable = () => {
value: item.name,
}));
};
const loadCategories = async () => {
return data.data.map((item: any) => ({
label: compile(item.name),
value: item.id,
}));
};
const ctx = useContext(SchemaComponentContext);
return (
<div>
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
<SchemaComponent
schema={collectionSchema}
@ -111,6 +118,7 @@ export const ConfigurationTable = () => {
useSelectedRowKeys,
useAsyncDataSource,
loadCollections,
loadCategories,
useCurrentFields,
useNewId,
useCancelAction,
@ -119,6 +127,5 @@ export const ConfigurationTable = () => {
}}
/>
</SchemaComponentContext.Provider>
</div>
);
};

View File

@ -0,0 +1,255 @@
import { MenuOutlined } from '@ant-design/icons';
import {
DndContext,
DragEndEvent,
DragOverlay,
MouseSensor,
useDraggable,
useDroppable,
useSensor,
useSensors
} from '@dnd-kit/core';
import { observer, RecursionField } from '@formily/react';
import { uid } from '@formily/shared';
import { Badge, Card, Dropdown, Menu, Modal, Tabs } from 'antd';
import React, { useContext, useState } from 'react';
import { useAPIClient } from '../../api-client';
import { SchemaComponent, SchemaComponentOptions, useCompile } from '../../schema-component';
import { CollectionCategroriesContext } from '../context';
import { useResourceActionContext } from '../ResourceActionProvider';
import { collectionTableSchema } from './schemas/collections';
function Draggable(props) {
const { attributes, listeners, setNodeRef } = useDraggable({
id: props.id,
data: props.data,
});
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
<div>{props.children}</div>
</div>
);
}
function Droppable(props) {
const { isOver, setNodeRef } = useDroppable({
id: props.id,
data: props.data,
});
const style = isOver
? {
color: 'green',
}
: undefined;
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
);
}
const TabTitle = observer(({ item }: { item: any }) => {
return (
<Droppable id={item.id.toString()} data={item}>
<div>
<Draggable id={item.id.toString()} data={item}>
<TabBar item={item} />
</Draggable>
</div>
</Droppable>
);
});
const TabBar = ({ item }) => {
const compile = useCompile();
return (
<span>
<Badge color={item.color} />
{compile(item.name)}
</span>
);
};
const DndProvider = observer((props) => {
const [activeTab, setActiveId] = useState(null);
const { refresh } = useContext(CollectionCategroriesContext);
const { refresh: refreshCM } = useResourceActionContext();
const api = useAPIClient();
const onDragEnd = async (props: DragEndEvent) => {
const { active, over } = props;
setTimeout(() => {
setActiveId(null);
});
if (over && over.id !== active.id) {
await api.resource('collectionCategories').move({
sourceId: active.id,
targetId: over.id,
});
await refresh();
await refreshCM();
}
};
function onDragStart(event) {
setActiveId(event.active?.data.current);
}
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
},
});
const sensors = useSensors(mouseSensor);
return (
<DndContext sensors={sensors} onDragEnd={onDragEnd} onDragStart={onDragStart}>
{props.children}
<DragOverlay>
{activeTab ? <span style={{ whiteSpace: 'nowrap' }}>{<TabBar item={activeTab} />}</span> : null}
</DragOverlay>
</DndContext>
);
});
export const ConfigurationTabs = () => {
const { data, refresh } = useContext(CollectionCategroriesContext);
const { refresh: refreshCM, run, defaultRequest, setState } = useResourceActionContext();
const [key, setKey] = useState('all');
const tabsItems = data
.sort((a, b) => b.sort - a.sort)
.concat()
.map((v) => {
return {
...v,
schema: collectionTableSchema,
};
});
!tabsItems.find((v) => v.id === 'all') &&
tabsItems.unshift({
name: '{{t("All collections")}}',
id: 'all',
sort: 0,
closable: false,
schema: collectionTableSchema,
});
const compile = useCompile();
const [activeKey, setActiveKey] = useState('all');
const api = useAPIClient();
const onChange = (key: string) => {
setActiveKey(key);
setKey(uid());
if (key !== 'all') {
const prevFilter = defaultRequest?.params?.filter;
const filter = { $and: [prevFilter, { 'category.id': key }] };
run({ filter });
setState?.({ category: [+key], params: [{ filter }] });
} else {
run();
setState?.({ category: [], params: [] });
}
};
const remove = (key: any) => {
Modal.confirm({
title: compile("{{t('Delete category')}}"),
content: compile("{{t('Are you sure you want to delete it?')}}"),
onOk: async () => {
await api.resource('collectionCategories').destroy({
filter: {
id: key,
},
});
key === +activeKey && setActiveKey('all');
await refresh();
await refreshCM();
},
});
};
const loadCategories = async () => {
return data.map((item: any) => ({
label: compile(item.name),
value: item.id,
}));
};
const menu = (item) => (
<Menu>
<Menu.Item key={'edit'}>
<SchemaComponent
schema={{
type: 'void',
properties: {
[uid()]: {
'x-component': 'EditCategory',
'x-component-props': {
item: item,
},
},
},
}}
/>
</Menu.Item>
<Menu.Item key="delete" onClick={() => remove(item.id)}>
{compile("{{t('Delete category')}}")}
</Menu.Item>
</Menu>
);
return (
<DndProvider>
<Tabs
addIcon={
<SchemaComponent
schema={{
type: 'void',
properties: {
addCategories: {
type: 'void',
title: '{{ t("Add category") }}',
'x-component': 'AddCategory',
'x-component-props': {
type: 'primary',
},
},
},
}}
/>
}
onChange={onChange}
defaultActiveKey="all"
type="editable-card"
destroyInactiveTabPane={true}
tabBarStyle={{ marginBottom: '0px' }}
>
{tabsItems.map((item) => {
return (
<Tabs.TabPane
tab={
item.id !== 'all' ? (
<div data-no-dnd="true">
<TabTitle item={item} />
</div>
) : (
compile(item.name)
)
}
key={item.id}
closable={item.closable}
closeIcon={
<Dropdown overlay={menu(item)}>
<MenuOutlined />
</Dropdown>
}
>
<Card bordered={false}>
<SchemaComponentOptions
inherit
scope={{ loadCategories, categoryVisible: item.id === 'all', categoryId: item.id }}
>
<RecursionField name={key} schema={item.schema} onlyRenderProperties />
</SchemaComponentOptions>
</Card>
</Tabs.TabPane>
);
})}
</Tabs>
</DndProvider>
);
};

View File

@ -0,0 +1,81 @@
import { useForm } from '@formily/react';
import { cloneDeep } from 'lodash';
import React, { useContext, useEffect, useState } from 'react';
import { useAPIClient, useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { CollectionCategroriesContext } from '../context';
import { useResourceActionContext } from '../ResourceActionProvider';
import * as components from './components';
import { collectionCategoryEditSchema } from './schemas/collections';
const useEditCategry = () => {
const form = useForm();
const ctx = useActionContext();
const { refresh } = useContext(CollectionCategroriesContext);
const { refresh: refreshCM } = useResourceActionContext();
const api = useAPIClient();
const { id } = useRecord();
return {
async run() {
await form.submit();
const values = cloneDeep(form.values);
await api.resource('collectionCategories').update({
filter: { id: id },
values: {
...values,
},
});
ctx.setVisible(false);
await form.reset();
await refresh();
await refreshCM();
},
};
};
const useValuesFromRecord = (options) => {
const record = useRecord();
const result = useRequest(() => Promise.resolve({ data: { ...record } }), {
...options,
manual: true,
});
const ctx = useActionContext();
useEffect(() => {
if (ctx.visible) {
result.run();
}
}, [ctx.visible]);
return result;
};
export const EditCategory = (props) => {
return <EditCategoryAction {...props} />;
};
export const EditCategoryAction = (props) => {
const { scope, getContainer, item, children } = props;
const [visible, setVisible] = useState(false);
const compile = useCompile();
return (
<RecordProvider record={item}>
<ActionContext.Provider value={{ visible, setVisible }}>
<>{children || <span onClick={() => setVisible(true)}>{compile('{{ t("Edit category") }}')}</span>}</>
<SchemaComponent
schema={collectionCategoryEditSchema}
components={{ ...components }}
scope={{
getContainer,
useCancelAction,
createOnly: true,
useEditCategry,
useValuesFromRecord,
...scope,
}}
/>
</ActionContext.Provider>
</RecordProvider>
);
};

View File

@ -78,7 +78,7 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
export const useValuesFromRecord = (options) => {
const record = useRecord();
const result = useRequest(() => Promise.resolve({ data: { autoGenId: true, ...record } }), {
const result = useRequest(() => Promise.resolve({ data: { autoGenId: true, ...record,category:record?.category.map((v)=>v.id) } }), {
...options,
manual: true,
});

View File

@ -0,0 +1,16 @@
import { observer } from '@formily/react';
import { Tag } from 'antd';
import React from 'react';
import { useCompile } from '../../../schema-component';
export const CollectionCategory = observer((props: any) => {
const { value } = props;
const compile = useCompile();
return (
<>
{value.map((item) => {
return <Tag color={item.color}>{compile(item?.name)}</Tag>;
})}
</>
);
});

View File

@ -11,6 +11,9 @@ export * from './OverridingCollectionField';
export * from './ViewInheritedField';
export * from './AddCollectionAction';
export * from './EditCollectionAction';
export * from './ConfigurationTabs';
export * from './AddCategoryAction';
export * from './EditCategoryAction'
registerValidateFormats({
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,

View File

@ -1,10 +1,12 @@
import { ISchema, Schema } from '@formily/react';
import { message } from 'antd';
import { uid } from '@formily/shared';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client';
import { i18n } from '../../../i18n';
import { CollectionOptions } from '../../types';
import { CollectionTemplate } from '../components/CollectionTemplate';
import { CollectionCategory } from '../components/CollectionCategory';
import { collectionFieldSchema } from './collectionFields';
const compile = (source) => {
@ -94,12 +96,24 @@ export const collectionSchema: ISchema = {
filter: {
'hidden.$isFalsy': true,
},
appends: [],
appends: ['category'],
},
},
},
properties: {
actions: {
tabs: {
type: 'void',
'x-component': 'ConfigurationTabs',
},
},
},
},
};
export const collectionTableSchema: ISchema = {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
@ -145,7 +159,7 @@ export const collectionSchema: ISchema = {
},
},
},
table: {
[uid()]: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
@ -160,7 +174,6 @@ export const collectionSchema: ISchema = {
const { t } = useTranslation();
return {
async move(from, to) {
console.log(from, to);
await api.resource('collections').move({
sourceId: from.key,
targetId: to.key,
@ -207,6 +220,19 @@ export const collectionSchema: ISchema = {
},
},
column4: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
'x-visible': 'categoryVisible',
title: '{{t("Collection category")}}',
properties: {
category: {
'x-component': CollectionCategory,
'x-read-pretty': true,
},
},
},
column5: {
type: 'void',
title: '{{ t("Actions") }}',
'x-component': 'Table.Column',
@ -270,6 +296,115 @@ export const collectionSchema: ISchema = {
},
},
},
};
export const collectionCategorySchema: ISchema = {
type: 'object',
properties: {
form: {
type: 'void',
'x-decorator': 'Form',
'x-component': 'Action.Modal',
title: '{{ t("Add category") }}',
'x-component-props': {
width: 520,
getContainer: '{{ getContainer }}',
},
properties: {
name: {
type: 'string',
title: '{{t("Category name")}}',
required: true,
'x-disabled': '{{ !createOnly }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
color: {
type: 'string',
title: '{{t("Color")}}',
required: false,
'x-decorator': 'FormItem',
'x-component': 'ColorSelect',
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useCreateCategry }}',
},
},
},
},
},
},
},
};
export const collectionCategoryEditSchema: ISchema = {
type: 'object',
properties: {
form: {
type: 'void',
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ useValuesFromRecord }}',
},
'x-component': 'Action.Modal',
title: '{{ t("Edit category") }}',
'x-component-props': {
width: 520,
getContainer: '{{ getContainer }}',
},
properties: {
name: {
type: 'string',
title: '{{t("Category name")}}',
required: true,
'x-disabled': '{{ !createOnly }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
color: {
type: 'string',
title: '{{t("Color")}}',
required: false,
'x-decorator': 'FormItem',
'x-component': 'ColorSelect',
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useEditCategry }}',
},
},
},
},
},
},
},
};

View File

@ -329,5 +329,9 @@ export const useFilterActionProps = () => {
const { collection } = useResourceContext();
const options = useFilterFieldOptions(collection.fields);
const service = useResourceActionContext();
return useFilterFieldProps({ options, params: service.params, service });
return useFilterFieldProps({
options,
params: service.state?.params?.[0] || service.params,
service,
});
};

View File

@ -9,3 +9,5 @@ export const CollectionManagerContext = createContext<CollectionManagerOptions>(
export const CollectionContext = createContext<CollectionOptions>({});
export const CollectionFieldContext = createContext<CollectionFieldOptions>({});
export const CollectionCategroriesContext = createContext({ data: [], refresh: () => {} });

View File

@ -38,5 +38,5 @@ export const calendar: ICollectionTemplate = {
availableFieldInterfaces: {
include: [],
},
configurableProperties: getConfigurableProperties('title', 'name', 'inherits'),
configurableProperties: getConfigurableProperties('title', 'name', 'inherits','category'),
};

View File

@ -9,5 +9,5 @@ export const general: ICollectionTemplate = {
default: {
fields: [],
},
configurableProperties: getConfigurableProperties('title', 'name', 'inherits', 'moreOptions'),
configurableProperties: getConfigurableProperties('title', 'name', 'inherits', 'category', 'moreOptions'),
};

View File

@ -102,6 +102,17 @@ export const defaultConfigurableProperties = {
'x-visible': '{{ enableInherits}}',
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
},
category: {
title: '{{t("Categories")}}',
type: 'hasMany',
name: 'category',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
'x-reactions': ['{{useAsyncDataSource(loadCategories)}}'],
},
...moreOptions,
moreOptions: {
title: '{{t("More options")}}',
@ -124,6 +135,7 @@ export type DefaultConfigurableKeys =
| 'name'
| 'title'
| 'inherits'
| 'category'
| 'autoGenId'
| 'createdBy'
| 'updatedBy'

View File

@ -34,6 +34,14 @@ export default {
"UI editor": "UI editor",
"Collection": "Collection",
"Collections & Fields": "Collections & Fields",
"All collections":"All collections",
"Add category":"Add category",
"Delete category":"Delete category",
"Edit category":"Edit category",
"Collection category":"Collection category",
"Sort":"Sort",
"Categories":"Categories",
"Category name":"Category name",
"Roles & Permissions": "Roles & Permissions",
"Edit profile": "Edit profile",
"Change password": "Change password",

View File

@ -34,6 +34,14 @@ export default {
"UI editor": "UI エディタ",
"Collection": "コレクション",
"Collections & Fields": "コレクションとフィールド",
"All collections":"すべてのデータテーブル",
"Add category":"分類の追加",
"Edit category":"分類の編集",
"Sort":"ソート#ソート#",
"Categories":"データテーブルカテゴリ",
"Category name":"分類名",
"Delete category":"分類の削除",
"Collection category":"Collection category",
"Roles & Permissions": "役割と権限",
"Edit profile": "プロフィール",
"Change password": "パスワード変更",

View File

@ -107,6 +107,7 @@ export default {
"Create collection": "Создать Коллекцию",
"Collection display name": "Отображение имени Коллекции",
"Collection name": "Имя Коллекции",
"Categories":"Категории таблиц данных",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Случайно сгенерированный и может быть изменен. Поддерживает буквы, цифры и подчеркивания, должно начинаться с буквы.",
"Storage type": "Тип Хранилища",
"Edit": "Изменить",

View File

@ -34,6 +34,14 @@ export default {
"UI editor": "界面配置",
"Collection": "数据表",
"Collections & Fields": "数据表配置",
"All collections":"全部数据表",
"Add category":"添加分类",
"Delete category":"删除分类",
"Edit category":"编辑分类",
"Collection category":"数据表类别",
"Sort":"排序",
"Categories":"数据表类别",
"Category name":"分类名称",
"Roles & Permissions": "角色和权限",
"Edit profile": "个人资料",
"Change password": "修改密码",

View File

@ -67,7 +67,9 @@ FormItem.Designer = (props) => {
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
const targetFields = collectionField?.target ? getCollectionFields(collectionField.target) : [];
const targetFields = collectionField?.target
? getCollectionFields(collectionField.target)
: getCollectionFields(collectionField?.targetCollection) ?? [];
const fieldComponentOptions = useFieldComponentOptions();
const isSubFormAssocitionField = field.address.segments.includes('__form_grid');
const initialValue = {
@ -466,7 +468,7 @@ FormItem.Designer = (props) => {
}}
/>
)}
{collectionField?.target && fieldSchema['x-component'] === 'CollectionField' && (
{options.length > 0 && fieldSchema['x-component'] === 'CollectionField' && (
<SchemaSettings.SelectItem
key="title-field"
title={t('Title field')}

View File

@ -1,5 +1,4 @@
import { Field } from '@formily/core';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { observer, RecursionField, useFieldSchema } from '@formily/react';
import { toArr } from '@formily/shared';
import React, { Fragment, useRef, useState } from 'react';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
@ -97,7 +96,7 @@ export const ReadPrettyRecordPicker: React.FC = observer((props: any) => {
return collectionField ? (
<div>
<BlockAssociationContext.Provider value={`${collectionField.collectionName}.${collectionField.name}`}>
<CollectionProvider name={collectionField.target}>
<CollectionProvider name={collectionField.target ?? collectionField.targetCollection}>
<EllipsisWithTooltip ellipsis={ellipsis} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>

View File

@ -31,7 +31,7 @@ export const TableColumnDesigner = (props) => {
const { dn } = useDesignable();
const fieldNames =
fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames'];
const options = useLabelFields(collectionField?.target);
const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection);
const intefaceCfg = getInterface(collectionField?.interface);
return (

View File

@ -1,10 +1,17 @@
import { MenuOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { ArrayField, Field } from '@formily/core';
import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import {
observer,
RecursionField,
Schema,
useField,
useFieldSchema,
SchemaExpressionScopeContext,
} from '@formily/react';
import { Table, TableColumnProps } from 'antd';
import { default as classNames, default as cls } from 'classnames';
import React, { useState } from 'react';
import React, { useState, useContext } from 'react';
import ReactDragListView from 'react-drag-listview';
import { DndContext } from '../..';
import { RecordIndexProvider, RecordProvider, useRequest, useSchemaInitializer } from '../../../';
@ -13,6 +20,11 @@ const isColumnComponent = (schema: Schema) => {
return schema['x-component']?.endsWith('.Column') > -1;
};
const useScope = (key: any) => {
const scope = useContext(SchemaExpressionScopeContext);
return scope[key] !== false;
};
const useTableColumns = () => {
const start = Date.now();
const field = useField<ArrayField>();
@ -20,9 +32,10 @@ const useTableColumns = () => {
const { exists, render } = useSchemaInitializer(schema['x-initializer']);
const columns = schema
.reduceProperties((buf, s) => {
if (isColumnComponent(s)) {
if (isColumnComponent(s) && useScope(s['x-visible'])) {
return buf.concat([s]);
}
return buf
}, [])
.map((s: Schema) => {
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import {
Sequelize,
SyncOptions,
Transactionable,
Utils,
Utils
} from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -58,8 +58,9 @@ import {
SyncListener,
UpdateListener,
UpdateWithAssociationsListener,
ValidateListener,
ValidateListener
} from './types';
import { snakeCase } from './utils';
import DatabaseUtils from './database-utils';
@ -78,6 +79,7 @@ export interface IDatabaseOptions extends Options {
tablePrefix?: string;
migrator?: any;
usingBigIntForId?: boolean;
underscored?: boolean;
}
export type DatabaseOptions = IDatabaseOptions;
@ -170,7 +172,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
constructor(options: DatabaseOptions) {
super();
this.version = new DatabaseVersion(this);
const opts = {
@ -253,6 +254,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
name: 'migrations',
autoGenId: false,
timestamps: false,
namespace: 'core',
duplicator: 'required',
fields: [{ type: 'string', name: 'name' }],
});
@ -266,6 +269,12 @@ export class Database extends EventEmitter implements AsyncEmitter {
}
initListener() {
this.on('beforeDefine', (model, options) => {
if (this.options.underscored) {
options.underscored = true;
}
});
this.on('afterCreate', async (instance) => {
instance?.toChangedWithAssociations?.();
});
@ -295,6 +304,27 @@ export class Database extends EventEmitter implements AsyncEmitter {
}
}
});
this.on('beforeDefineCollection', (options) => {
if (options.underscored) {
if (lodash.get(options, 'sortable.scopeKey')) {
options.sortable.scopeKey = snakeCase(options.sortable.scopeKey);
}
if (lodash.get(options, 'indexes')) {
// change index fields to snake case
options.indexes = options.indexes.map((index) => {
if (index.fields) {
index.fields = index.fields.map((field) => {
return snakeCase(field);
});
}
return index;
});
}
}
});
}
addMigration(item: MigrationItem) {
@ -329,6 +359,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
collection<Attributes = any, CreateAttributes = Attributes>(
options: CollectionOptions,
): Collection<Attributes, CreateAttributes> {
if (this.options.underscored) {
options.underscored = true;
}
this.emit('beforeDefineCollection', options);
const hasValidInheritsOptions = (() => {
@ -478,6 +512,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
throw Error(`unsupported field type ${type}`);
}
if (options.field && this.options.underscored) {
options.field = snakeCase(options.field);
}
return new Field(options, context);
}
@ -526,18 +564,24 @@ export class Database extends EventEmitter implements AsyncEmitter {
await this.sequelize.getQueryInterface().dropAllTables(others);
}
async collectionExistsInDb(name, options?: Transactionable) {
async collectionExistsInDb(name: string, options?: Transactionable) {
const collection = this.getCollection(name);
if (!collection) {
return false;
}
const tables = await this.sequelize.getQueryInterface().showAllTables({
transaction: options?.transaction,
});
return !!tables.find((table) => table === `${this.getTablePrefix()}${name}`);
return tables.includes(this.getCollection(name).model.tableName);
}
public isSqliteMemory() {
return this.sequelize.getDialect() === 'sqlite' && lodash.get(this.options, 'storage') == ':memory:';
}
async auth(options: QueryOptions & { retry?: number } = {}) {
async auth(options: Omit<QueryOptions, 'retry'> & { retry?: number | Pick<QueryOptions, 'retry'> } = {}) {
const { retry = 10, ...others } = options;
const delay = (ms) => new Promise((yea) => setTimeout(yea, ms));
let count = 1;
@ -615,6 +659,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
}
extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions) {
collectionOptions = lodash.cloneDeep(collectionOptions);
const collectionName = collectionOptions.name;
const existCollection = this.getCollection(collectionName);
if (existCollection) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -129,7 +129,9 @@ WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schem
if (options.alter) {
const columns = await queryInterface.describeTable(tableName, options);
for (const columnName in childAttributes) {
for (const attribute in childAttributes) {
const columnName = childAttributes[attribute].field;
if (!columns[columnName]) {
await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options);
}

View File

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

View File

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

View File

@ -101,6 +101,8 @@ export class ApplicationVersion {
if (!app.db.hasCollection('applicationVersion')) {
app.db.collection({
name: 'applicationVersion',
namespace: 'core',
duplicator: 'required',
timestamps: false,
fields: [{ name: 'value', type: 'string' }],
});

View File

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

View File

@ -1,5 +1,7 @@
export default {
name: 'applicationPlugins',
namespace: 'core',
duplicator: 'required',
repository: 'PluginManagerRepository',
fields: [
{ type: 'string', name: 'name', unique: true },

View File

@ -0,0 +1,11 @@
# acl
English | [中文](./README.zh-CN.md)
基于角色的权限控制插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,11 @@
# acl
[English](./README.md) | 中文
基于角色的权限控制插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
# audit-logs
English | [中文](./README.zh-CN.md)
审计日志插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,11 @@
# audit-logs
[English](./README.md) | 中文
审计日志插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

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

View File

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

View File

@ -0,0 +1,11 @@
# china-region
English | [中文](./README.zh-CN.md)
中国行政区划插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,11 @@
# china-region
[English](./README.md) | 中文
中国行政区划插件。
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

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

View File

@ -0,0 +1,9 @@
# client
English | [中文](./README.zh-CN.md)
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,9 @@
# client
[English](./README.md) | 中文
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,9 @@
# collection-manager
English | [中文](./README.zh-CN.md)
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,9 @@
# collection-manager
[English](./README.md) | 中文
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

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

View File

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

View File

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

View File

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

View File

@ -100,6 +100,8 @@ pgOnly()('Inherited Collection', () => {
},
});
const collectionB = app.db.getCollection('b');
const res = await agent.resource('b').create({
values: {
af: 'a1',

View File

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

View File

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

View File

@ -0,0 +1,34 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
duplicator: 'required',
name: 'collectionCategories',
autoGenId: true,
sortable: true,
fields: [
{
type: 'string',
name: 'name',
},
// {
// type: 'integer',
// name: 'sort',
// defaultValue: 0,
// },
{
type: 'string',
name: 'color',
defaultValue: 'default',
},
{
type: 'belongsToMany',
name: 'collections',
target: 'collections',
foreignKey: 'categoryId',
otherKey: 'collectionName',
targetKey: 'name',
through: 'collectionCategory',
},
],
} as CollectionOptions;

View File

@ -1,6 +1,8 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
duplicator: 'required',
name: 'collections',
title: '数据表配置',
sortable: 'sort',
@ -50,5 +52,15 @@ export default {
foreignKey: 'collectionName',
sortBy: 'sort',
},
{
type: 'belongsToMany',
name: 'category',
target: 'collectionCategories',
sourceKey: 'name',
foreignKey: 'collectionName',
otherKey: 'categoryId',
targetKey: 'id',
through: 'collectionCategory',
},
],
} as CollectionOptions;

View File

@ -1,6 +1,8 @@
import { CollectionOptions } from '@nocobase/database';
export default {
namespace: 'collection-manager',
duplicator: 'required',
name: 'fields',
autoGenId: false,
model: 'FieldModel',

View File

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

View File

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

View File

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

View File

@ -0,0 +1,118 @@
# Duplicator
English | [中文](./README.zh-CN.md)
NocoBase 应用的备份与还原插件,可用于应用的复制、迁移、升级等场景。
## 安装激活
内置插件无需手动安装激活。
## 使用方法
Duplicator 插件提供了 `dump``restore` 命令,分别用于备份和还原应用数据,可用于单应用的备份和还原,也可以跨应用。如果跨应用还原数据,请保证目标应用 NocoBase 版本与源应用一致,相对应插件也已下载本地。
**⚠️ 如果使用了继承PostgreSQL、视图、触发器等不兼容的特性跨数据库还原备份数据可能失败。**
### 备份数据
```bash
yarn nocobase dump
```
选择需要备份的插件表结构及其数据
```bash
? Select the plugin collections to be dumped (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
== Required ==
- migration (core) (Disabled)
- collections (collection-manager) (Disabled)
- uiSchemas (ui-schema-storage) (Disabled)
- uiRoutes (ui-routes-storage) (Disabled)
- acl (acl) (Disabled)
- workflowConfig (workflow) (Disabled)
- snapshot-field (snapshot-field) (Disabled)
- sequences (sequence-field) (Disabled)
== Optional ==
❯◉ executionLogs (workflow)
◉ users (users)
◉ storageSetting (file-manager)
◉ attachmentRecords (file-manager)
◉ systemSettings (system-settings)
◉ verificationProviders (verification)
◉ verificationData (verification)
◉ oidcProviders (oidc)
◉ samlProviders (saml)
◉ mapConfiguration (map)
(Move up and down to reveal more choices)
```
选择需要备份的其他数据表的记录
```bash
? Select the collection records to be dumped (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ Test1
❯◉ Test2
❯◉ Test3
```
数据备份成功之后,备份文件位于 `storage/duplicator` 目录下:
```bash
dumped to /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump
dumped file size: 20.8 kB
```
### 还原数据
```bash
yarn nocobase restore /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump
```
导入前请先备份数据
```bash
? Danger !!! This action will overwrite your current data, please make sure you have a backup❗ (y/N)
```
选择需要还原的插件表结构及其数据
```bash
? Select the plugin collections to be restored (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
== Required ==
- migration (core) (Disabled)
- collections (collection-manager) (Disabled)
- uiSchemas (ui-schema-storage) (Disabled)
- uiRoutes (ui-routes-storage) (Disabled)
- acl (acl) (Disabled)
- workflowConfig (workflow) (Disabled)
- sequences (sequence-field) (Disabled)
== Optional ==
❯◯ executionLogs (workflow)
◯ users (users)
◯ storageSetting (file-manager)
◯ attachmentRecords (file-manager)
◯ systemSettings (system-settings)
◯ verificationProviders (verification)
◯ verificationData (verification)
◯ auditLogs (audit-logs)
◯ iframe html storage (iframe-block)
```
选择需要还原的其他数据表的记录
```bash
? Select the collection records to be restored (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ Test1
❯◉ Test2
❯◉ Test3
```
成功之后,重启应用
```bash
# for development
yarn dev
# for production
yarn start
```

View File

@ -0,0 +1,118 @@
# Duplicator
[English](./README.md) | 中文
NocoBase 应用的备份与还原插件,可用于应用的复制、迁移、升级等场景。
## 安装激活
内置插件无需手动安装激活。
## 使用方法
Duplicator 插件提供了 `dump``restore` 命令,分别用于备份和还原应用数据,可用于单应用的备份和还原,也可以跨应用。如果跨应用还原数据,请保证目标应用 NocoBase 版本与源应用一致,相对应插件也已下载本地。
**⚠️ 如果使用了继承PostgreSQL、视图、触发器等不兼容的特性跨数据库还原备份数据可能失败。**
### 备份数据
```bash
yarn nocobase dump
```
选择需要备份的插件表结构及其数据
```bash
? Select the plugin collections to be dumped (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
== Required ==
- migration (core) (Disabled)
- collections (collection-manager) (Disabled)
- uiSchemas (ui-schema-storage) (Disabled)
- uiRoutes (ui-routes-storage) (Disabled)
- acl (acl) (Disabled)
- workflowConfig (workflow) (Disabled)
- snapshot-field (snapshot-field) (Disabled)
- sequences (sequence-field) (Disabled)
== Optional ==
❯◉ executionLogs (workflow)
◉ users (users)
◉ storageSetting (file-manager)
◉ attachmentRecords (file-manager)
◉ systemSettings (system-settings)
◉ verificationProviders (verification)
◉ verificationData (verification)
◉ oidcProviders (oidc)
◉ samlProviders (saml)
◉ mapConfiguration (map)
(Move up and down to reveal more choices)
```
选择需要备份的其他数据表的记录
```bash
? Select the collection records to be dumped (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ Test1
❯◉ Test2
❯◉ Test3
```
数据备份成功之后,备份文件位于 `storage/duplicator` 目录下:
```bash
dumped to /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump
dumped file size: 20.8 kB
```
### 还原数据
```bash
yarn nocobase restore /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump
```
导入前请先备份数据
```bash
? Danger !!! This action will overwrite your current data, please make sure you have a backup❗ (y/N)
```
选择需要还原的插件表结构及其数据
```bash
? Select the plugin collections to be restored (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
== Required ==
- migration (core) (Disabled)
- collections (collection-manager) (Disabled)
- uiSchemas (ui-schema-storage) (Disabled)
- uiRoutes (ui-routes-storage) (Disabled)
- acl (acl) (Disabled)
- workflowConfig (workflow) (Disabled)
- sequences (sequence-field) (Disabled)
== Optional ==
❯◯ executionLogs (workflow)
◯ users (users)
◯ storageSetting (file-manager)
◯ attachmentRecords (file-manager)
◯ systemSettings (system-settings)
◯ verificationProviders (verification)
◯ verificationData (verification)
◯ auditLogs (audit-logs)
◯ iframe html storage (iframe-block)
```
选择需要还原的其他数据表的记录
```bash
? Select the collection records to be restored (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ Test1
❯◉ Test2
❯◉ Test3
```
成功之后,重启应用
```bash
# for development
yarn dev
# for production
yarn start
```

View File

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

View File

@ -1,12 +1,12 @@
import { Application } from '@nocobase/server';
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 lodash from 'lodash';
import fsPromises from 'fs/promises';
import crypto from 'crypto';
import inquirer from 'inquirer';
import EventEmitter from 'events';
import { applyMixins, AsyncEmitter, requireModule } from '@nocobase/utils';
abstract class AppMigrator extends EventEmitter {
protected workDir: string;
@ -64,11 +64,11 @@ abstract class AppMigrator extends EventEmitter {
return {
type: 'checkbox',
name: 'collectionGroups',
message: `选择需要${this.direction}的插件数据`,
message: `Select the plugin collections to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 20,
choices: [
new inquirer.Separator('== 必选数据 =='),
new inquirer.Separator('== Required =='),
...requiredGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
@ -76,7 +76,7 @@ abstract class AppMigrator extends EventEmitter {
disabled: true,
})),
new inquirer.Separator('== 可选数据 =='),
new inquirer.Separator('== Optional =='),
...optionalGroups.map((collectionGroup) => ({
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
@ -95,7 +95,7 @@ abstract class AppMigrator extends EventEmitter {
return {
type: 'checkbox',
name: 'userCollections',
message: `选择需要${this.direction}的Collection数据`,
message: `Select the collection records to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
loop: false,
pageSize: 30,
choices: collections.map((collection) => {

View File

@ -1,4 +1,3 @@
import { Application } from '@nocobase/server';
import lodash from 'lodash';
import { Restorer } from './restorer';
@ -70,7 +69,7 @@ CollectionGroupManager.registerCollectionGroup({
CollectionGroupManager.registerCollectionGroup({
pluginName: 'collection-manager',
function: 'collections',
collections: ['collections', 'fields'],
collections: ['collections', 'fields', 'collectionCategories', 'collectionCategory'],
dumpable: 'required',
});
@ -252,5 +251,5 @@ CollectionGroupManager.registerCollectionGroup({
pluginName: 'iframe-block',
function: 'iframe html storage',
collections: ['iframeHtml'],
dumpable: 'optional',
dumpable: 'required',
});

View File

@ -1,4 +1,5 @@
import decompress from 'decompress';
import fs from 'fs';
import fsPromises from 'fs/promises';
import inquirer from 'inquirer';
import path from 'path';
@ -6,15 +7,22 @@ import { AppMigrator } from './app-migrator';
import { CollectionGroupManager } from './collection-group-manager';
import { FieldValueWriter } from './field-value-writer';
import { readLines, sqlAdapter } from './utils';
import fs from 'fs';
export class Restorer extends AppMigrator {
direction = 'restore' as const;
importedCollections: string[] = [];
async restore(backupFilePath: string) {
let filePath: string;
if (path.isAbsolute(backupFilePath)) {
filePath = backupFilePath;
} else if (path.basename(backupFilePath) === backupFilePath) {
const dirname = path.resolve(process.cwd(), 'storage', 'duplicator');
const filePath = path.isAbsolute(backupFilePath) ? backupFilePath : path.resolve(dirname, backupFilePath);
filePath = path.resolve(dirname, backupFilePath);
} else {
filePath = path.resolve(process.cwd(), backupFilePath);
}
const results = await inquirer.prompt([
{

View File

@ -11,4 +11,48 @@ export default class Duplicator extends Plugin {
addDumpCommand(this.app);
addRestoreCommand(this.app);
}
async load() {
this.app.resourcer.define({
name: 'duplicator',
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();
},
},
});
this.app.acl.allow('duplicator', 'getDict');
}
}

View File

@ -0,0 +1,9 @@
# error-handler
English | [中文](./README.zh-CN.md)
## 安装激活
内置插件无需手动安装激活。
## 使用方法

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