feat: collection inheritance (#1069)

* chore: test

* chore: inherited-collection class

* feat: collection inherit

* feat: collection inherit

* feat: inhertis sync runner

* test: get parents fields

* feat: collection inherit style promote

* feat: sync

* feat: sync alter table

* feat: pgOnly Test

* fix: child collection create api

* feat: replace parent field

* chore: reload parent fields

* test: reload collection test

* feat: details are displayed according to conditions

* fix: typo

* feat: inheritance map class

* chore: is parent node

* feat: display where child row created from

* fix: find with appends

* feat: add parent collection fields

* fix: create table

* feat: load fields for all children

* refactor: sync fields from parent

* test: has one field inhertis

* feat: replace child association target

* feat: should not replace child field when parent field update

* test: should update inherit field when parent field update

* feat: only the blocks directly inherited from the current data are displayed

* fix: inherit from multiple collections

* feat: only the blocks directly inherited from the current data are displayed

* fix: test

* feat: parent collection expend

* fix: test

* test: belongsToMany inherits

* test: belongsToMany inherits

* feat: block display

* feat: collection inherite

* feat: collection inherite

* feat: multiple inherits

* fix: sync runner

* feat: collection inherite

* feat: collecton inherits

* feat: cannot be modified after inheritance and saving

* feat: collection inherit for graph

* feat: collection inherits

* fix: drop inhertied field

* fix: should throw error when type conflit

* feat: output inherited fields

* feat: bulk update collection fields

* feat: collection fields

* feat: collection fields

* test: create relation with child table

* fix: test

* fix: test

* fix: test

* feat: style impove

* test: should not replace field with difference type

* feat: add text

* fix: throw error when replace field with difference type

* feat: overriding

* feat: kan bankanban group fields

* feat: calendar block fields

* feat: kan bankanban group fields

* fix: test

* feat: relationship fields

* feat: should delete child's field when parent field deleted

* feat: foreign key filter

* fix: build error & multiple inherit destory field

* fix: test

* chore: disable error

* feat: no recursive update associations (#1091)

* feat: update associations

* fix(collection-manager): should update uiSchema

* chore: flip if

* feat: mutile inherits

* feat: db dialect

* feat: inherits show by database

* chore: git hash into docker image

* fix: js gzip

* fix: dockerfile

* chore: error message

* feat: overriding

* feat: overriding

* feat: overriding

* feat: local

* feat: filter fields by interface

* fix: database logging env

* test: replace hasOne target

* feat: add view

* feat: local

* chore: enable error

* fix: update docs

Co-authored-by: katherinehhh <katherine_15995@163.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-11-16 12:53:58 +08:00 committed by GitHub
parent 688387413d
commit e991b2965a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 3733 additions and 928 deletions

View File

@ -22,19 +22,15 @@ jobs:
ports:
- 4873:4873
steps:
-
name: Checkout
- name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver-opts: network=host
-
name: Docker meta
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
@ -45,20 +41,19 @@ jobs:
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
-
name: Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: ${{ secrets.ALI_DOCKER_REGISTRY }}
username: ${{ secrets.ALI_DOCKER_USERNAME }}
password: ${{ secrets.ALI_DOCKER_PASSWORD }}
-
name: Build and push
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
file: Dockerfile
build-args: |
VERDACCIO_URL=http://localhost:4873/
COMMIT_HASH=${GITHUB_SHA}
push: true
tags: ${{ secrets.ALI_DOCKER_REGISTRY }}/${{ steps.meta.outputs.tags }}

3
.vscode/launch.json vendored
View File

@ -1,7 +1,4 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{

View File

@ -1,5 +1,6 @@
FROM node:16 as builder
ARG VERDACCIO_URL=http://host.docker.internal:10104/
ARG COMIT_HASH
RUN apt-get update && apt-get install -y jq
WORKDIR /tmp
@ -43,6 +44,8 @@ COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz
WORKDIR /app/nocobase
RUN mkdir -p /app/nocobase/storage/uploads/ && echo "$COMIT_HASH" >> /app/nocobase/storage/uploads/COMIT_HASH
COPY ./docker/nocobase/docker-entrypoint.sh /app/
CMD ["/app/docker-entrypoint.sh"]

View File

@ -9,7 +9,7 @@ server {
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/javascript application/xml;
location /storage/uploads/ {
alias /app/nocobase/storage/uploads/;

View File

@ -0,0 +1,74 @@
# v0.8.1: 数据表继承
数据表继承基于 [PostgreSQL 的 INHERITS 语法](https://www.postgresql.org/docs/current/tutorial-inheritance.html) 实现,仅限于 PostgreSQL 数据库安装的 NocoBase 时才会提供。
## 示例
我们从一个例子开始,假设要做一个教学系统,有三类用户:学生、家长和老师。
如果没有继承,要分别为三类用户建表:
- 学生:姓名、年龄、性别、身份证
- 家长:姓名、年龄、性别、职业、学历
- 老师:姓名、年龄、性别、教龄、已婚
有了数据表继承之后,共同的信息就可以提炼出来:
- 用户:姓名、年龄、性别
- 学生:身份证
- 家长:职业、学历
- 老师:教龄、已婚
ER 图如下:
<img src="./inherits/er.svg" style="max-width: 700px;" />
注:子表 ID 和父表 ID 共享序列
## 配置数据表继承
Inherits 字段选择需要继承的数据表
<img src="./inherits/inherit.jpg" />
通过代码配置如下:
```ts
db.collection({
name: 'users',
});
db.collection({
name: 'students',
inherits: 'users',
});
```
注意:
- 继承的表并不能随意选择,主键必须是唯一序列,比如 uuid 或者所有继承线路上的表的 id 自增序列都用同一个
- Inherits 参数不能被编辑
- 如果有继承关系,被继承的父表不能被删除
## 数据表字段列表
字段列表里同步显示继承的父表字段父表字段不可以修改但可以重写Override
<img src="./inherits/inherit-fields.jpg" />
重写父表字段的注意事项:
- 子表字段标识与父表字段一样时为重写
- 重写字段的类型必须保持一致
- 关系字段除了 target collection 以外的其他参数需要保持一致
## 父表的子表区块
在父表区块里可以配置子表的区块
<img src="./inherits/inherited-blocks.jpg" />
## 新增继承的父表字段的配置
当有继承的父表时,配置字段时,会提供从父表继承的字段的配置
<img src="./inherits/configure-fields.jpg" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -1,7 +1,7 @@
import { IDatabaseOptions } from '@nocobase/database';
export default {
logging: process.env.DB_LOGGING === 'on' ? console.log : false,
logging: process.env.DB_LOGGING == 'on' ? customLogger : false,
dialect: process.env.DB_DIALECT as any,
storage: process.env.DB_STORAGE,
username: process.env.DB_USER,
@ -12,3 +12,8 @@ export default {
timezone: process.env.DB_TIMEZONE,
tablePrefix: process.env.DB_TABLE_PREFIX,
} as IDatabaseOptions;
function customLogger(queryString, queryObject) {
console.log(queryString); // outputs a string
console.log(queryObject.bind); // outputs an array
}

View File

@ -1,9 +1,11 @@
import { createForm } from '@formily/core';
import { useField } from '@formily/react';
import { useField, useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { RecordProvider } from '../record-provider';
import { RecordProvider, useRecord } from '../record-provider';
import { BlockProvider, useBlockRequestContext } from './BlockProvider';
import { useDesignable } from '../schema-component';
import { useCollectionManager } from '../collection-manager';
export const FormBlockContext = createContext<any>({});
@ -46,10 +48,19 @@ const InternalFormBlockProvider = (props) => {
};
export const FormBlockProvider = (props) => {
const record = useRecord();
const { __tableName } = record;
const { getParentCollections } = useCollectionManager();
const inheritCollections = getParentCollections(__tableName);
const { designable } = useDesignable();
const flag =
!designable && __tableName && !inheritCollections.includes(props.collection) && __tableName !== props.collection;
return (
!flag && (
<BlockProvider {...props}>
<InternalFormBlockProvider {...props} />
</BlockProvider>
)
);
};

View File

@ -7,7 +7,17 @@ import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager';
import { ActionContext, SchemaComponent } from '../schema-component';
import { AddCollectionField, AddFieldAction, ConfigurationTable, EditFieldAction,EditCollectionField } from './Configuration';
import {
AddCollectionField,
AddFieldAction,
ConfigurationTable,
EditFieldAction,
EditCollectionField,
OverridingFieldAction,
OverridingCollectionField,
ViewCollectionField,
ViewFieldAction,
} from './Configuration';
const schema: ISchema = {
type: 'object',
@ -37,7 +47,20 @@ const schema2: ISchema = {
export const CollectionManagerPane = () => {
return (
<Card bordered={false}>
<SchemaComponent schema={schema2} components={{ ConfigurationTable, AddFieldAction, AddCollectionField, EditFieldAction,EditCollectionField }} />
<SchemaComponent
schema={schema2}
components={{
ConfigurationTable,
AddFieldAction,
AddCollectionField,
EditFieldAction,
EditCollectionField,
OverridingCollectionField,
OverridingFieldAction,
ViewCollectionField,
ViewFieldAction,
}}
/>
</Card>
);
};
@ -68,7 +91,16 @@ export const CollectionManagerShortcut2 = () => {
setVisible(true);
}}
/>
<SchemaComponent schema={schema} components={{ ConfigurationTable, AddFieldAction, EditFieldAction }} />
<SchemaComponent
schema={schema}
components={{
ConfigurationTable,
AddFieldAction,
EditFieldAction,
OverridingFieldAction,
ViewFieldAction,
}}
/>
</ActionContext.Provider>
);
};

View File

@ -3,52 +3,24 @@ import { ArrayField, Field } from '@formily/core';
import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { Table, TableColumnProps } from 'antd';
import { default as classNames } from 'classnames';
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { RecordIndexProvider, RecordProvider, useCollectionManager, useRequest, useSchemaInitializer } from '../..';
import { findIndex } from 'lodash';
import {
RecordIndexProvider,
RecordProvider,
useCollectionManager,
useRequest,
useSchemaInitializer,
useRecord,
useCompile,
} from '../..';
import { overridingSchema } from '../Configuration/schemas/collectionFields';
const isColumnComponent = (schema: Schema) => {
return schema['x-component']?.endsWith('.Column') > -1;
};
const useTableColumns = () => {
const field = useField<ArrayField>();
const schema = useFieldSchema();
const { exists, render } = useSchemaInitializer(schema['x-initializer']);
const columns = schema
.reduceProperties((buf, s) => {
if (isColumnComponent(s)) {
return buf.concat([s]);
}
}, [])
.map((s: Schema) => {
return {
title: <RecursionField name={s.name} schema={s} onlyRenderSelf />,
dataIndex: s.name,
key: s.name,
render: (v, record) => {
const index = field.value?.indexOf(record);
// console.log((Date.now() - start) / 1000);
return (
<RecordIndexProvider index={index}>
<RecordProvider record={record}>
<RecursionField schema={s} name={index} onlyRenderProperties />
</RecordProvider>
</RecordIndexProvider>
);
},
} as TableColumnProps<any>;
});
if (!exists) {
return columns;
}
return columns.concat({
title: render(),
dataIndex: 'TABLE_COLUMN_INITIALIZER',
key: 'TABLE_COLUMN_INITIALIZER',
});
};
export const components = {
body: {
row: (props) => {
@ -93,7 +65,6 @@ const groupColumns = [
];
type CategorizeKey = 'primaryAndForeignKey' | 'relation' | 'systemInfo' | 'basic';
const sortKeyArr: Array<CategorizeKey> = ['primaryAndForeignKey', 'relation', 'basic', 'systemInfo'];
const CategorizeKeyNameMap = new Map<CategorizeKey, string>([
['primaryAndForeignKey', 'PK & FK fields'],
['relation', 'Association fields'],
@ -108,10 +79,13 @@ interface CategorizeDataItem {
}
export const CollectionFieldsTableArray: React.FC<any> = observer((props) => {
const sortKeyArr: Array<CategorizeKey> = ['primaryAndForeignKey', 'relation', 'basic', 'systemInfo'];
const field = useField<ArrayField>();
const columns = useTableColumns();
const { name } = useRecord();
const { t } = useTranslation();
const { getInterface } = useCollectionManager();
const compile = useCompile();
const { getInterface, getParentCollections, getCollection, getCurrentCollectionFields, getInheritedFields } =
useCollectionManager();
const {
showIndex = true,
useSelectedRowKeys = useDef,
@ -120,12 +94,14 @@ export const CollectionFieldsTableArray: React.FC<any> = observer((props) => {
...others
} = props;
const [selectedRowKeys, setSelectedRowKeys] = useSelectedRowKeys();
const [categorizeData, setCategorizeData] = useState<Array<CategorizeDataItem>>([]);
const [expandedKeys, setExpendedKeys] = useState(selectedRowKeys);
const inherits = getParentCollections(name);
const currentFields = getCurrentCollectionFields(name);
useDataSource({
onSuccess(data) {
field.value = data?.data || [];
// categorize field
const tmpData: Array<CategorizeDataItem> = [];
const categorizeMap = new Map<CategorizeKey, any>();
const addCategorizeVal = (categorizeKey: CategorizeKey, val) => {
let fieldArr = categorizeMap.get(categorizeKey);
@ -151,21 +127,103 @@ export const CollectionFieldsTableArray: React.FC<any> = observer((props) => {
addCategorizeVal('basic', item);
}
});
const tmpData: Array<CategorizeDataItem> = [];
if (inherits) {
inherits.forEach((v) => {
sortKeyArr.push(v);
const parentCollection = getCollection(v);
parentCollection.fields.map((k) => {
if (k.interface) {
addCategorizeVal(v, new Proxy(k, {}));
field.value.push(new Proxy(k, {}));
}
});
});
}
sortKeyArr.forEach((key) => {
if (categorizeMap.get(key)?.length > 0) {
const parentCollection = getCollection(key);
tmpData.push({
key,
name: t(CategorizeKeyNameMap.get(key)),
name:
t(CategorizeKeyNameMap.get(key)) ||
t(`Parent collection fields`) + `(${compile(parentCollection.title)})`,
data: categorizeMap.get(key),
});
}
});
setExpendedKeys(sortKeyArr);
setCategorizeData(tmpData);
},
});
const useTableColumns = () => {
const schema = useFieldSchema();
const { exists, render } = useSchemaInitializer(schema['x-initializer']);
const columns = schema
.reduceProperties((buf, s) => {
if (isColumnComponent(s)) {
return buf.concat([s]);
}
}, [])
.map((s: Schema) => {
return {
title: <RecursionField name={s.name} schema={s} onlyRenderSelf />,
dataIndex: s.name,
key: s.name,
render: (v, record) => {
const index = findIndex(field.value, record);
return (
<RecordIndexProvider index={index}>
<RecordProvider record={record}>
<RecursionField schema={s} name={index} onlyRenderProperties />
</RecordProvider>
</RecordIndexProvider>
);
},
} as TableColumnProps<any>;
});
if (!exists) {
return columns;
}
return columns.concat({
title: render(),
dataIndex: 'TABLE_COLUMN_INITIALIZER',
key: 'TABLE_COLUMN_INITIALIZER',
});
};
const getIsOverriding = (record) => {
const flag = currentFields.find((v) => {
return v.name === record.name;
});
return !flag;
};
const expandedRowRender = (record: CategorizeDataItem, index, indent, expanded) => {
const columns = useTableColumns();
if (inherits.includes(record.key)) {
columns.pop();
columns.push({
title: <RecursionField name={'column4'} schema={overridingSchema as Schema} onlyRenderSelf />,
dataIndex: 'column4',
key: 'column4',
render: (v, record) => {
const index = findIndex(field.value, record);
const flag = getIsOverriding(record);
//@ts-ignore
overridingSchema.properties.actions.properties.overriding['x-visible'] = flag;
return (
<RecordIndexProvider index={index}>
<RecordProvider record={record}>
<RecursionField schema={overridingSchema as Schema} name={index} onlyRenderProperties />
</RecordProvider>
</RecordIndexProvider>
);
},
});
}
const restProps = {
rowSelection: props.rowSelection
rowSelection:
props.rowSelection && !inherits.includes(record.key)
? {
type: 'checkbox',
selectedRowKeys,
@ -176,12 +234,6 @@ export const CollectionFieldsTableArray: React.FC<any> = observer((props) => {
}
: undefined,
};
const defaultRowKey = (record: any) => {
return field.value?.indexOf?.(record);
};
const expandedRowRender = (record: CategorizeDataItem, index, indent, expanded) => {
return (
<Table
{...others}
@ -211,7 +263,18 @@ export const CollectionFieldsTableArray: React.FC<any> = observer((props) => {
pagination={false}
expandable={{
expandedRowRender,
defaultExpandedRowKeys: sortKeyArr,
expandedRowKeys: expandedKeys,
}}
onExpand={(expanded, record) => {
let keys = [];
if (expanded) {
keys = expandedKeys.concat([record.key]);
} else {
keys = expandedKeys.filter((v) => {
return v !== record.key;
});
}
setExpendedKeys(keys);
}}
/>
</div>

View File

@ -1,17 +1,19 @@
import { useForm } from '@formily/react';
import { action } from '@formily/reactive';
import { uid } from '@formily/shared';
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { CollectionFieldsTable } from '.';
import { useRequest } from '../../api-client';
import { useCurrentDatabase } from '../../database';
import { useRecord } from '../../record-provider';
import { SchemaComponent, SchemaComponentContext, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction, useUpdateCollectionActionAndRefreshCM } from '../action-hooks';
import { useCollectionManager } from '../hooks/useCollectionManager';
import { DataSourceContext } from '../sub-table';
import { AddSubFieldAction } from './AddSubFieldAction';
import { FieldSummary } from './components/FieldSummary';
import { EditSubFieldAction } from './EditSubFieldAction';
import { collectionSchema } from './schemas/collections';
import { CollectionFieldsTable } from ".";
const useAsyncDataSource = (service: any) => (field: any) => {
field.loading = true;
@ -173,9 +175,14 @@ const useNewId = (prefix) => {
export const ConfigurationTable = () => {
const { collections = [] } = useCollectionManager();
const {
data: { database },
} = useCurrentDatabase();
const collectonsRef: any = useRef();
collectonsRef.current = collections;
const compile = useCompile();
const loadCollections = async (field: any) => {
return collections
return collectonsRef.current
?.filter((item) => !(item.autoCreate && item.isThrough))
.map((item: any) => ({
label: compile(item.title),
@ -187,14 +194,15 @@ export const ConfigurationTable = () => {
<div>
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
<SchemaComponent
schema={collectionSchema}
schema={collectionSchema(database?.dialect)}
components={{
AddSubFieldAction,
EditSubFieldAction,
FieldSummary,
CollectionFieldsTable
CollectionFieldsTable,
}}
scope={{
enableInherits: database?.dialect === 'postgres',
useDestroySubField,
useBulkDestroySubField,
useSelectedRowKeys,
@ -203,6 +211,8 @@ export const ConfigurationTable = () => {
loadCollections,
useCurrentFields,
useNewId,
useCancelAction,
useUpdateCollectionActionAndRefreshCM,
}}
/>
</SchemaComponentContext.Provider>

View File

@ -0,0 +1,180 @@
import { ArrayTable } from '@formily/antd';
import { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord, RecordProvider } from '../../record-provider';
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { useCollectionManager } from '../hooks';
import { IField } from '../interfaces/types';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components';
const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => {
if (!schema) {
return;
}
const properties = cloneDeep(schema.properties) as any;
properties.name['x-disabled'] = true;
if (schema.hasDefaultValue === true) {
properties['defaultValue'] = cloneDeep(schema.default.uiSchema);
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem';
}
return {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
return useRequest(
() =>
Promise.resolve({
data: cloneDeep(schema.default),
}),
options,
);
},
},
title: `${compile(record.__parent?.title)} - ${compile('{{ t("Override field") }}')}`,
properties: {
summary: {
type: 'void',
'x-component': 'FieldSummary',
'x-component-props': {
schemaKey: schema.name,
},
},
// @ts-ignore
...properties,
footer: {
type: 'void',
'x-component': 'Action.Drawer.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: '{{ useOverridingCollectionField }}',
},
},
},
},
},
},
},
};
};
const useOverridingCollectionField = () => {
const form = useForm();
const { refreshCM } = useCollectionManager();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
return {
async run() {
await form.submit();
const values = cloneDeep(form.values);
if (values.autoCreateReverseField) {
} else {
delete values.reverseField;
}
delete values.autoCreateReverseField;
const { uiSchema } = values;
delete values.collectionName;
delete values.key;
await resource.create({
values: {
...values,
uiSchema: { title: uiSchema.title, type: uiSchema.type, 'x-component': uiSchema['x-component'] },
},
});
ctx.setVisible(false);
await form.reset();
refresh();
await refreshCM();
},
};
};
export const OverridingCollectionField = (props) => {
const record = useRecord();
return <OverridingFieldAction item={record} {...props} />;
};
export const OverridingFieldAction = (props) => {
const { scope, getContainer, item: record, children } = props;
const { getInterface } = useCollectionManager();
const [visible, setVisible] = useState(false);
const [schema, setSchema] = useState({});
const api = useAPIClient();
const { t } = useTranslation();
const compile = useCompile();
const [data, setData] = useState<any>({});
return (
<RecordProvider record={record}>
<ActionContext.Provider value={{ visible, setVisible }}>
<a
onClick={async () => {
const { data } = await api.resource('collections.fields', record.collectionName).get({
filterByTk: record.name,
appends: ['uiSchema', 'reverseField'],
});
setData(data?.data);
const interfaceConf = getInterface(record.interface);
const defaultValues: any = cloneDeep(data?.data) || {};
if (!defaultValues?.reverseField) {
defaultValues.autoCreateReverseField = false;
defaultValues.reverseField = interfaceConf.default?.reverseField;
set(defaultValues.reverseField, 'name', `f_${uid()}`);
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
}
const schema = getSchema(
{
...interfaceConf,
default: defaultValues,
},
record,
compile,
getContainer,
);
setSchema(schema);
setVisible(true);
}}
>
{children || t('Override')}
</a>
<SchemaComponent
schema={schema}
components={{ ...components, ArrayTable }}
scope={{
getContainer,
useOverridingCollectionField,
useCancelAction,
showReverseFieldConfig: !data?.reverseField,
createOnly: true,
...scope,
}}
/>
</ActionContext.Provider>
</RecordProvider>
);
};

View File

@ -0,0 +1,179 @@
import { ArrayTable } from '@formily/antd';
import { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord, RecordProvider } from '../../record-provider';
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { useCollectionManager } from '../hooks';
import { IField } from '../interfaces/types';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components';
const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => {
if (!schema) {
return;
}
const properties = cloneDeep(schema.properties) as any;
properties.name['x-disabled'] = true;
if (schema.hasDefaultValue === true) {
properties['defaultValue'] = cloneDeep(schema.default.uiSchema);
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem';
}
return {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
return useRequest(
() =>
Promise.resolve({
data: cloneDeep(schema.default),
}),
options,
);
},
},
title: `${compile(record.__parent?.title)} - ${compile('{{ t("View") }}')}`,
properties: {
summary: {
type: 'void',
'x-component': 'FieldSummary',
'x-component-props': {
schemaKey: schema.name,
},
},
// @ts-ignore
...properties,
// footer: {
// type: 'void',
// 'x-component': 'Action.Drawer.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: '{{ useVieInheritedCollectionField }}',
// },
// },
// },
// },
},
},
},
};
};
const useVieInheritedCollectionField = () => {
const form = useForm();
const { refreshCM } = useCollectionManager();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
return {
async run() {
await form.submit();
const values = cloneDeep(form.values);
if (values.autoCreateReverseField) {
} else {
delete values.reverseField;
}
delete values.autoCreateReverseField;
const { uiSchema } = values;
delete values.collectionName;
delete values.key;
await resource.create({
values: {
...values,
uiSchema: { title: uiSchema.title, type: uiSchema.type, 'x-component': uiSchema['x-component'] },
},
});
ctx.setVisible(false);
await form.reset();
refresh();
await refreshCM();
},
};
};
export const ViewCollectionField = (props) => {
const record = useRecord();
return <ViewFieldAction item={record} {...props} />;
};
export const ViewFieldAction = (props) => {
const { scope, getContainer, item: record, children } = props;
const { getInterface } = useCollectionManager();
const [visible, setVisible] = useState(false);
const [schema, setSchema] = useState({});
const api = useAPIClient();
const { t } = useTranslation();
const compile = useCompile();
const [data, setData] = useState<any>({});
return (
<RecordProvider record={record}>
<ActionContext.Provider value={{ visible, setVisible }}>
<a
onClick={async () => {
const { data } = await api.resource('collections.fields', record.collectionName).get({
filterByTk: record.name,
appends: ['uiSchema', 'reverseField'],
});
setData(data?.data);
const interfaceConf = getInterface(record.interface);
const defaultValues: any = cloneDeep(data?.data) || {};
if (!defaultValues?.reverseField) {
defaultValues.autoCreateReverseField = false;
defaultValues.reverseField = interfaceConf.default?.reverseField;
set(defaultValues.reverseField, 'name', `f_${uid()}`);
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
}
const schema = getSchema(
{
...interfaceConf,
default: defaultValues,
},
record,
compile,
getContainer,
);
setSchema(schema);
setVisible(true);
}}
>
{children || t('View')}
</a>
<SchemaComponent
schema={schema}
components={{ ...components, ArrayTable }}
scope={{
getContainer,
useVieInheritedCollectionField,
useCancelAction,
showReverseFieldConfig: !data?.reverseField,
createOnly: !true,
...scope,
}}
/>
</ActionContext.Provider>
</RecordProvider>
);
};

View File

@ -7,6 +7,9 @@ export * from './EditFieldAction';
export * from './interfaces';
export * from './components';
export * from './CollectionFieldsTable';
export * from './schemas/collections'
export * from './OverridingCollectionField'
export * from './ViewInheritedField'
registerValidateFormats({
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,

View File

@ -80,10 +80,6 @@ export const collectionFieldSchema: ISchema = {
},
},
},
// 'x-component': 'CollectionProvider',
// 'x-component-props': {
// collection,
// },
properties: {
summary: {
type: 'void',
@ -206,3 +202,37 @@ export const collectionFieldSchema: ISchema = {
},
},
};
export const overridingSchema: ISchema = {
type: 'void',
title: '{{ t("Actions") }}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
overriding: {
type: 'void',
title: '{{ t("Overriding") }}',
'x-component': 'OverridingCollectionField',
'x-component-props': {
type: 'primary',
},
},
view:{
type: 'void',
title: '{{ t("View") }}',
'x-component': 'ViewCollectionField',
'x-component-props': {
type: 'primary',
},
}
},
},
},
};

View File

@ -7,7 +7,8 @@ const compile = (source) => {
return Schema.compile(source, { t: i18n.t });
};
export const collection: CollectionOptions = {
export const getCollectionOptions = (database): CollectionOptions => {
return {
name: 'collections',
filterTargetKey: 'name',
targetKey: 'name',
@ -44,10 +45,103 @@ export const collection: CollectionOptions = {
targetKey: 'name',
uiSchema: {},
},
database === 'postgres' && {
type: 'hasMany',
name: 'inherits',
interface: 'select',
uiSchema: {
title: '{{ t("Inherits") }}',
type: 'string',
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
},
},
],
};
};
export const collectionSchema: ISchema = {
export const createCollectionProperties = {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-validator': 'uid',
},
inherits: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-visible': '{{ enableInherits }}',
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useCreateActionAndRefreshCM }}',
},
},
},
},
};
export const editCollectionProperties = {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': true,
},
inherits: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': true,
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.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: '{{ useUpdateCollectionActionAndRefreshCM }}',
},
},
},
},
};
export const collectionSchema = (database): ISchema => {
return {
type: 'object',
properties: {
block1: {
@ -55,7 +149,7 @@ export const collectionSchema: ISchema = {
'x-collection': 'collections',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
collection: getCollectionOptions(database),
request: {
resource: 'collections',
action: 'list',
@ -125,38 +219,7 @@ export const collectionSchema: ISchema = {
'x-decorator-props': {
useValues: '{{ useCollectionValues }}',
},
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-validator': 'uid',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useCreateActionAndRefreshCM }}',
},
},
},
},
},
properties: createCollectionProperties,
},
},
},
@ -218,6 +281,9 @@ export const collectionSchema: ISchema = {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-component-props': {
destroyOnClose: true,
},
'x-reactions': (field) => {
const i = field.path.segments[1];
const table = field.form.getValuesIn(`table.${i}`);
@ -247,38 +313,7 @@ export const collectionSchema: ISchema = {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{ t("Edit collection") }}',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': true,
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useUpdateCollectionActionAndRefreshCM }}',
},
},
},
},
},
properties: editCollectionProperties,
},
},
},
@ -304,3 +339,4 @@ export const collectionSchema: ISchema = {
},
},
};
};

View File

@ -1,18 +1,38 @@
import { SchemaKey } from '@formily/react';
import { reduce ,unionBy} from 'lodash';
import { useContext } from 'react';
import { useAPIClient } from '../../api-client';
import { CollectionContext } from '../context';
import { CollectionFieldOptions } from '../types';
import { useCollectionManager } from './useCollectionManager';
export const useCollection = () => {
const collection = useContext(CollectionContext);
const api = useAPIClient();
const resource = api?.resource(collection?.name);
const { getParentCollections, getCurrentCollectionFields } = useCollectionManager();
const currentFields = collection.fields;
const inheritKeys = getParentCollections(collection.name);
const inheritedFields = reduce(
inheritKeys,
(result, value) => {
const arr = result;
return arr.concat(getCurrentCollectionFields(value));
},
[],
);
const totalFields = unionBy(currentFields?.concat(inheritedFields),'name').filter((v)=>{
return !v.isForeignKey
});
return {
...collection,
resource,
getField(name: SchemaKey): CollectionFieldOptions {
return collection?.fields?.find((field) => field.name === name);
const fields = totalFields;
return fields?.find((field) => field.name === name);
},
fields: totalFields,
currentFields,
inheritedFields
};
};

View File

@ -1,9 +1,32 @@
import { clone } from '@formily/shared';
import { useContext } from 'react';
import { reduce, unionBy,uniq } from 'lodash';
import { CollectionManagerContext } from '../context';
import { CollectionFieldOptions } from '../types';
export const useCollectionManager = () => {
const { refreshCM, service, interfaces, collections } = useContext(CollectionManagerContext);
const getInheritedFields = (name) => {
const inheritKeys = getParentCollections(name);
const inheritedFields = reduce(
inheritKeys,
(result, value) => {
const arr = result;
return arr.concat(collections?.find((collection) => collection.name === value)?.fields);
},
[],
);
return inheritedFields;
};
const getCollectionFields = (name: string): CollectionFieldOptions[] => {
const currentFields = collections?.find((collection) => collection.name === name)?.fields;
const inheritedFields = getInheritedFields(name);
const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v:any) => {
return !v.isForeignKey;
});
return totalFields;
};
const getCollectionField = (name: string) => {
const [collectionName, fieldName] = name.split('.');
if (!fieldName) {
@ -13,27 +36,68 @@ export const useCollectionManager = () => {
if (!collection) {
return;
}
return collection?.fields?.find((field) => field.name === fieldName);
return getCollectionFields(collectionName)?.find((field) => field.name === fieldName);
};
const getParentCollections = (name) => {
const parents = [];
const getParents = (name) => {
const collection = collections?.find((collection) => collection.name === name);
if (collection) {
const { inherits } = collection;
if (inherits) {
for (let index = 0; index < inherits.length; index++) {
const collectionKey = inherits[index];
parents.push(collectionKey);
getParents(collectionKey);
}
}
}
return uniq(parents);
};
return getParents(name);
};
const getChildrenCollections = (name) => {
const childrens = [];
const getChildrens = (name) => {
const inheritCollections = collections.filter((v) => {
return v.inherits?.includes(name);
});
inheritCollections.forEach((v) => {
const collectionKey = v.name;
childrens.push(v);
return getChildrens(collectionKey);
});
return childrens;
};
return getChildrens(name);
};
const getCurrentCollectionFields = (name: string) => {
const collection = collections?.find((collection) => collection.name === name);
return collection?.fields || [];
};
return {
service,
interfaces,
collections,
getParentCollections,
getChildrenCollections,
refreshCM: () => refreshCM?.(),
get(name: string) {
return collections?.find((collection) => collection.name === name);
},
getInheritedFields,
getCollectionField,
getCollectionFields,
getCurrentCollectionFields,
getCollection(name: any) {
if (typeof name !== 'string') {
return name;
}
return collections?.find((collection) => collection.name === name);
},
getCollectionFields(name: string) {
const collection = collections?.find((collection) => collection.name === name);
return collection?.fields || [];
},
getCollectionField,
getCollectionJoinField(name: string) {
if (!name) {
return;
@ -58,5 +122,23 @@ export const useCollectionManager = () => {
getInterface(name: string) {
return interfaces[name] ? clone(interfaces[name]) : null;
},
getParentCollectionFields: (parentCollection, currentCollection) => {
const currentFields = collections?.find((collection) => collection.name === currentCollection)?.fields;
const parentFields = collections?.find((collection) => collection.name === parentCollection)?.fields;
const inheritKeys = getParentCollections(currentCollection);
const index = inheritKeys.indexOf(parentCollection);
let filterFields = currentFields;
if (index > 0) {
inheritKeys.splice(index);
inheritKeys.forEach((v) => {
filterFields = filterFields.concat(getCurrentCollectionFields(v));
});
}
return parentFields.filter((v) => {
return !filterFields.find((k) => {
return k.name === v.name;
});
});
},
};
};

View File

@ -21,6 +21,7 @@ export interface CollectionOptions {
targetKey?: string;
sortable?: any;
fields?: FieldOptions[];
inherits?:string[];
}
export interface ICollectionProviderProps {

View File

@ -0,0 +1,24 @@
import React, { createContext, useContext } from 'react';
import { Spin } from 'antd';
import { useRequest } from '../api-client';
export const CurrentDatabaseContext = createContext(null);
export const useCurrentDatabase = () => {
return useContext(CurrentDatabaseContext);
};
export const CurrentDatabaseProvider = (props) => {
const result = useRequest({
url: 'app:getInfo',
});
if (result.loading) {
return <Spin />;
}
return (
<CurrentDatabaseContext.Provider
value={result.data}
>
{props.children}
</CurrentDatabaseContext.Provider>
);
};

View File

@ -0,0 +1 @@
export * from './CurrentDatabaseProvider';

View File

@ -25,4 +25,5 @@ export * from './schema-templates';
export * from './settings-form';
export * from './system-settings';
export * from './user';
export * from './database'

View File

@ -113,6 +113,7 @@ export default {
"Create collection": "Create collection",
"Collection display name": "Collection display name",
"Collection name": "Collection name",
"Inherits":"Inherits",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
"Storage type": "Storage type",
"Edit": "Edit",
@ -120,9 +121,12 @@ export default {
"Configure fields": "Configure fields",
"Configure columns": "Configure columns",
"Edit field": "Edit field",
"Override":"Override",
"Override field":"Override field",
"Configure fields of {{title}}": "Configure fields of {{title}}",
"PK & FK fields": "PK & FK fields",
"Association fields": "Association fields",
"Parent collection fields":"Parent collection fields",
"System fields": "System fields",
"General fields": "General fields",
"Basic": "Basic",

View File

@ -114,6 +114,8 @@ export default {
"Configure fields": "フィールドの設定",
"Configure columns": "カラムの設定",
"Edit field": "フィールドの編集",
"Override":"書き換え",
"Override field":"フィールドの上書き",
"Configure fields of {{title}}": "{{title}}のフィールド設定",
"Basic": "基本タイプ",
"Single line text": "一行テキスト",

View File

@ -117,6 +117,7 @@ export default {
"Create collection": "创建数据表",
"Collection display name": "数据表名称",
"Collection name": "数据表标识",
"Inherits":"继承",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
"Storage type": "存储类型",
"Edit": "编辑",
@ -124,11 +125,14 @@ export default {
"Configure fields": "配置字段",
"Configure columns": "配置字段",
"Edit field": "编辑字段",
"Override":"重写",
"Override field":"重写字段",
"Configure fields of {{title}}": "「{{title}}」的字段配置",
"PK & FK fields": "主外键字段",
"Association fields": "关系字段",
"System fields": "系统字段",
"General fields": "普通字段",
"Parent collection fields":"父表字段",
"Basic": "基本类型",
"Single line text": "单行文本",
"Long text": "多行文本",

View File

@ -7,6 +7,7 @@ import {
ACLRolesCheckProvider,
CurrentUser,
CurrentUserProvider,
CurrentDatabaseProvider,
findByUid,
findMenuItem,
RemoteCollectionManagerProvider,
@ -17,7 +18,7 @@ import {
useDocumentTitle,
useRequest,
useRoute,
useSystemSettings
useSystemSettings,
} from '../../../';
import { useCollectionManager } from '../../../collection-manager';
import { PoweredBy } from '../../../powered-by';
@ -206,6 +207,7 @@ const InternalAdminLayout = (props: any) => {
export const AdminLayout = (props) => {
return (
<CurrentDatabaseProvider>
<CurrentUserProvider>
<RemoteSchemaTemplateManagerProvider>
<RemoteCollectionManagerProvider>
@ -215,6 +217,7 @@ export const AdminLayout = (props) => {
</RemoteCollectionManagerProvider>
</RemoteSchemaTemplateManagerProvider>
</CurrentUserProvider>
</CurrentDatabaseProvider>
);
};

View File

@ -13,7 +13,7 @@ const useLabelFields = (collectionName?: any) => {
const { getCollectionFields } = useCollectionManager();
const targetFields = getCollectionFields(collectionName);
return targetFields
?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean')
?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean'&&!field.isForeignKey)
?.map?.((field) => {
return {
value: field.name,

View File

@ -10,7 +10,7 @@ import {
SchemaInitializerButtonProps,
SchemaInitializerItemComponent,
SchemaInitializerItemOptions,
SchemaInitializerItemProps
SchemaInitializerItemProps,
} from './types';
const defaultWrap = (s: ISchema) => s;
@ -99,12 +99,11 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => {
}
});
};
const menu = <Menu>{renderItems(items)}</Menu>;
const menu = <Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>{renderItems(items)}</Menu>;
if (!designable && props.designable !== true) {
return null;
}
return (
<Dropdown
className={classNames('nb-schema-initializer-button')}

View File

@ -1,23 +1,41 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../SchemaInitializer';
import { gridRowColWrap, useCustomFormItemInitializerFields } from '../utils';
import { gridRowColWrap, useCustomFormItemInitializerFields,useInheritsFormItemInitializerFields } from '../utils';
import { useCompile } from '../../schema-component';
// 表单里配置字段
export const CustomFormItemInitializers = (props: any) => {
const { t } = useTranslation();
const compile = useCompile();
const { insertPosition, component } = props;
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={[
const inheritFields = useInheritsFormItemInitializerFields();
const fieldItems:any[]=[
{
type: 'itemGroup',
title: t('Configure fields'),
children: useCustomFormItemInitializerFields(),
},
]}
]
if (inheritFields?.length > 0) {
inheritFields.forEach((inherit) => {
fieldItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')',
children: Object.values(inherit)[0],
},
);
});
}
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={fieldItems}
insertPosition={insertPosition}
component={component}
title={component ? null : t('Configure fields')}

View File

@ -2,28 +2,45 @@ import { union } from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../SchemaInitializer';
import { gridRowColWrap, useAssociatedFormItemInitializerFields, useFormItemInitializerFields } from '../utils';
import {
gridRowColWrap,
useAssociatedFormItemInitializerFields,
useFormItemInitializerFields,
useInheritsFormItemInitializerFields,
} from '../utils';
import { useCompile } from '../../schema-component';
// 表单里配置字段
export const FormItemInitializers = (props: any) => {
const { t } = useTranslation();
const { insertPosition, component } = props;
const associationFields = useAssociatedFormItemInitializerFields({ readPretty: true, block: 'Form' });
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={union<any>(
[
const inheritFields = useInheritsFormItemInitializerFields();
const compile = useCompile();
const fieldItems: any[] = [
{
type: 'itemGroup',
title: t('Display fields'),
children: useFormItemInitializerFields(),
},
],
associationFields.length > 0
? [
];
if (inheritFields?.length > 0) {
inheritFields.forEach((inherit) => {
Object.values(inherit)[0].length&&fieldItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')',
children: Object.values(inherit)[0],
},
);
});
}
associationFields.length > 0 &&
fieldItems.push(
{
type: 'divider',
},
@ -32,9 +49,9 @@ export const FormItemInitializers = (props: any) => {
title: t('Display association fields'),
children: associationFields,
},
]
: [],
[
);
fieldItems.push(
{
type: 'divider',
},
@ -53,8 +70,12 @@ export const FormItemInitializers = (props: any) => {
},
},
},
],
)}
);
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={fieldItems}
insertPosition={insertPosition}
component={component}
title={component ? null : t('Configure fields')}

View File

@ -2,27 +2,38 @@ import { union } from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../SchemaInitializer';
import { gridRowColWrap, useAssociatedFormItemInitializerFields, useFormItemInitializerFields } from '../utils';
import { gridRowColWrap, useAssociatedFormItemInitializerFields, useFormItemInitializerFields ,useInheritsFormItemInitializerFields} from '../utils';
import { useCompile } from '../../schema-component';
export const ReadPrettyFormItemInitializers = (props: any) => {
const { t } = useTranslation();
const { insertPosition, component } = props;
const associationFields = useAssociatedFormItemInitializerFields({ readPretty: true, block: 'Form' });
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={union<any>(
[
const inheritFields = useInheritsFormItemInitializerFields();
const compile = useCompile();
const fieldItems: any[] = [
{
type: 'itemGroup',
title: t('Display fields'),
children: useFormItemInitializerFields(),
},
],
associationFields.length > 0
? [
];
if (inheritFields?.length > 0) {
inheritFields.forEach((inherit) => {
fieldItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')',
children: Object.values(inherit)[0],
},
);
});
}
associationFields.length > 0 &&
fieldItems.push([
{
type: 'divider',
},
@ -31,9 +42,9 @@ export const ReadPrettyFormItemInitializers = (props: any) => {
title: t('Display association fields'),
children: associationFields,
},
]
: [],
[
]);
fieldItems.push(
{
type: 'divider',
},
@ -52,8 +63,12 @@ export const ReadPrettyFormItemInitializers = (props: any) => {
},
},
},
],
)}
);
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={fieldItems}
insertPosition={insertPosition}
component={component}
title={component ? null : t('Configure fields')}

View File

@ -104,6 +104,23 @@ const useRelationFields = () => {
return relationFields;
};
const useInheritFields = () => {
const collection = useCollection();
const { getChildrenCollections } = useCollectionManager();
const childrenCollections = getChildrenCollections(collection.name);
return childrenCollections.map((c) => {
return {
key: c.key,
type: 'item',
title: c?.title || c.name,
component: 'RecordReadPrettyFormBlockInitializer',
icon: false,
targetCollection: c,
actionInitializers: 'CalendarFormActionInitializers',
};
});
};
export const RecordBlockInitializers = (props: any) => {
const { t } = useTranslation();
const { insertPosition, component } = props;
@ -134,6 +151,11 @@ export const RecordBlockInitializers = (props: any) => {
},
],
},
{
type: 'itemGroup',
title: '{{t("Children collection blocks")}}',
children: useInheritFields(),
},
{
type: 'itemGroup',
title: '{{t("Relationship blocks")}}',

View File

@ -1,34 +1,65 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../SchemaInitializer';
import { itemsMerge, useAssociatedTableColumnInitializerFields, useTableColumnInitializerFields } from '../utils';
import {
itemsMerge,
useAssociatedTableColumnInitializerFields,
useTableColumnInitializerFields,
useInheritsTableColumnInitializerFields,
} from '../utils';
import { useCompile } from '../../schema-component';
// 表格列配置
export const TableColumnInitializers = (props: any) => {
const { items = [] } = props;
const { t } = useTranslation();
const associatedFields = useAssociatedTableColumnInitializerFields();
const fieldItems: any[] = [{
const inheritFields = useInheritsTableColumnInitializerFields();
const compile = useCompile();
const fieldItems: any[] = [
{
type: 'itemGroup',
title: t('Display fields'),
children: useTableColumnInitializerFields(),
}];
if (associatedFields?.length > 0) {
fieldItems.push({
},
];
if (inheritFields?.length > 0) {
inheritFields.forEach((inherit) => {
Object.values(inherit)[0].length &&
fieldItems.push(
{
type: 'divider',
}, {
},
{
type: 'itemGroup',
title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')',
children: Object.values(inherit)[0],
},
);
});
}
if (associatedFields?.length > 0) {
fieldItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: t('Display association fields'),
children: associatedFields,
})
},
);
}
fieldItems.push({
fieldItems.push(
{
type: 'divider',
}, {
},
{
type: 'item',
title: t('Action column'),
component: 'TableActionColumnInitializer',
})
},
);
return (
<SchemaInitializer.Button
insertPosition={'beforeEnd'}
@ -49,10 +80,7 @@ export const TableColumnInitializers = (props: any) => {
},
};
}}
items={itemsMerge(
fieldItems,
items,
)}
items={itemsMerge(fieldItems, items)}
>
{t('Configure columns')}
</SchemaInitializer.Button>

View File

@ -12,7 +12,7 @@ import { DataBlockInitializer } from "./DataBlockInitializer";
export const CalendarBlockInitializer = (props) => {
const { insert } = props;
const { t } = useTranslation();
const { getCollection } = useCollectionManager();
const { getCollectionFields } = useCollectionManager();
const options = useContext(SchemaOptionsContext);
return (
<DataBlockInitializer
@ -20,8 +20,8 @@ export const CalendarBlockInitializer = (props) => {
componentType={'Calendar'}
icon={<FormOutlined />}
onCreateBlockSchema={async ({ item }) => {
const collection = getCollection(item.name);
const stringFields = collection?.fields
const collectionFields = getCollectionFields(item.name);
const stringFields = collectionFields
?.filter((field) => field.type === 'string')
?.map((field) => {
return {
@ -29,7 +29,7 @@ export const CalendarBlockInitializer = (props) => {
value: field.name,
};
});
const dateFields = collection?.fields
const dateFields = collectionFields
?.filter((field) => field.type === 'date')
?.map((field) => {
return {

View File

@ -13,7 +13,7 @@ import { SchemaComponent, SchemaComponentOptions } from "../../schema-component"
export const KanbanBlockInitializer = (props) => {
const { insert } = props;
const { t } = useTranslation();
const { getCollection } = useCollectionManager();
const { getCollectionFields,getCollection } = useCollectionManager();
const options = useContext(SchemaOptionsContext);
const api = useAPIClient();
return (
@ -22,8 +22,8 @@ export const KanbanBlockInitializer = (props) => {
componentType={'Kanban'}
icon={<FormOutlined />}
onCreateBlockSchema={async ({ item }) => {
const collection = getCollection(item.name);
const fields = collection?.fields
const collectionFields = getCollectionFields(item.name);
const fields = collectionFields
?.filter((field) => ['select', 'radioGroup'].includes(field.interface))
?.map((field) => {
return {
@ -64,7 +64,7 @@ export const KanbanBlockInitializer = (props) => {
initialValues: {},
});
const sortName = `${values.groupField.value}_sort`;
const exists = collection?.fields?.find((field) => field.name === sortName);
const exists = collectionFields?.find((field) => field.name === sortName);
if (!exists) {
await api.resource('collections.fields', item.name).create({
values: {

View File

@ -7,10 +7,17 @@ import { SchemaInitializer } from '../SchemaInitializer';
import { createReadPrettyFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils';
export const RecordReadPrettyFormBlockInitializer = (props) => {
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
const {
onCreateBlockSchema,
componentType,
createBlockSchema,
insert,
icon = true,
targetCollection,
...others
} = props;
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const collection = useCollection();
const collection = targetCollection || useCollection();
const association = useBlockAssociationContext();
const { block } = useBlockRequestContext();
const actionInitializers =
@ -18,7 +25,7 @@ export const RecordReadPrettyFormBlockInitializer = (props) => {
return (
<SchemaInitializer.Item
icon={<FormOutlined />}
icon={icon && <FormOutlined />}
{...others}
key={'123'}
onClick={async ({ item }) => {

View File

@ -73,15 +73,15 @@ export const findTableColumn = (schema: Schema, key: string, action: string, dee
};
export const useTableColumnInitializerFields = () => {
const { name, fields = [] } = useCollection();
const { currentFields = [] } = useCollection();
const { getInterface } = useCollectionManager();
return fields
return currentFields
.filter((field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey)
.map((field) => {
const interfaceConfig = getInterface(field.interface);
const schema = {
name: field.name,
'x-collection-field': `${name}.${field.name}`,
'x-collection-field': `${field.name}`,
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-component-props': {},
@ -105,7 +105,6 @@ export const useTableColumnInitializerFields = () => {
export const useAssociatedTableColumnInitializerFields = () => {
const { name, fields } = useCollection();
const { getInterface, getCollectionFields } = useCollectionManager();
const groups = fields
?.filter((field) => {
return ['o2o', 'oho', 'obo', 'm2o'].includes(field.interface);
@ -141,7 +140,6 @@ export const useAssociatedTableColumnInitializerFields = () => {
schema,
} as SchemaInitializerItemOptions;
});
return {
type: 'subMenu',
title: field.uiSchema?.title,
@ -152,13 +150,51 @@ export const useAssociatedTableColumnInitializerFields = () => {
return groups;
};
export const useInheritsTableColumnInitializerFields = () => {
const { name } = useCollection();
const { getInterface, getParentCollections, getCollection, getParentCollectionFields } = useCollectionManager();
const inherits = getParentCollections(name);
return inherits?.map((v) => {
const fields = getParentCollectionFields(v, name);
const targetCollection = getCollection(v);
return {
[targetCollection?.title]: fields
?.filter((field) => {
return field?.interface;
})
.map((k) => {
const interfaceConfig = getInterface(k.interface);
const schema = {
name: `${k.name}`,
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-collection-field': `${k.name}`,
'x-component-props': {},
};
return {
type: 'item',
title: k?.uiSchema?.title || k.name,
component: 'TableCollectionFieldInitializer',
find: findTableColumn,
remove: removeTableColumn,
schemaInitialize: (s) => {
interfaceConfig?.schemaInitialize?.(s, { field: k, readPretty: true, block: 'Table' });
},
field: k,
schema,
} as SchemaInitializerItemOptions;
}),
};
});
};
export const useFormItemInitializerFields = (options?: any) => {
const { name, fields } = useCollection();
const { name, currentFields } = useCollection();
const { getInterface } = useCollectionManager();
const form = useForm();
const { readPretty = form.readPretty, block = 'Form' } = options || {};
return fields
return currentFields
?.filter((field) => field?.interface && !field?.isForeignKey)
?.map((field) => {
const interfaceConfig = getInterface(field.interface);
@ -170,7 +206,7 @@ export const useFormItemInitializerFields = (options?: any) => {
'x-designer': 'FormItem.Designer',
'x-component': field.interface === 'o2m' ? 'TableField' : 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`,
'x-collection-field': `${field.name}`,
'x-component-props': {},
'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
};
@ -240,13 +276,52 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
return groups;
};
export const useInheritsFormItemInitializerFields = (options?) => {
const { name } = useCollection();
const { getInterface, getParentCollections, getCollection, getParentCollectionFields } = useCollectionManager();
const inherits = getParentCollections(name);
return inherits?.map((v) => {
const fields = getParentCollectionFields(v, name);
const form = useForm();
const { readPretty = form.readPretty, block = 'Form' } = options || {};
const targetCollection = getCollection(v);
return {
[targetCollection.title]: fields
?.filter((field) => field?.interface && !field?.isForeignKey)
?.map((field) => {
const interfaceConfig = getInterface(field.interface);
const schema = {
type: 'string',
name: field.name,
title: field?.uiSchema?.title || field.name,
'x-designer': 'FormItem.Designer',
'x-component': field.interface === 'o2m' ? 'TableField' : 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': `${field.name}`,
'x-component-props': {},
'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
};
return {
type: 'item',
title: field?.uiSchema?.title || field.name,
component: 'CollectionFieldInitializer',
remove: removeGridFormItem,
schemaInitialize: (s) => {
interfaceConfig?.schemaInitialize?.(s, { field, block, readPretty });
},
schema,
} as SchemaInitializerItemOptions;
}),
};
});
};
export const useCustomFormItemInitializerFields = (options?: any) => {
const { name, fields } = useCollection();
const { name, currentFields } = useCollection();
const { getInterface } = useCollectionManager();
const form = useForm();
const { readPretty = form.readPretty, block = 'Form' } = options || {};
const remove = useRemoveGridFormItem();
return fields
return currentFields
?.filter((field) => {
return field?.interface && !field?.uiSchema?.['x-read-pretty'];
})
@ -259,7 +334,7 @@ export const useCustomFormItemInitializerFields = (options?: any) => {
'x-designer': 'FormItem.Designer',
'x-component': 'AssignedField',
'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`,
'x-collection-field': `${field.name}`,
};
return {
type: 'item',
@ -293,7 +368,7 @@ export const useCustomBulkEditFormItemInitializerFields = (options?: any) => {
'x-designer': 'FormItem.Designer',
'x-component': 'BulkEditField',
'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`,
'x-collection-field': `${field.name}`,
};
return {
type: 'item',
@ -344,6 +419,7 @@ export const useCurrentSchema = (action: string, key: string, find = findSchema,
}
const { remove } = useDesignable();
const schema = find(fieldSchema, key, action);
console.log(fieldSchema, key, action);
return {
schema,
exists: !!schema,

View File

@ -1,5 +1,5 @@
import { Dropdown, Menu } from 'antd';
import React, { createContext, useState } from 'react';
import React, { createContext, useState, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useAPIClient, useCurrentUserContext } from '..';
@ -8,14 +8,11 @@ import { ChangePassword } from './ChangePassword';
import { EditProfile } from './EditProfile';
import { LanguageSettings } from './LanguageSettings';
import { SwitchRole } from './SwitchRole';
import {useCurrentDatabase} from '../database/CurrentDatabaseProvider'
const ApplicationVersion = () => {
const { data, loading } = useRequest({
url: 'app:getInfo',
});
if (loading) {
return null;
}
const data=useCurrentDatabase();
return (
<Menu.Item key="version" disabled>
Version {data?.data?.version}

View File

@ -0,0 +1,38 @@
import { Collection } from '../../collection';
import Database from '../../database';
import { InheritedCollection } from '../../inherited-collection';
import { mockDatabase } from '../index';
import pgOnly from './helper';
pgOnly()('sync inherits', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
});
afterEach(async () => {
await db.close();
});
it('should update table fields', async () => {
const person = db.collection({
name: 'person',
fields: [{ type: 'string', name: 'name' }],
});
const student = db.collection({
name: 'student',
inherits: 'person',
});
await db.sync();
student.setField('score', { type: 'integer' });
await db.sync();
const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName);
expect(studentTableInfo.score).toBeDefined();
});
});

View File

@ -0,0 +1,706 @@
import Database from '../../database';
import { InheritedCollection } from '../../inherited-collection';
import { mockDatabase } from '../index';
import pgOnly from './helper';
pgOnly()('collection inherits', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('can update relation with child table', async () => {
const A = db.collection({
name: 'a',
fields: [
{
name: 'a-field',
type: 'string',
},
{
name: 'a1s',
type: 'hasMany',
target: 'a1',
foreignKey: 'aId',
},
],
});
const A1 = db.collection({
name: 'a1',
inherits: ['a'],
fields: [
{
name: 'a1-field',
type: 'string',
},
],
});
await db.sync();
await A.repository.create({
values: {
'a-field': 'a-1',
},
});
let a11 = await A1.repository.create({
values: {
'a1-field': 'a1-1',
'a-field': 'a1-1',
},
});
const a12 = await A1.repository.create({
values: {
'a1-field': 'a1-2',
'a-field': 'a1-2',
},
});
await A1.repository.update({
filterByTk: a11.get('id'),
values: {
a1s: [{ id: a12.get('id') }],
},
});
a11 = await A1.repository.findOne({
filter: {
'a1-field': 'a1-1',
},
appends: ['a1s'],
});
const a11a1s = a11.get('a1s');
expect(a11a1s[0].get('id')).toBe(a12.get('id'));
});
it('can create relation with child table', async () => {
const A = db.collection({
name: 'a',
fields: [
{
name: 'af',
type: 'string',
},
{
name: 'bs',
type: 'hasMany',
target: 'b',
},
],
});
const B = db.collection({
name: 'b',
inherits: ['a'],
fields: [
{
name: 'bf',
type: 'string',
},
{
name: 'a',
type: 'belongsTo',
target: 'a',
},
],
});
await db.sync();
const a1 = await B.repository.create({
values: {
af: 'a1',
bs: [{ bf: 'b1' }, { bf: 'b2' }],
},
});
expect(a1.get('bs').length).toBe(2);
const b1 = await B.repository.findOne({
filter: {
af: 'a1',
},
appends: ['bs'],
});
expect(b1.get('bs').length).toBe(2);
});
it('should inherit belongsToMany field', async () => {
db.collection({
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
{
name: 'tags',
type: 'belongsToMany',
},
],
});
db.collection({
name: 'tags',
fields: [
{
name: 'name',
type: 'string',
},
{
name: 'person',
type: 'belongsToMany',
},
],
});
db.collection({
name: 'students',
inherits: 'person',
fields: [
{
name: 'score',
type: 'integer',
},
],
});
await db.sync();
await db.getCollection('students').repository.create({
values: {
name: 'John',
score: 100,
tags: [
{
name: 't1',
},
{
name: 't2',
},
{
name: 't3',
},
],
},
});
await db.getCollection('person').repository.create({
values: {
name: 'Max',
tags: [
{
name: 't2',
},
{
name: 't4',
},
],
},
});
const john = await db.getCollection('students').repository.findOne({
appends: ['tags'],
});
expect(john.get('name')).toBe('John');
expect(john.get('tags')).toHaveLength(3);
const max = await db.getCollection('person').repository.findOne({
appends: ['tags'],
filter: {
name: 'Max',
},
});
expect(max.get('name')).toBe('Max');
expect(max.get('tags')).toHaveLength(2);
});
it('should inherit hasMany field', async () => {
db.collection({
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
{
name: 'pets',
type: 'hasMany',
},
],
});
db.collection({
name: 'pets',
fields: [
{
name: 'name',
type: 'string',
},
],
});
db.collection({
name: 'students',
inherits: 'person',
fields: [
{
name: 'score',
type: 'integer',
},
],
});
await db.sync();
await db.getCollection('person').repository.create({
values: {
name: 'Max',
pets: [
{
name: 'doge1',
},
{
name: 'kitty1',
},
],
},
});
await db.getCollection('students').repository.create({
values: {
name: 'John',
score: 100,
pets: [
{
name: 'doge',
},
{
name: 'kitty',
},
{
name: 'doge2',
},
],
},
});
const john = await db.getCollection('students').repository.findOne({
appends: ['pets'],
});
expect(john.get('name')).toBe('John');
expect(john.get('pets')).toHaveLength(3);
});
it('can inherit from multiple collections', async () => {
const a = db.collection({
name: 'a',
fields: [{ type: 'string', name: 'a1' }],
});
const b = db.collection({
name: 'b',
fields: [{ type: 'string', name: 'b1' }],
});
const c = db.collection({
name: 'c',
inherits: ['a', 'b'],
fields: [
{ type: 'integer', name: 'id', autoIncrement: true },
{ type: 'string', name: 'c1' },
],
});
await db.sync();
expect(c.getField('a1')).toBeTruthy();
expect(c.getField('b1')).toBeTruthy();
expect(c.getField('c1')).toBeTruthy();
const c1 = await c.repository.create({
values: {
a1: 'a1',
b1: 'b1',
c1: 'c1',
},
});
expect(c1.get('a1')).toBe('a1');
expect(c1.get('b1')).toBe('b1');
expect(c1.get('c1')).toBe('c1');
const a2 = await a.repository.create({
values: {
a1: 'a2',
},
});
expect(a2.get('id')).toEqual(2);
});
it('should update inherit field when parent field update', async () => {
db.collection({
name: 'person',
fields: [{ name: 'name', type: 'string', title: 'parent-name' }],
});
db.collection({
name: 'students',
inherits: 'person',
});
expect(db.getCollection('students').getField('name').get('title')).toBe('parent-name');
db.getCollection('person').setField('name', { type: 'string', title: 'new-name' });
expect(db.getCollection('person').getField('name').get('title')).toBe('new-name');
expect(db.getCollection('students').getField('name').get('title')).toBe('new-name');
});
it('should not replace child field when parent field update', async () => {
db.collection({
name: 'person',
fields: [{ name: 'name', type: 'string', title: 'parent-name' }],
});
db.collection({
name: 'students',
inherits: 'person',
fields: [{ name: 'name', type: 'string', title: 'student-name' }],
});
expect(db.getCollection('students').getField('name').get('title')).toBe('student-name');
db.getCollection('person').setField('name', { type: 'string', title: 'new-name' });
expect(db.getCollection('person').getField('name').get('title')).toBe('new-name');
expect(db.getCollection('students').getField('name').get('title')).toBe('student-name');
});
it('should replace child association target', async () => {
db.collection({
name: 'person',
fields: [
{ name: 'name', type: 'string' },
{ type: 'hasOne', name: 'profile' },
],
});
db.collection({
name: 'profiles',
fields: [
{ name: 'age', type: 'integer' },
{
type: 'belongsTo',
name: 'person',
},
],
});
db.collection({
name: 'teachers',
inherits: 'person',
fields: [{ name: 'salary', type: 'integer' }],
});
db.collection({
name: 'students',
inherits: 'person',
fields: [
{ name: 'score', type: 'integer' },
{
type: 'hasOne',
name: 'profile',
target: 'studentProfiles',
},
],
});
db.collection({
name: 'studentProfiles',
fields: [{ name: 'grade', type: 'string' }],
});
await db.sync();
const student = await db.getCollection('students').repository.create({
values: {
name: 'foo',
score: 100,
profile: {
grade: 'A',
},
},
});
expect(student.get('profile').get('grade')).toBe('A');
const teacher = await db.getCollection('teachers').repository.create({
values: {
name: 'bar',
salary: 1000,
profile: {
age: 30,
},
},
});
expect(teacher.get('profile').get('age')).toBe(30);
});
it('should replace hasOne association field', async () => {
const person = db.collection({
name: 'person',
fields: [
{ name: 'name', type: 'string' },
{ type: 'hasOne', name: 'profile', target: 'profiles', foreignKey: 'person_id' },
],
});
const profile = db.collection({
name: 'profiles',
fields: [
{ name: 'age', type: 'integer' },
{
type: 'belongsTo',
name: 'person',
},
],
});
const student = db.collection({
name: 'students',
inherits: 'person',
fields: [{ name: 'profile', type: 'hasOne', target: 'studentProfiles', foreignKey: 'student_id' }],
});
const studentProfile = db.collection({
name: 'studentProfiles',
fields: [{ name: 'score', type: 'integer' }],
});
await db.sync();
const student1 = await student.repository.create({
values: {
name: 'student-1',
profile: {
score: '100',
},
},
});
let person1 = await person.repository.findOne();
await person.repository
.relation('profile')
.of(person1.get('id'))
.create({
values: {
age: 30,
},
});
person1 = await person.repository.findOne({
appends: ['profile'],
});
expect(person1.get('profile').get('age')).toBe(30);
expect(student1.get('profile').get('score')).toBe(100);
});
it('should inherit hasOne association field', async () => {
const person = db.collection({
name: 'person',
fields: [
{ name: 'name', type: 'string' },
{ type: 'hasOne', name: 'profile' },
],
});
const profile = db.collection({
name: 'profiles',
fields: [
{ name: 'age', type: 'integer' },
{
type: 'belongsTo',
name: 'person',
},
],
});
db.collection({
name: 'students',
inherits: 'person',
fields: [{ name: 'score', type: 'integer' }],
});
db.collection({
name: 'teachers',
inherits: 'person',
fields: [{ name: 'salary', type: 'integer' }],
});
await db.sync();
await db.getCollection('students').repository.create({
values: {
name: 'foo',
score: 100,
profile: {
age: 18,
},
},
});
await db.getCollection('teachers').repository.create({
values: {
name: 'bar',
salary: 1000,
profile: {
age: 30,
},
},
});
const studentFoo = await db.getCollection('students').repository.findOne({
appends: ['profile'],
});
const teacherBar = await db.getCollection('teachers').repository.findOne({
appends: ['profile'],
});
expect(studentFoo.get('profile').age).toBe(18);
expect(teacherBar.get('profile').age).toBe(30);
});
it('should inherit from Collection', async () => {
const person = db.collection({
name: 'person',
fields: [{ name: 'name', type: 'string' }],
});
const student = db.collection({
name: 'student',
inherits: 'person',
fields: [{ name: 'score', type: 'integer' }],
});
await db.sync();
const StudentRepository = student.repository;
await StudentRepository.create({
values: { name: 'student1' },
});
expect(await person.repository.count()).toBe(1);
});
it('should create inherited table', async () => {
const person = db.collection({
name: 'person',
fields: [{ name: 'name', type: 'string' }],
});
const student = db.collection({
name: 'student',
inherits: 'person',
fields: [{ name: 'score', type: 'integer' }],
});
await db.sync();
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();
});
it('should get parent fields', async () => {
const root = db.collection({
name: 'root',
fields: [{ name: 'rootField', type: 'string' }],
});
const parent1 = db.collection({
name: 'parent1',
inherits: 'root',
fields: [{ name: 'parent1Field', type: 'string' }],
});
const parent2 = db.collection({
name: 'parent2',
inherits: 'parent1',
fields: [{ name: 'parent2Field', type: 'string' }],
});
const parent21 = db.collection({
name: 'parent21',
fields: [{ name: 'parent21Field', type: 'string' }],
});
const child: InheritedCollection = db.collection({
name: 'child',
inherits: ['parent2', 'parent21'],
fields: [{ name: 'childField', type: 'string' }],
}) as InheritedCollection;
const parentFields = child.parentFields();
expect(parentFields.size).toBe(4);
});
it('should sync parent fields', async () => {
const person = db.collection({
name: 'person',
fields: [{ name: 'name', type: 'string' }],
});
const student = db.collection({
name: 'student',
inherits: 'person',
fields: [{ name: 'score', type: 'integer' }],
});
await db.sync();
expect(student.fields.get('name')).toBeDefined();
// add new field to parent
person.setField('age', { type: 'integer' });
await db.sync();
expect(student.fields.get('age')).toBeDefined();
const student1 = await db.getCollection('student').repository.create({
values: {
name: 'student1',
age: 10,
score: 100,
},
});
expect(student1.get('name')).toBe('student1');
expect(student1.get('age')).toBe(10);
});
});

View File

@ -0,0 +1,3 @@
const pgOnly = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
export default pgOnly;

View File

@ -0,0 +1,27 @@
import InheritanceMap from '../../inherited-map';
describe('InheritedMap', () => {
it('should setInherits', () => {
const map = new InheritanceMap();
map.setInheritance('b', 'a');
const nodeA = map.getNode('a');
const nodeB = map.getNode('b');
expect(nodeA.children.has(nodeB)).toBe(true);
expect(nodeB.parents.has(nodeA)).toBe(true);
expect(map.isParentNode('a')).toBe(true);
});
it('should get deep children', () => {
const map = new InheritanceMap();
map.setInheritance('b', 'a');
map.setInheritance('c', 'b');
map.setInheritance('c1', 'b');
map.setInheritance('d', 'c');
const children = map.getChildren('a');
expect(children.size).toBe(4);
});
});

View File

@ -22,6 +22,7 @@ export type CollectionSortable = string | boolean | { name?: string; scopeKey?:
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
name: string;
tableName?: string;
inherits?: string[] | string;
filterTargetKey?: string;
fields?: FieldOptions[];
model?: string | ModelCtor<Model>;
@ -74,7 +75,9 @@ export class Collection<
this.bindFieldEventListener();
this.modelInit();
this.db.modelCollection.set(this.model, this);
this.db.tableNameCollectionMap.set(this.model.tableName, this);
this.setFields(options.fields);
this.setRepository(options.repository);
@ -181,9 +184,34 @@ export class Collection<
},
);
const oldField = this.fields.get(name);
if (oldField && oldField.options.inherit && options.type != oldField.options.type) {
throw new Error(
`Field type conflict: cannot set "${name}" to ${options.type}, parent "${name}" type is ${oldField.options.type}`,
);
}
this.removeField(name);
this.fields.set(name, field);
this.emit('field.afterAdd', field);
if (this.isParent()) {
for (const child of this.context.database.inheritanceMap.getChildren(this.name, {
deep: false,
})) {
const childCollection = this.db.getCollection(child);
const existField = childCollection.getField(name);
if (!existField || existField.options.inherit) {
childCollection.setField(name, {
...options,
inherit: true,
});
}
}
}
return field;
}
@ -371,6 +399,7 @@ export class Collection<
for (const associationKey in associations) {
const association = associations[associationKey];
modelNames.add(association.target.name);
if ((<any>association).through) {
modelNames.add((<any>association).through.model.name);
}
@ -388,4 +417,12 @@ export class Collection<
await model.sync(syncOptions);
}
}
public isInherited() {
return false;
}
public isParent() {
return this.context.database.inheritanceMap.isParentNode(this.name);
}
}

View File

@ -56,6 +56,8 @@ import {
} from './types';
import { referentialIntegrityCheck } from './features/referential-integrity-check';
import ReferencesMap from './features/ReferencesMap';
import { InheritedCollection } from './inherited-collection';
import InheritanceMap from './inherited-map';
export interface MergeOptions extends merge.Options {}
@ -148,7 +150,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
collections = new Map<string, Collection>();
pendingFields = new Map<string, RelationField[]>();
modelCollection = new Map<ModelCtor<any>, Collection>();
tableNameCollectionMap = new Map<string, Collection>();
referenceMap = new ReferencesMap();
inheritanceMap = new InheritanceMap();
modelHook: ModelHook;
version: DatabaseVersion;
@ -293,7 +298,11 @@ export class Database extends EventEmitter implements AsyncEmitter {
): Collection<Attributes, CreateAttributes> {
this.emit('beforeDefineCollection', options);
const collection = new Collection(options, {
const collection = options.inherits
? new InheritedCollection(options, {
database: this,
})
: new Collection(options, {
database: this,
});
@ -429,6 +438,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
if (isMySQL) {
await this.sequelize.query('SET FOREIGN_KEY_CHECKS = 1', null);
}
return result;
}

View File

@ -10,6 +10,7 @@ import {
import { Collection } from '../collection';
import { Database } from '../database';
import { ModelEventTypes } from '../types';
import { InheritedCollection } from '../inherited-collection';
export interface FieldContext {
database: Database;
@ -19,6 +20,7 @@ export interface FieldContext {
export interface BaseFieldOptions {
name?: string;
hidden?: boolean;
[key: string]: any;
}
@ -32,8 +34,17 @@ export abstract class Field {
context: FieldContext;
database: Database;
collection: Collection;
[key: string]: any;
constructor(options?: any, context?: FieldContext) {
this.context = context;
this.database = context.database;
this.collection = context.collection;
this.options = options || {};
this.init();
}
get name() {
return this.options.name;
}
@ -46,14 +57,6 @@ export abstract class Field {
return this.options.dataType;
}
constructor(options?: any, context?: FieldContext) {
this.context = context;
this.database = context.database;
this.collection = context.collection;
this.options = options || {};
this.init();
}
async sync(syncOptions: SyncOptions) {
await this.collection.sync({
...syncOptions,
@ -88,11 +91,18 @@ export abstract class Field {
}
async removeFromDb(options?: QueryInterfaceOptions) {
if (!this.collection.model.rawAttributes[this.name]) {
const attribute = this.collection.model.rawAttributes[this.name];
if (!attribute) {
this.remove();
// console.log('field is not attribute');
return;
}
if (this.collection.isInherited() && (<InheritedCollection>this.collection).parentFields().has(this.name)) {
return;
}
if ((this.collection.model as any)._virtualAttributes.has(this.name)) {
this.remove();
// console.log('field is virtual attribute');

View File

@ -1,5 +1,6 @@
export { DataTypes, ModelCtor, Op, SyncOptions } from 'sequelize';
export * from './collection';
export * from './inherited-collection';
export * from './database';
export { Database as default } from './database';
export * from './fields';
@ -14,4 +15,3 @@ export * from './relation-repository/multiple-relation-repository';
export * from './relation-repository/single-relation-repository';
export * from './repository';
export * from './update-associations';

View File

@ -0,0 +1,73 @@
import { Collection, CollectionContext, CollectionOptions } from './collection';
import { default as lodash } from 'lodash';
import { Field } from '.';
export class InheritedCollection extends Collection {
parents?: Collection[];
constructor(options: CollectionOptions, context: CollectionContext) {
if (!options.inherits) {
throw new Error('InheritedCollection must have inherits option');
}
super(options, context);
this.setParents(options.inherits);
this.context.database.inheritanceMap.setInheritance(this.name, options.inherits);
this.setParentFields();
}
protected setParents(inherits: string | string[]) {
this.parents = lodash.castArray(inherits).map((name) => this.context.database.collections.get(name));
}
protected setParentFields() {
for (const [name, field] of this.parentFields()) {
if (!this.hasField(name)) {
this.setField(name, {
...field.options,
inherit: true,
});
}
}
}
getParents() {
return this.parents;
}
parentFields() {
const fields = new Map<string, Field>();
for (const parent of this.parents) {
if (parent.isInherited()) {
for (const [name, field] of (<InheritedCollection>parent).parentFields()) {
fields.set(name, field);
}
}
const parentFields = parent.fields;
for (const [name, field] of parentFields) {
fields.set(name, field);
}
}
return fields;
}
parentAttributes() {
const attributes = {};
for (const parent of this.parents) {
if (parent.isInherited()) {
Object.assign(attributes, (<InheritedCollection>parent).parentAttributes());
}
const parentAttributes = (<any>parent.model).tableAttributes;
Object.assign(attributes, parentAttributes);
}
return attributes;
}
isInherited() {
return true;
}
}

View File

@ -0,0 +1,81 @@
import lodash from 'lodash';
class TableNode {
name: string;
parents: Set<TableNode>;
children: Set<TableNode>;
constructor(name: string) {
this.name = name;
this.parents = new Set();
this.children = new Set();
}
}
export default class InheritanceMap {
nodes: Map<string, TableNode> = new Map<string, TableNode>();
getOrCreateNode(name: string) {
if (!this.nodes.has(name)) {
this.nodes.set(name, new TableNode(name));
}
return this.getNode(name);
}
getNode(name: string) {
return this.nodes.get(name);
}
setInheritance(name: string, inherits: string | string[]) {
const node = this.getOrCreateNode(name);
const parents = lodash.castArray(inherits).map((name) => this.getOrCreateNode(name));
node.parents = new Set(parents);
for (const parent of parents) {
parent.children.add(node);
}
}
isParentNode(name: string) {
const node = this.getNode(name);
return node && node.children.size > 0;
}
getChildren(name: string, options: { deep: boolean } = { deep: true }): Set<string> {
const results = new Set<string>();
const node = this.getNode(name);
if (!node) return results;
for (const child of node.children) {
results.add(child.name);
if (!options.deep) {
continue;
}
for (const grandchild of this.getChildren(child.name)) {
results.add(grandchild);
}
}
return results;
}
getParents(name: string, options: { deep: boolean } = { deep: true }): Set<string> {
const results = new Set<string>();
const node = this.getNode(name);
if (!node) return results;
for (const parent of node.parents) {
results.add(parent.name);
if (!options.deep) {
continue;
}
for (const grandparent of this.getParents(parent.name)) {
results.add(grandparent);
}
}
return results;
}
}

View File

@ -3,6 +3,8 @@ import { Model as SequelizeModel, ModelCtor } 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';
interface IModel {
[key: string]: any;
@ -145,4 +147,14 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
return lodash.orderBy(data, orderItems, orderDirections);
}
static async sync(options) {
const model = this as any;
if (this.collection.isInherited()) {
return SyncRunner.syncInheritModel(model, options);
}
return SequelizeModel.sync.call(this, options);
}
}

View File

@ -104,6 +104,13 @@ export class OptionsParser {
return filterParams;
}
protected inheritFromSubQuery(): any {
return [
Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`),
'__tableName',
];
}
protected parseFields(filterParams: any) {
const appends = this.options?.appends || [];
const except = [];
@ -113,6 +120,10 @@ export class OptionsParser {
exclude: [],
}; // out put all fields by default
if (this.collection.isParent()) {
attributes.include.push(this.inheritFromSubQuery());
}
if (this.options?.fields) {
// 将fields拆分为 attributes 和 appends
for (const field of this.options.fields) {

View File

@ -232,6 +232,8 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
...this.buildQueryOptions(options),
};
let rows;
if (opts.include && opts.include.length > 0) {
// @ts-ignore
const primaryKeyField = model.primaryKeyField || model.primaryKeyAttribute;
@ -258,29 +260,40 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
},
};
return await handleAppendsQuery({
rows = await handleAppendsQuery({
queryPromises: opts.include.map((include) => {
return model
.findAll({
const options = {
...omit(opts, ['limit', 'offset']),
include: include,
where,
transaction,
})
.then((rows) => {
};
return model.findAll(options).then((rows) => {
return { rows, include };
});
}),
templateModel: ids[0].row,
});
}
return await model.findAll({
} else {
rows = await model.findAll({
...opts,
transaction,
});
}
if (this.collection.isParent()) {
for (const row of rows) {
const rowCollectionName = this.database.tableNameCollectionMap.get(row.get('__tableName')).name;
row.set('__collection', rowCollectionName, {
raw: true,
});
}
}
return rows;
}
/**
* find and count
* @param options

View File

@ -0,0 +1,117 @@
import { InheritedCollection } from './inherited-collection';
import lodash from 'lodash';
import { Sequelize } from 'sequelize';
export class SyncRunner {
static async syncInheritModel(model: any, options: any) {
const { transaction } = options;
const inheritedCollection = model.collection as InheritedCollection;
const db = inheritedCollection.context.database;
const dialect = db.sequelize.getDialect();
const queryInterface = db.sequelize.getQueryInterface();
if (dialect != 'postgres') {
throw new Error('Inherit model is only supported on postgres');
}
const parents = inheritedCollection.parents;
const parentTables = parents.map((parent) => parent.model.tableName);
const tableName = model.getTableName();
const attributes = model.tableAttributes;
const childAttributes = lodash.pickBy(attributes, (value) => {
return !value.inherit;
});
let maxSequenceVal = 0;
let maxSequenceName;
if (childAttributes.id && childAttributes.id.autoIncrement) {
for (const parent of parentTables) {
const sequenceNameResult = await queryInterface.sequelize.query(
`select pg_get_serial_sequence('"${parent}"', 'id')`,
{
transaction,
},
);
const sequenceName = sequenceNameResult[0][0]['pg_get_serial_sequence'];
const sequenceCurrentValResult = await queryInterface.sequelize.query(
`select last_value from ${sequenceName}`,
{
transaction,
},
);
const sequenceCurrentVal = sequenceCurrentValResult[0][0]['last_value'];
if (sequenceCurrentVal > maxSequenceVal) {
maxSequenceName = sequenceName;
maxSequenceVal = sequenceCurrentVal;
}
}
}
await this.createTable(tableName, childAttributes, options, model, parentTables);
const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map(
(parent) => db.getCollection(parent).model.tableName,
);
const sequenceTables = [...parentsDeep, tableName];
for (const sequenceTable of sequenceTables) {
await queryInterface.sequelize.query(
`alter table "${sequenceTable}" alter column id set default nextval('${maxSequenceName}')`,
{
transaction,
},
);
}
if (options.alter) {
const columns = await queryInterface.describeTable(tableName, options);
for (const columnName in childAttributes) {
if (!columns[columnName]) {
await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options);
}
}
}
}
static async createTable(tableName, attributes, options, model, parentTables) {
let sql = '';
options = { ...options };
if (options && options.uniqueKeys) {
lodash.forOwn(options.uniqueKeys, (uniqueKey) => {
if (uniqueKey.customIndex === undefined) {
uniqueKey.customIndex = true;
}
});
}
if (model) {
options.uniqueKeys = options.uniqueKeys || model.uniqueKeys;
}
const queryGenerator = model.queryGenerator;
attributes = lodash.mapValues(attributes, (attribute) => model.sequelize.normalizeAttribute(attribute));
attributes = queryGenerator.attributesToSQL(attributes, { table: tableName, context: 'createTable' });
sql = `${queryGenerator.createTableQuery(tableName, attributes, options)}`.replace(
';',
` INHERITS (${parentTables.map((t) => `"${t}"`).join(', ')});`,
);
return await model.sequelize.query(sql, options);
}
}

View File

@ -1,3 +1,5 @@
export { mockDatabase } from '@nocobase/database';
export * from './mockServer';
const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
export { pgOnly };

View File

@ -22,6 +22,7 @@ export class ClientPlugin extends Plugin {
this.app.acl.allow('app', 'getInfo');
this.app.acl.allow('app', 'getPlugins');
this.app.acl.allow('plugins', 'getPinned', 'loggedIn');
const dialect = this.app.db.sequelize.getDialect();
this.app.resource({
name: 'app',
actions: {
@ -35,6 +36,9 @@ export class ClientPlugin extends Plugin {
lang = currentUser?.appLang;
}
ctx.body = {
database: {
dialect,
},
version: await ctx.app.version.get(),
lang,
};

View File

@ -8,9 +8,7 @@ describe('collections repository', () => {
beforeEach(async () => {
app = await createApp();
agent = app.agent();
await agent
.resource('collections')
.create({
await agent.resource('collections').create({
values: {
name: 'tags',
fields: [
@ -21,9 +19,7 @@ describe('collections repository', () => {
],
},
});
await agent
.resource('collections')
.create({
await agent.resource('collections').create({
values: {
name: 'foos',
fields: [
@ -34,18 +30,14 @@ describe('collections repository', () => {
],
},
});
await agent
.resource('collections.fields', 'tags')
.create({
await agent.resource('collections.fields', 'tags').create({
values: {
name: 'foos',
target: 'foos',
type: 'belongsToMany',
},
});
await agent
.resource('collections')
.create({
await agent.resource('collections').create({
values: {
name: 'comments',
fields: [
@ -56,9 +48,7 @@ describe('collections repository', () => {
],
},
});
await agent
.resource('collections')
.create({
await agent.resource('collections').create({
values: {
name: 'posts',
fields: [
@ -95,23 +85,17 @@ describe('collections repository', () => {
it('case 2', async () => {
const response = await app.agent().resource('posts').create();
const postId = response.body.data.id;
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 1',
},
});
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 2',
},
});
const response2 = await agent
.resource('posts')
.list({
const response2 = await agent.resource('posts').list({
filter: {
'comments.title': 'comment 1',
},
@ -122,16 +106,12 @@ describe('collections repository', () => {
it('case 3', async () => {
const response = await app.agent().resource('posts').create();
const postId = response.body.data.id;
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 1',
},
});
const response2 = await agent
.resource('posts')
.list({
const response2 = await agent.resource('posts').list({
filter: {
'comments.id': 3,
},
@ -142,16 +122,12 @@ describe('collections repository', () => {
it('case 4', async () => {
const response = await app.agent().resource('posts').create();
const postId = response.body.data.id;
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 1',
},
});
const response2 = await agent
.resource('posts')
.list({
const response2 = await agent.resource('posts').list({
filter: {
$and: [
{
@ -174,9 +150,7 @@ describe('collections repository', () => {
});
it('case 6', async () => {
const response = await agent
.resource('posts')
.create({
const response = await agent.resource('posts').create({
values: {
tags: [
{},
@ -192,9 +166,7 @@ describe('collections repository', () => {
});
it('case 7', async () => {
const response = await agent
.resource('posts')
.create({
const response = await agent.resource('posts').create({
values: {
tags: [
{},
@ -205,9 +177,7 @@ describe('collections repository', () => {
},
});
const postId = response.body.data.id;
const response1 = await agent
.resource('posts.tags', postId)
.list({
const response1 = await agent.resource('posts.tags', postId).list({
filter: {
title: 'Tag1',
},
@ -216,9 +186,7 @@ describe('collections repository', () => {
});
it('case 8', async () => {
const response = await agent
.resource('posts')
.create({
const response = await agent.resource('posts').create({
values: {
tags: [
{},
@ -232,9 +200,7 @@ describe('collections repository', () => {
},
});
const postId = response.body.data.id;
const response1 = await agent
.resource('posts.tags', postId)
.list({
const response1 = await agent.resource('posts.tags', postId).list({
filter: {
$or: [{ title: 'Tag1' }, { title: 'Tag2' }],
},
@ -243,9 +209,7 @@ describe('collections repository', () => {
});
it('case 9', async () => {
const response = await agent
.resource('posts')
.create({
const response = await agent.resource('posts').create({
values: {
tags: [
{},
@ -259,9 +223,7 @@ describe('collections repository', () => {
},
});
const postId = response.body.data.id;
const response1 = await agent
.resource('posts.tags', postId)
.list({
const response1 = await agent.resource('posts.tags', postId).list({
filter: {
$or: [{ title: 'Tag1' }, { title: 'Tag2' }],
},
@ -270,9 +232,7 @@ describe('collections repository', () => {
});
it('case 10', async () => {
const response = await agent
.resource('posts')
.create({
const response = await agent.resource('posts').create({
values: {
tags: [
{},
@ -286,9 +246,7 @@ describe('collections repository', () => {
},
});
const postId = response.body.data.id;
const response1 = await agent
.resource('posts.tags', postId)
.list({
const response1 = await agent.resource('posts.tags', postId).list({
appends: ['foos'],
page: 1,
pageSize: 20,
@ -301,39 +259,29 @@ describe('collections repository', () => {
it('case 11', async () => {
const response = await app.agent().resource('posts').create();
const postId = response.body.data.id;
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 1',
},
});
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 2',
},
});
const response2 = await app.agent().resource('posts').create();
const postId2 = response2.body.data.id;
await agent
.resource('posts.comments', postId2)
.create({
await agent.resource('posts.comments', postId2).create({
values: {
title: 'comment 2',
},
});
await agent
.resource('posts.comments', postId2)
.create({
await agent.resource('posts.comments', postId2).create({
values: {
title: 'comment 2',
},
});
const response3 = await agent
.resource('posts')
.list({
const response3 = await agent.resource('posts').list({
filter: {
$or: [
{
@ -351,30 +299,22 @@ describe('collections repository', () => {
it('case 12', async () => {
const response = await app.agent().resource('posts').create();
const postId = response.body.data.id;
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 1',
},
});
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 2',
},
});
await agent
.resource('posts.comments', postId)
.create({
await agent.resource('posts.comments', postId).create({
values: {
title: 'comment 3',
},
});
const response2 = await agent
.resource('posts.comments', postId)
.list({
const response2 = await agent.resource('posts.comments', postId).list({
filter: {
$or: [
{
@ -394,31 +334,23 @@ describe('collections repository', () => {
const tag1 = await tagRepository.create({ values: { title: 'tag1' } });
const tag2 = await tagRepository.create({ values: { title: 'tag2' } });
const tag3 = await tagRepository.create({ values: { title: 'tag3' } });
await agent
.resource('posts')
.create({
await agent.resource('posts').create({
values: {
tags: [tag1.get('id'), tag3.get('id')],
},
});
await agent
.resource('posts')
.create({
await agent.resource('posts').create({
values: {
tags: [tag2.get('id')],
},
});
await agent
.resource('posts')
.create({
await agent.resource('posts').create({
values: {
tags: [tag2.get('id'), tag3.get('id')],
},
});
const response1 = await agent
.resource('posts')
.list({
const response1 = await agent.resource('posts').list({
filter: {
$or: [{ 'tags.title': 'tag1' }, { 'tags.title': 'tag3' }],
},

View File

@ -0,0 +1,281 @@
import { MockServer, pgOnly } from '@nocobase/test';
import { createApp } from '..';
pgOnly()('Inherited Collection', () => {
let app: MockServer;
let agent;
beforeEach(async () => {
app = await createApp();
agent = app.agent();
await agent.resource('collections').create({
values: {
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should not replace field with difference type when add field', async () => {
let response = await agent.resource('collections').create({
values: {
name: 'students',
inherits: 'person',
fields: [],
},
});
expect(response.statusCode).toBe(200);
response = await agent.resource('fields').create({
values: {
collectionName: 'students',
name: 'name',
type: 'integer',
},
});
expect(response.statusCode).not.toBe(200);
});
it('should not replace field with difference type when create collection', async () => {
const response = await agent.resource('collections').create({
context: {},
values: {
name: 'students',
inherits: ['person'],
fields: [
{
name: 'name',
type: 'integer',
},
],
},
});
expect(response.statusCode).toBe(500);
});
it('can create relation with child table', async () => {
await agent.resource('collections').create({
values: {
name: 'a',
fields: [
{
name: 'af',
type: 'string',
},
{
name: 'bs',
type: 'hasMany',
target: 'b',
},
],
},
});
await agent.resource('collections').create({
values: {
name: 'b',
inherits: ['a'],
fields: [
{
name: 'bf',
type: 'string',
},
{
name: 'a',
type: 'belongsTo',
target: 'a',
},
],
},
});
const res = await agent.resource('b').create({
values: {
af: 'a1',
bs: [{ bf: 'b1' }, { bf: 'b2' }],
},
});
expect(res.statusCode).toEqual(200);
const a1 = await agent.resource('b').list({
filter: {
af: 'a1',
},
appends: 'bs',
});
expect(a1.body.data[0].bs.length).toEqual(2);
});
it('can drop child replaced field', async () => {
await agent.resource('collections').create({
values: {
name: 'students',
inherits: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
});
const response = await agent.resource('collections.fields', 'students').destroy({
filter: {
name: 'name',
},
});
expect(response.status).toBe(200);
});
it('should reload collection when parent fields change', async () => {
await agent.resource('collections').create({
values: {
name: 'employee',
inherits: 'person',
fields: [
{
name: 'salary',
type: 'integer',
},
],
},
});
await agent.resource('collections').create({
values: {
name: 'instructor',
inherits: 'employee',
fields: [
{
name: 'rank',
type: 'integer',
},
],
},
});
const createInstructorResponse = await agent.resource('instructor').create({
values: {
name: 'foo',
salary: 1000,
rank: 100,
},
});
expect(createInstructorResponse.statusCode).toBe(200);
const employeeCollection = app.db.getCollection('employee');
expect(employeeCollection.fields.get('new-field')).not.toBeDefined();
// add new field to root collection
await agent.resource('fields').create({
values: {
collectionName: 'person',
name: 'age',
type: 'integer',
},
});
expect(employeeCollection.fields.get('age')).toBeDefined();
let listResponse = await agent.resource('employee').list();
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body.data[0].age).toBeDefined();
listResponse = await agent.resource('instructor').list();
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body.data[0].age).toBeDefined();
});
it('should create inherited collection', async () => {
const response = await agent.resource('collections').create({
values: {
name: 'students',
inherits: 'person',
fields: [
{
name: 'score',
type: 'integer',
},
],
},
});
expect(response.statusCode).toBe(200);
const studentCollection = app.db.getCollection('students');
expect(studentCollection).toBeDefined();
const studentFieldsResponse = await agent.resource('fields').list({
filter: {
collectionName: 'students',
},
});
expect(studentFieldsResponse.statusCode).toBe(200);
const studentFields = studentFieldsResponse.body.data;
expect(studentFields.length).toBe(1);
const createStudentResponse = await agent.resource('students').create({
values: {
name: 'foo',
score: 100,
},
});
expect(createStudentResponse.statusCode).toBe(200);
const fooStudent = createStudentResponse.body.data;
expect(fooStudent.name).toBe('foo');
const studentList = await agent.resource('students').list();
expect(studentList.statusCode).toBe(200);
});
it('should know which child table row it is', async () => {
await agent.resource('collections').create({
values: {
name: 'students',
inherits: 'person',
fields: [
{
name: 'score',
type: 'integer',
},
],
},
});
await agent.resource('students').create({
values: {
name: 'foo',
score: 100,
},
});
const personList = await agent.resource('person').list();
const person = personList.body.data[0];
expect(person['__collection']).toBe('students');
});
});

View File

@ -0,0 +1,320 @@
import Database, { Repository } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '..';
import { pgOnly } from '@nocobase/test';
pgOnly()('Inherited Collection', () => {
let db: Database;
let app: Application;
let collectionRepository: Repository;
let fieldsRepository: Repository;
beforeEach(async () => {
app = await createApp();
db = app.db;
collectionRepository = db.getCollection('collections').repository;
fieldsRepository = db.getCollection('fields').repository;
});
afterEach(async () => {
await app.destroy();
});
it("should not delete child's field when parent field delete that inherits from multiple table", async () => {
await collectionRepository.create({
values: {
name: 'b',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
await collectionRepository.create({
values: {
name: 'c',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
await collectionRepository.create({
values: {
name: 'a',
inherits: ['b', 'c'],
},
context: {},
});
await fieldsRepository.create({
values: {
collectionName: 'a',
name: 'name',
type: 'string',
},
});
await db.getCollection('fields').repository.destroy({
filter: {
collectionName: 'b',
name: 'name',
},
});
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'a',
name: 'name',
},
}),
).not.toBeNull();
await db.getCollection('fields').repository.destroy({
filter: {
collectionName: 'c',
name: 'name',
},
});
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'a',
name: 'name',
},
}),
).toBeNull();
});
it("should delete child's field when parent field deleted", async () => {
await collectionRepository.create({
values: {
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
await collectionRepository.create({
values: {
name: 'students',
inherits: ['person'],
},
context: {},
});
await db.getCollection('fields').repository.create({
values: {
collectionName: 'students',
name: 'name',
type: 'string',
},
context: {},
});
await db.getCollection('fields').repository.create({
values: {
collectionName: 'students',
name: 'age',
type: 'integer',
},
context: {},
});
const childNameField = await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'students',
name: 'name',
},
});
expect(childNameField.get('overriding')).toBeTruthy();
await db.getCollection('fields').repository.destroy({
filter: {
collectionName: 'person',
name: 'name',
},
});
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'students',
name: 'name',
},
}),
).toBeNull();
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'students',
name: 'age',
},
}),
).not.toBeNull();
await db.getCollection('fields').repository.create({
values: {
collectionName: 'person',
name: 'age',
type: 'integer',
},
context: {},
});
await db.getCollection('fields').repository.destroy({
filter: {
collectionName: 'person',
name: 'age',
},
});
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'person',
name: 'age',
},
}),
).toBeNull();
expect(
await db.getCollection('fields').repository.findOne({
filter: {
collectionName: 'students',
name: 'age',
},
}),
).not.toBeNull();
});
it('should not inherit with difference type', async () => {
const personCollection = await collectionRepository.create({
values: {
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
let err;
try {
const studentCollection = await collectionRepository.create({
values: {
name: 'students',
inherits: 'person',
fields: [
{
name: 'name',
type: 'integer',
},
],
},
context: {},
});
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(err.message.includes('type conflict')).toBeTruthy();
});
it('should replace parent collection field', async () => {
const personCollection = await collectionRepository.create({
values: {
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
const studentCollection = await collectionRepository.create({
values: {
name: 'students',
inherits: 'person',
fields: [
{
name: 'name',
type: 'string',
title: '姓名',
},
],
},
context: {},
});
const studentFields = await studentCollection.getFields();
expect(studentFields.length).toBe(1);
expect(studentFields[0].get('title')).toBe('姓名');
});
it('should remove parent collections field', async () => {
await collectionRepository.create({
values: {
name: 'person',
fields: [
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
await collectionRepository.create({
values: {
name: 'students',
fields: [
{
name: 'score',
type: 'integer',
},
],
},
context: {},
});
const studentCollection = await db.getCollection('students');
console.log(studentCollection.fields);
await studentCollection.repository.create({
values: {
name: 'foo',
score: 100,
},
});
});
});

View File

@ -0,0 +1,57 @@
import Database, { Collection as DBCollection } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '.';
describe('sync collection', () => {
let db: Database;
let app: Application;
beforeEach(async () => {
app = await createApp();
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should not remove column when async with drop false', async () => {
const getTableInfo = async (tableName: string) => {
const queryInterface = db.sequelize.getQueryInterface();
const tableInfo = await queryInterface.describeTable(tableName);
return tableInfo;
};
const c1 = db.collection({
name: 'c1',
fields: [{ type: 'string', name: 'f1' }],
});
await db.sync({
force: false,
alter: {
drop: false,
},
});
let tableInfo1 = await getTableInfo(c1.model.tableName);
expect(tableInfo1.f1).toBeTruthy();
c1.setField('f2', {
type: 'string',
});
c1.removeField('f1');
await db.sync({
force: false,
alter: {
drop: false,
},
});
let tableInfo2 = await getTableInfo(c1.model.tableName);
expect(tableInfo2.f2).toBeTruthy();
expect(tableInfo2.f1).toBeTruthy();
});
});

View File

@ -1,6 +1,7 @@
import Database, { Collection, MagicAttributeModel } from '@nocobase/database';
import { SyncOptions, Transactionable } from 'sequelize';
import { FieldModel } from './field';
import lodash from 'lodash';
interface LoadOptions extends Transactionable {
// TODO
@ -19,25 +20,27 @@ export class CollectionModel extends MagicAttributeModel {
let collection: Collection;
const collectionOptions = {
...this.get(),
fields: [],
};
if (this.db.hasCollection(name)) {
collection = this.db.getCollection(name);
if (skipExist) {
return collection;
}
collection.updateOptions({
...this.get(),
fields: [],
});
collection.updateOptions(collectionOptions);
} else {
collection = this.db.collection({
...this.get(),
fields: [],
});
collection = this.db.collection(collectionOptions);
}
if (!skipField) {
await this.loadFields({ transaction });
}
return collection;
}
@ -83,6 +86,7 @@ export class CollectionModel extends MagicAttributeModel {
const collection = await this.load({
transaction: options?.transaction,
});
try {
await collection.sync({
force: false,
@ -92,8 +96,73 @@ export class CollectionModel extends MagicAttributeModel {
...options,
});
} catch (error) {
console.error(error);
const name = this.get('name');
this.db.removeCollection(name);
throw error;
}
}
isInheritedModel() {
return this.get('inherits');
}
async findParents(options: Transactionable) {
const { transaction } = options;
const findModelParents = async (model: CollectionModel, carry = []) => {
if (!model.get('inherits')) {
return;
}
const parents = lodash.castArray(model.get('inherits'));
for (const parent of parents) {
const parentModel = (await this.db.getCollection('collections').repository.findOne({
filterByTk: parent,
transaction,
})) as CollectionModel;
carry.push(parentModel.get('name'));
await findModelParents(parentModel, carry);
}
return carry;
};
return findModelParents(this);
}
async parentFields(options: Transactionable) {
const { transaction } = options;
return this.db.getCollection('fields').repository.find({
filter: {
collectionName: { $in: await this.findParents({ transaction }) },
},
transaction,
});
}
// sync fields from parents
async syncParentFields(options: Transactionable) {
const { transaction } = options;
const ancestorFields = await this.parentFields({ transaction });
const selfFields = await this.getFields({ transaction });
const inheritedFields = ancestorFields.filter((field: FieldModel) => {
return (
!field.isAssociationField() &&
!selfFields.find((selfField: FieldModel) => selfField.get('name') == field.get('name'))
);
});
for (const inheritedField of inheritedFields) {
await this.createField(lodash.omit(inheritedField.toJSON(), ['key', 'collectionName', 'sort']), {
transaction,
});
}
}
}

View File

@ -67,15 +67,21 @@ export class FieldModel extends MagicAttributeModel {
return (<any>this.constructor).database;
}
isAssociationField() {
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(this.get('type'));
}
async load(loadOptions?: LoadOptions) {
const { skipExist = false } = loadOptions || {};
const collectionName = this.get('collectionName');
if (!this.db.hasCollection(collectionName)) {
return;
}
const collection = this.db.getCollection(collectionName);
const name = this.get('name');
if (skipExist && collection.hasField(name)) {
return collection.getField(name);
}
@ -123,6 +129,7 @@ export class FieldModel extends MagicAttributeModel {
if (!field) {
return;
}
return field.removeFromDb({
transaction: options.transaction,
});

View File

@ -17,6 +17,7 @@ import {
} from './hooks';
import { CollectionModel, FieldModel } from './models';
import { InheritedCollection } from '@nocobase/database';
export class CollectionManagerPlugin extends Plugin {
async beforeLoad() {
@ -56,6 +57,20 @@ export class CollectionManagerPlugin extends Plugin {
// 要在 beforeInitOptions 之前处理
this.app.db.on('fields.beforeCreate', beforeCreateForReverseField(this.app.db));
this.app.db.on('fields.beforeCreate', beforeCreateForChildrenCollection(this.app.db));
this.app.db.on('fields.beforeCreate', async (model, options) => {
const collectionName = model.get('collectionName');
const collection = this.app.db.getCollection(collectionName);
if (!collection) {
return;
}
if (collection.isInherited() && (<InheritedCollection>collection).parentFields().has(model.get('name'))) {
model.set('overriding', true);
}
});
this.app.db.on('fields.beforeCreate', async (model, options) => {
const type = model.get('type');
const fn = beforeInitOptions[type];
@ -66,14 +81,16 @@ export class CollectionManagerPlugin extends Plugin {
this.app.db.on('fields.afterCreate', afterCreateForReverseField(this.app.db));
this.app.db.on('collections.afterCreateWithAssociations', async (model, { context, transaction }) => {
this.app.db.on(
'collections.afterCreateWithAssociations',
async (model: CollectionModel, { context, transaction }) => {
if (context) {
await model.migrate({
isNew: true,
transaction,
});
}
});
},
);
this.app.db.on('fields.afterCreate', async (model: FieldModel, { context, transaction }) => {
if (context) {
@ -123,7 +140,7 @@ export class CollectionManagerPlugin extends Plugin {
// before field remove
this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db));
this.app.db.on('fields.beforeDestroy', async (model, options) => {
this.app.db.on('fields.beforeDestroy', async (model: FieldModel, options) => {
await model.remove(options);
});
@ -131,6 +148,37 @@ export class CollectionManagerPlugin extends Plugin {
await model.remove(options);
});
this.app.db.on('fields.afterDestroy', async (model: FieldModel, options) => {
const { transaction } = options;
const collectionName = model.get('collectionName');
const childCollections = this.db.inheritanceMap.getChildren(collectionName);
const childShouldRemoveField = Array.from(childCollections).filter((item) => {
const parents = Array.from(this.db.inheritanceMap.getParents(item))
.map((parent) => {
const collection = this.db.getCollection(parent);
const field = collection.getField(model.get('name'));
return field;
})
.filter(Boolean);
return parents.length == 0;
});
await this.db.getCollection('fields').repository.destroy({
filter: {
name: model.get('name'),
collectionName: {
$in: childShouldRemoveField,
},
options: {
overriding: true,
},
},
transaction,
});
});
this.app.on('afterLoad', async (app, options) => {
if (options?.method === 'install') {
return;