feat(database): view collection support for add new, update and delete actions (#2119)

* feat: writeableView options in view collection

* refactor: view collection support edit

* refactor: view collection support edit

* refactor: view collection support edit

* refactor: view collection support edit

* test: insert into view with join table

* chore: typo

* chore: package.json

* chore: sql parser

* chore: query interface

* chore: test

* feat: update view collection

* chore: test

* chore: test

* chore: github action pg version

* fix: params in update and delete

* refactor: locale improve

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
ChengLei Shao 2023-07-14 14:49:12 +08:00 committed by GitHub
parent b63012d85a
commit 3510531182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 13564 additions and 11270 deletions

View File

@ -40,7 +40,7 @@ jobs:
- name: Test with Sqlite - name: Test with Sqlite
run: yarn nocobase install -f && yarn test run: yarn nocobase install -f && yarn test
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
DB_DIALECT: sqlite DB_DIALECT: sqlite
DB_STORAGE: /tmp/db.sqlite DB_STORAGE: /tmp/db.sqlite
DB_UNDERSCORED: ${{ matrix.underscored }} DB_UNDERSCORED: ${{ matrix.underscored }}
@ -59,7 +59,7 @@ jobs:
# Label used to access the service container # Label used to access the service container
postgres: postgres:
# Docker Hub image # Docker Hub image
image: postgres:10 image: postgres:11
# Provide the password for postgres # Provide the password for postgres
env: env:
POSTGRES_USER: nocobase POSTGRES_USER: nocobase
@ -82,7 +82,7 @@ jobs:
- name: Test with postgres - name: Test with postgres
run: yarn nocobase install -f && yarn test run: yarn nocobase install -f && yarn test
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
DB_DIALECT: postgres DB_DIALECT: postgres
DB_HOST: postgres DB_HOST: postgres
DB_PORT: 5432 DB_PORT: 5432
@ -120,7 +120,7 @@ jobs:
- name: Test with MySQL - name: Test with MySQL
run: yarn nocobase install -f && yarn test run: yarn nocobase install -f && yarn test
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
DB_DIALECT: mysql DB_DIALECT: mysql
DB_HOST: mysql DB_HOST: mysql
DB_PORT: 3306 DB_PORT: 3306

View File

@ -88,7 +88,7 @@ export const useGanttBlockContext = () => {
export const useGanttBlockProps = () => { export const useGanttBlockProps = () => {
const ctx = useGanttBlockContext(); const ctx = useGanttBlockContext();
const [tasks, setTasks] = useState<any>([]); const [tasks, setTasks] = useState<any>([]);
const { getPrimaryKey, name, template } = useCollection(); const { getPrimaryKey, name, template, writableView } = useCollection();
const { parseAction } = useACLRoleContext(); const { parseAction } = useACLRoleContext();
const primaryKey = getPrimaryKey(); const primaryKey = getPrimaryKey();
const checkPermassion = (record) => { const checkPermassion = (record) => {
@ -96,7 +96,7 @@ export const useGanttBlockProps = () => {
const schema = {}; const schema = {};
const recordPkValue = record?.[primaryKey]; const recordPkValue = record?.[primaryKey];
const params = parseAction(actionPath, { schema, recordPkValue }); const params = parseAction(actionPath, { schema, recordPkValue });
return template === 'view' || !params; return (template === 'view' && !writableView) || !params;
}; };
const onExpanderClick = (task: any) => { const onExpanderClick = (task: any) => {

View File

@ -1,6 +1,6 @@
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react'; import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
import { parse } from '@nocobase/utils/client'; import { parse } from '@nocobase/utils/client';
import { Modal, message } from 'antd'; import { message, Modal } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import get from 'lodash/get'; import get from 'lodash/get';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
@ -17,7 +17,7 @@ import { useRecord } from '../../record-provider';
import { removeNullCondition, useActionContext, useCompile } from '../../schema-component'; import { removeNullCondition, useActionContext, useCompile } from '../../schema-component';
import { BulkEditFormItemValueType } from '../../schema-initializer/components'; import { BulkEditFormItemValueType } from '../../schema-initializer/components';
import { useCurrentUserContext } from '../../user'; import { useCurrentUserContext } from '../../user';
import { useBlockRequestContext, useFilterByTk } from '../BlockProvider'; import { useBlockRequestContext, useFilterByTk, useParamsFromRecord } from '../BlockProvider';
import { useDetailsBlockContext } from '../DetailsBlockProvider'; import { useDetailsBlockContext } from '../DetailsBlockProvider';
import { mergeFilter } from '../SharedFilterProvider'; import { mergeFilter } from '../SharedFilterProvider';
import { TableFieldResource } from '../TableFieldProvider'; import { TableFieldResource } from '../TableFieldProvider';
@ -707,7 +707,7 @@ export const useCustomizeRequestActionProps = () => {
export const useUpdateActionProps = () => { export const useUpdateActionProps = () => {
const form = useForm(); const form = useForm();
const filterByTk = useFilterByTk(); const filterByTk = useFilterByTk();
const { field, resource, __parent } = useBlockRequestContext(); const { field, resource, __parent, service } = useBlockRequestContext();
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
const actionSchema = useFieldSchema(); const actionSchema = useFieldSchema();
const navigate = useNavigate(); const navigate = useNavigate();
@ -718,6 +718,7 @@ export const useUpdateActionProps = () => {
const currentRecord = useRecord(); const currentRecord = useRecord();
const currentUserContext = useCurrentUserContext(); const currentUserContext = useCurrentUserContext();
const currentUser = currentUserContext?.data?.data; const currentUser = currentUserContext?.data?.data;
const data = useParamsFromRecord();
return { return {
async onClick() { async onClick() {
const { const {
@ -742,6 +743,7 @@ export const useUpdateActionProps = () => {
...overwriteValues, ...overwriteValues,
...assignedValues, ...assignedValues,
}, },
...data,
updateAssociationValues, updateAssociationValues,
}); });
actionField.data.loading = false; actionField.data.loading = false;
@ -778,10 +780,12 @@ export const useDestroyActionProps = () => {
const filterByTk = useFilterByTk(); const filterByTk = useFilterByTk();
const { resource, service, block, __parent } = useBlockRequestContext(); const { resource, service, block, __parent } = useBlockRequestContext();
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
const data = useParamsFromRecord();
return { return {
async onClick() { async onClick() {
await resource.destroy({ await resource.destroy({
filterByTk, filterByTk,
...data,
}); });
const { count = 0, page = 0, pageSize = 0 } = service?.data?.meta || {}; const { count = 0, page = 0, pageSize = 0 } = service?.data?.meta || {};

View File

@ -9,9 +9,9 @@ import { useTranslation } from 'react-i18next';
import { useRequest } from '../../api-client'; import { useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider'; import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import { useCancelAction } from '../action-hooks'; import { useCancelAction } from '../action-hooks';
import { useCollectionManager } from '../hooks'; import { useCollectionManager } from '../hooks';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components'; import * as components from './components';
import { TemplateSummay } from './components/TemplateSummay'; import { TemplateSummay } from './components/TemplateSummay';
import { templateOptions } from './templates'; import { templateOptions } from './templates';
@ -97,7 +97,10 @@ const getSchema = (schema, category, compile): ISchema => {
}; };
}; };
const useDefaultCollectionFields = (values) => { const getDefaultCollectionFields = (values) => {
if (values?.template === 'view') {
return values.fields;
}
const defaults = values.fields ? [...values.fields] : []; const defaults = values.fields ? [...values.fields] : [];
const { autoGenId = true, createdAt = true, createdBy = true, updatedAt = true, updatedBy = true } = values; const { autoGenId = true, createdAt = true, createdBy = true, updatedAt = true, updatedBy = true } = values;
if (autoGenId) { if (autoGenId) {
@ -207,9 +210,8 @@ const useCreateCollection = (schema?: any) => {
if (schema?.events?.beforeSubmit) { if (schema?.events?.beforeSubmit) {
schema.events.beforeSubmit(values); schema.events.beforeSubmit(values);
} }
const fields = values?.template !== 'view' ? useDefaultCollectionFields(values) : values.fields; const fields = getDefaultCollectionFields(values);
if (values.autoCreateReverseField) { if (!values.autoCreateReverseField) {
} else {
delete values.reverseField; delete values.reverseField;
} }
delete values.id; delete values.id;

View File

@ -1,7 +1,7 @@
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
import { PreviewFields } from './components/PreviewFields'; import { PreviewFields } from './components/PreviewFields';
import { PreviewTable } from './components/PreviewTable'; import { PreviewTable } from './components/PreviewTable';
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
export const view: ICollectionTemplate = { export const view: ICollectionTemplate = {
name: 'view', name: 'view',
@ -91,6 +91,13 @@ export const view: ICollectionTemplate = {
}, },
}, },
}, },
writableView: {
type: 'boolean',
'x-content': '{{t("Allow add new, update and delete actions")}}',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
default: false,
},
sources: { sources: {
type: 'array', type: 'array',
title: '{{ t("Source collections") }}', title: '{{ t("Source collections") }}',
@ -128,6 +135,7 @@ export const view: ICollectionTemplate = {
}, },
}, },
}, },
...getConfigurableProperties('category'), ...getConfigurableProperties('category'),
}, },
}; };

View File

@ -29,6 +29,7 @@ export interface CollectionOptions {
inherits?: string[]; inherits?: string[];
tree?: string; tree?: string;
template?: string; template?: string;
writableView?: boolean;
} }
export interface ICollectionProviderProps { export interface ICollectionProviderProps {

View File

@ -707,4 +707,5 @@ export default {
"Current form": "Current form", "Current form": "Current form",
"Current object":"Current object", "Current object":"Current object",
"Linkage with form fields":"Linkage with form fields", "Linkage with form fields":"Linkage with form fields",
"Allow add new, update and delete actions":"Allow add new, update and delete actions"
}; };

View File

@ -618,4 +618,5 @@ export default {
"Current form":"現在のフォーム", "Current form":"現在のフォーム",
"Current object":"現在のオブジェクト", "Current object":"現在のオブジェクト",
"Linkage with form fields":"フォームデータから連動", "Linkage with form fields":"フォームデータから連動",
"Allow add new, update and delete actions":"削除変更操作の許可"
} }

View File

@ -792,4 +792,5 @@ export default {
"Copy into the form and continue to fill in": "复制到表单并继续填写", "Copy into the form and continue to fill in": "复制到表单并继续填写",
"Linkage with form fields":"从表单字段联动", "Linkage with form fields":"从表单字段联动",
"Failed to load plugin": "插件加载失败", "Failed to load plugin": "插件加载失败",
"Allow add new, update and delete actions":"允许增删改操作"
} }

View File

@ -74,9 +74,9 @@ export const CalendarActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -23,9 +23,9 @@ export const CalendarFormActionInitializers = {
type: 'primary', type: 'primary',
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -36,9 +36,9 @@ export const CalendarFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -49,9 +49,9 @@ export const CalendarFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -144,9 +144,9 @@ export const CalendarFormActionInitializers = {
useProps: '{{ useCustomizeUpdateActionProps }}', useProps: '{{ useCustomizeUpdateActionProps }}',
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -173,7 +173,7 @@ export const CalendarFormActionInitializers = {
}, },
visible: () => { visible: () => {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -1,4 +1,3 @@
import { useFieldSchema, Schema } from '@formily/react';
import { useCollection } from '../../collection-manager'; import { useCollection } from '../../collection-manager';
// 表单的操作配置 // 表单的操作配置
@ -34,7 +33,7 @@ export const GridCardActionInitializers = {
}, },
visible: () => { visible: () => {
const collection = useCollection(); const collection = useCollection();
return collection.template !== 'view' && collection.template !== 'file'; return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView;
}, },
}, },
{ {
@ -166,7 +165,7 @@ export const GridCardItemActionInitializers = {
}, },
visible: () => { visible: () => {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -264,7 +263,7 @@ export const GridCardItemActionInitializers = {
}, },
visible: () => { visible: () => {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -290,7 +289,7 @@ export const GridCardItemActionInitializers = {
}, },
visible: () => { visible: () => {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -30,9 +30,9 @@ export const KanbanActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -1,4 +1,3 @@
import { useFieldSchema, Schema } from '@formily/react';
import { useCollection } from '../../collection-manager'; import { useCollection } from '../../collection-manager';
// 表单的操作配置 // 表单的操作配置
@ -32,9 +31,9 @@ export const ListActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return collection.template !== 'view' && collection.template !== 'file'; return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file';
}, },
}, },
{ {
@ -164,9 +163,9 @@ export const ListItemActionInitializers = {
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
'x-align': 'left', 'x-align': 'left',
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -262,9 +261,9 @@ export const ListItemActionInitializers = {
useProps: '{{ useCustomizeUpdateActionProps }}', useProps: '{{ useCustomizeUpdateActionProps }}',
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -288,9 +287,9 @@ export const ListItemActionInitializers = {
useProps: '{{ useCustomizeRequestActionProps }}', useProps: '{{ useCustomizeRequestActionProps }}',
}, },
}, },
visible: () => { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return (collection as any).template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -2,7 +2,7 @@ import { useCollection } from '../..';
const useVisibleCollection = () => { const useVisibleCollection = () => {
const collection = useCollection(); const collection = useCollection();
return collection.template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}; };
// 表单的操作配置 // 表单的操作配置
export const ReadPrettyFormActionInitializers = { export const ReadPrettyFormActionInitializers = {

View File

@ -1,7 +1,7 @@
import { Schema, useFieldSchema } from '@formily/react'; import { Schema, useFieldSchema } from '@formily/react';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SchemaInitializer, useCollection, useCollectionManager, SchemaInitializerItemOptions } from '../..'; import { SchemaInitializer, SchemaInitializerItemOptions, useCollection, useCollectionManager } from '../..';
import { gridRowColWrap } from '../utils'; import { gridRowColWrap } from '../utils';
const recursiveParent = (schema: Schema) => { const recursiveParent = (schema: Schema) => {
@ -170,7 +170,7 @@ export const RecordBlockInitializers = (props: any) => {
const hasFormChildCollection = formChildrenCollections?.length > 0; const hasFormChildCollection = formChildrenCollections?.length > 0;
const detailChildrenCollections = getChildrenCollections(collection.name, true); const detailChildrenCollections = getChildrenCollections(collection.name, true);
const hasDetailChildCollection = detailChildrenCollections?.length > 0; const hasDetailChildCollection = detailChildrenCollections?.length > 0;
const modifyFlag = (collection as any).template !== 'view'; const modifyFlag = (collection as any).template !== 'view' || collection?.writableView;
return ( return (
<SchemaInitializer.Button <SchemaInitializer.Button
wrap={gridRowColWrap} wrap={gridRowColWrap}

View File

@ -53,7 +53,6 @@ export const TableActionColumnInitializers = (props: any) => {
const { t } = useTranslation(); const { t } = useTranslation();
const collection = useCollection(); const collection = useCollection();
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props'] || {}; const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props'] || {};
const modifyFlag = (collection as any).template !== 'view';
return ( return (
<SchemaInitializer.Button <SchemaInitializer.Button
insertPosition={'beforeEnd'} insertPosition={'beforeEnd'}
@ -102,11 +101,11 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => { visible: () => {
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
modifyFlag && { {
type: 'item', type: 'item',
title: t('Delete'), title: t('Delete'),
component: 'DestroyActionInitializer', component: 'DestroyActionInitializer',
@ -115,6 +114,9 @@ export const TableActionColumnInitializers = (props: any) => {
'x-action': 'destroy', 'x-action': 'destroy',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
return collection.template !== 'view' || collection?.writableView;
},
}, },
collection.tree && collection.tree &&
treeTable !== false && { treeTable !== false && {
@ -137,7 +139,7 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => { visible: () => {
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
], ],
@ -223,7 +225,7 @@ export const TableActionColumnInitializers = (props: any) => {
}, },
}, },
visible: () => { visible: () => {
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -248,7 +250,7 @@ export const TableActionColumnInitializers = (props: any) => {
}, },
}, },
visible: () => { visible: () => {
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -34,7 +34,7 @@ export const TableActionInitializers = {
}, },
visible: function useVisible() { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return collection.template !== 'view' && collection.template !== 'file'; return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView;
}, },
}, },
{ {
@ -47,7 +47,7 @@ export const TableActionInitializers = {
}, },
visible: function useVisible() { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
{ {
@ -78,7 +78,7 @@ export const TableActionInitializers = {
type: 'divider', type: 'divider',
visible: function useVisible() { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
// { // {
@ -159,7 +159,7 @@ export const TableActionInitializers = {
], ],
visible: function useVisible() { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return (collection as any).template !== 'view'; return collection.template !== 'view' || collection?.writableView;
}, },
}, },
], ],

View File

@ -1,6 +1,5 @@
import React from 'react';
import { TableOutlined } from '@ant-design/icons'; import { TableOutlined } from '@ant-design/icons';
import React from 'react';
import { useCollectionManager } from '../../collection-manager'; import { useCollectionManager } from '../../collection-manager';
import { createDetailsBlockSchema } from '../utils'; import { createDetailsBlockSchema } from '../utils';
import { DataBlockInitializer } from './DataBlockInitializer'; import { DataBlockInitializer } from './DataBlockInitializer';
@ -18,7 +17,8 @@ export const DetailsBlockInitializer = (props) => {
const schema = createDetailsBlockSchema({ const schema = createDetailsBlockSchema({
collection: item.name, collection: item.name,
rowKey: collection.filterTargetKey || 'id', rowKey: collection.filterTargetKey || 'id',
actionInitializers: collection.template !== 'view' && 'DetailsActionInitializers', actionInitializers:
(collection.template !== 'view' || collection?.writableView) && 'DetailsActionInitializers',
}); });
insert(schema); insert(schema);
}} }}

View File

@ -860,7 +860,7 @@ export const useCollectionDataSourceItems = (componentName) => {
const fields = getCollectionFields(item.name); const fields = getCollectionFields(item.name);
if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) { if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) {
return false; return false;
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view') { } else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view' && !item.writableView) {
return false; return false;
} else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) { } else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) {
return false; return false;

View File

@ -15,6 +15,7 @@
"excel-date-to-js": "^1.1.5", "excel-date-to-js": "^1.1.5",
"flat": "^5.0.2", "flat": "^5.0.2",
"glob": "^7.1.6", "glob": "^7.1.6",
"graphlib": "^2.1.8",
"mathjs": "^10.6.1", "mathjs": "^10.6.1",
"semver": "^7.3.7", "semver": "^7.3.7",
"sequelize": "^6.26.0", "sequelize": "^6.26.0",

View File

@ -1,6 +1,6 @@
import { Database } from '../database'; import { Database } from '../database';
import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository';
import { mockDatabase } from './'; import { mockDatabase } from './';
import { AdjacencyListRepository } from '../tree-repository/adjacency-list-repository';
describe('tree test', function () { describe('tree test', function () {
let db: Database; let db: Database;

View File

@ -1,6 +1,112 @@
import { Database, mockDatabase } from '@nocobase/database';
import { uid } from '@nocobase/utils'; import { uid } from '@nocobase/utils';
import { Database, mockDatabase } from '../../index';
import { ViewCollection } from '../../view-collection'; import { ViewCollection } from '../../view-collection';
import pgOnly from '../inhertits/helper';
pgOnly()('', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
tablePrefix: '',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should update view collection', async () => {
const UserCollection = db.collection({
name: 'users',
timestamps: false,
fields: [
{
name: 'name',
type: 'string',
interface: { type: 'string', title: '姓名' },
},
{
name: 'group',
type: 'belongsTo',
foreignKey: 'group_id',
},
],
});
const GroupCollection = db.collection({
name: 'groups',
timestamps: false,
fields: [
{
name: 'name',
type: 'string',
interface: { type: 'string', title: '分组名' },
},
],
});
await db.sync();
const viewName = `users_with_group`;
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
await db.sequelize.query(dropSQL);
const viewSQL = `CREATE VIEW ${viewName} AS SELECT users.id AS user_id, users.name AS user_name, groups.name AS group_name FROM ${UserCollection.quotedTableName()} AS users INNER JOIN ${GroupCollection.quotedTableName()} AS groups ON users.group_id = groups.id`;
await db.sequelize.query(viewSQL);
const UsersWithGroup = db.collection({
name: viewName,
view: true,
schema: db.inDialect('postgres') ? 'public' : undefined,
writableView: true,
fields: [
{ name: 'user_id', type: 'bigInt' },
{ name: 'user_name', type: 'string', source: 'users.name' },
{ name: 'group_name', type: 'string', source: 'groups.name' },
],
});
// create INSTEAD OF INSERT trigger
await db.sequelize.query(`
CREATE OR REPLACE FUNCTION insert_users_with_group() RETURNS TRIGGER AS $$
DECLARE
new_group_id BIGINT;
BEGIN
-- groups ID
INSERT INTO ${GroupCollection.quotedTableName()} (name) VALUES (NEW.group_name) RETURNING id INTO new_group_id;
-- users 使 groups ID group_id
INSERT INTO ${UserCollection.quotedTableName()} (name, group_id) VALUES (NEW.user_name, new_group_id) RETURNING id INTO NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await db.sequelize.query(`
CREATE TRIGGER insert_users_with_group_trigger
INSTEAD OF INSERT ON ${UsersWithGroup.quotedTableName()}
FOR EACH ROW EXECUTE FUNCTION insert_users_with_group();
`);
const returned = await UsersWithGroup.repository.create({
values: {
user_name: 'u1',
group_name: 'g1',
},
});
expect(returned.get('user_name')).toBe('u1');
expect(returned.get('group_name')).toBe('g1');
const records = await UsersWithGroup.repository.find();
const firstRecord = records[0].toJSON();
expect(firstRecord.user_name).toBe('u1');
expect(firstRecord.group_name).toBe('g1');
});
});
describe('create view', () => { describe('create view', () => {
let db: Database; let db: Database;
@ -9,6 +115,7 @@ describe('create view', () => {
db = mockDatabase({ db = mockDatabase({
tablePrefix: '', tablePrefix: '',
}); });
await db.clean({ drop: true }); await db.clean({ drop: true });
}); });
@ -119,7 +226,6 @@ describe('create view', () => {
}, },
}); });
console.log(fooData);
expect(fooData.get('name')).toBe('foo'); expect(fooData.get('name')).toBe('foo');
expect(fooData.get('age')).toBe(18); expect(fooData.get('age')).toBe(18);
}); });

View File

@ -1,6 +1,5 @@
import { Database, mockDatabase } from '@nocobase/database'; import { Database, mockDatabase } from '@nocobase/database';
import { ViewFieldInference } from '../../view/view-inference'; import { ViewFieldInference } from '../../view/view-inference';
import { uid } from '@nocobase/utils';
describe('view inference', function () { describe('view inference', function () {
let db: Database; let db: Database;

View File

@ -12,9 +12,9 @@ import {
import { Database } from './database'; import { Database } from './database';
import { BelongsToField, Field, FieldOptions, HasManyField } from './fields'; import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
import { Model } from './model'; import { Model } from './model';
import { AdjacencyListRepository } from './repositories/tree-repository/adjacency-list-repository';
import { Repository } from './repository'; import { Repository } from './repository';
import { checkIdentifier, md5, snakeCase } from './utils'; import { checkIdentifier, md5, snakeCase } from './utils';
import { AdjacencyListRepository } from './tree-repository/adjacency-list-repository';
export type RepositoryType = typeof Repository; export type RepositoryType = typeof Repository;
@ -44,6 +44,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
tableName?: string; tableName?: string;
inherits?: string[] | string; inherits?: string[] | string;
viewName?: string; viewName?: string;
writableView?: boolean;
filterTargetKey?: string; filterTargetKey?: string;
fields?: FieldOptions[]; fields?: FieldOptions[];
@ -272,7 +273,7 @@ export class Collection<
`source field "${sourceFieldName}" not found for field "${name}" at collection "${this.name}"`, `source field "${sourceFieldName}" not found for field "${name}" at collection "${this.name}"`,
); );
} else { } else {
options = { ...sourceField.options, ...options }; options = { ...lodash.omit(sourceField.options, 'name'), ...options };
} }
} }

View File

@ -1,3 +1,4 @@
import { Logger } from '@nocobase/logger';
import { applyMixins, AsyncEmitter, requireModule } from '@nocobase/utils'; import { applyMixins, AsyncEmitter, requireModule } from '@nocobase/utils';
import merge from 'deepmerge'; import merge from 'deepmerge';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@ -19,7 +20,9 @@ import {
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
import { CollectionGroupManager } from './collection-group-manager';
import { ImporterReader, ImportFileExtension } from './collection-importer'; import { ImporterReader, ImportFileExtension } from './collection-importer';
import DatabaseUtils from './database-utils';
import ReferencesMap from './features/ReferencesMap'; import ReferencesMap from './features/ReferencesMap';
import { referentialIntegrityCheck } from './features/referential-integrity-check'; import { referentialIntegrityCheck } from './features/referential-integrity-check';
import { ArrayFieldRepository } from './field-repository/array-field-repository'; import { ArrayFieldRepository } from './field-repository/array-field-repository';
@ -27,10 +30,13 @@ import * as FieldTypes from './fields';
import { Field, FieldContext, RelationField } from './fields'; import { Field, FieldContext, RelationField } from './fields';
import { InheritedCollection } from './inherited-collection'; import { InheritedCollection } from './inherited-collection';
import InheritanceMap from './inherited-map'; import InheritanceMap from './inherited-map';
import { registerBuiltInListeners } from './listeners';
import { MigrationItem, Migrations } from './migration'; import { MigrationItem, Migrations } from './migration';
import { Model } from './model'; import { Model } from './model';
import { ModelHook } from './model-hook'; import { ModelHook } from './model-hook';
import extendOperators from './operators'; import extendOperators from './operators';
import QueryInterface from './query-interface/query-interface';
import buildQueryInterface from './query-interface/query-interface-builder';
import { RelationRepository } from './relation-repository/relation-repository'; import { RelationRepository } from './relation-repository/relation-repository';
import { Repository } from './repository'; import { Repository } from './repository';
import { import {
@ -61,13 +67,6 @@ import {
ValidateListener, ValidateListener,
} from './types'; } from './types';
import { patchSequelizeQueryInterface, snakeCase } from './utils'; import { patchSequelizeQueryInterface, snakeCase } from './utils';
import { Logger } from '@nocobase/logger';
import { CollectionGroupManager } from './collection-group-manager';
import DatabaseUtils from './database-utils';
import { registerBuiltInListeners } from './listeners';
import QueryInterface from './query-interface/query-interface';
import buildQueryInterface from './query-interface/query-interface-builder';
import { BaseValueParser, registerFieldValueParsers } from './value-parsers'; import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
import { ViewCollection } from './view-collection'; import { ViewCollection } from './view-collection';

View File

@ -1,7 +1,7 @@
import QueryInterface from './query-interface';
import { Collection } from '../collection';
import { Transactionable } from 'sequelize'; import { Transactionable } from 'sequelize';
import { Collection } from '../collection';
import sqlParser from '../sql-parser'; import sqlParser from '../sql-parser';
import QueryInterface from './query-interface';
export default class MysqlQueryInterface extends QueryInterface { export default class MysqlQueryInterface extends QueryInterface {
constructor(db) { constructor(db) {
@ -39,13 +39,7 @@ export default class MysqlQueryInterface extends QueryInterface {
}; };
}> { }> {
try { try {
const viewDefinition = await this.db.sequelize.query(`SHOW CREATE VIEW ${options.viewName}`, { type: 'SELECT' }); const { ast } = this.parseSQL(await this.viewDef(options.viewName));
const createView = viewDefinition[0]['Create View'];
const regex = /(?<=AS\s)([\s\S]*)/i;
const match = createView.match(regex);
const sql = match[0];
const { ast } = sqlParser.parse(sql);
const columns = ast.columns; const columns = ast.columns;
@ -69,4 +63,17 @@ export default class MysqlQueryInterface extends QueryInterface {
return {}; return {};
} }
} }
parseSQL(sql: string): any {
return sqlParser.parse(sql);
}
async viewDef(viewName: string): Promise<string> {
const viewDefinition = await this.db.sequelize.query(`SHOW CREATE VIEW ${viewName}`, { type: 'SELECT' });
const createView = viewDefinition[0]['Create View'];
const regex = /(?<=AS\s)([\s\S]*)/i;
const match = createView.match(regex);
const sql = match[0];
return sql;
}
} }

View File

@ -1,6 +1,7 @@
import QueryInterface from './query-interface'; import lodash from 'lodash';
import { Collection } from '../collection'; import { Collection } from '../collection';
import sqlParser from '../sql-parser/postgres'; import sqlParser from '../sql-parser/postgres';
import QueryInterface from './query-interface';
export default class PostgresQueryInterface extends QueryInterface { export default class PostgresQueryInterface extends QueryInterface {
constructor(db) { constructor(db) {
@ -33,6 +34,23 @@ export default class PostgresQueryInterface extends QueryInterface {
return await this.db.sequelize.query(sql, { type: 'SELECT' }); return await this.db.sequelize.query(sql, { type: 'SELECT' });
} }
async viewDef(viewName: string) {
const [schema, name] = viewName.split('.');
const viewDefQuery = await this.db.sequelize.query(
`
select pg_get_viewdef(format('%I.%I', '${schema}', '${name}')::regclass, true) as definition
`,
{ type: 'SELECT' },
);
return lodash.trim(viewDefQuery[0]['definition']);
}
parseSQL(sql: string): any {
return sqlParser.parse(sql);
}
async viewColumnUsage(options): Promise<{ async viewColumnUsage(options): Promise<{
[view_column_name: string]: { [view_column_name: string]: {
column_name: string; column_name: string;
@ -54,16 +72,10 @@ export default class PostgresQueryInterface extends QueryInterface {
table_schema: string; table_schema: string;
}>; }>;
const viewDefQuery = await this.db.sequelize.query( const def = await this.viewDef(`${schema}.${viewName}`);
`
select pg_get_viewdef(format('%I.%I', '${schema}', '${viewName}')::regclass, true) as definition
`,
{ type: 'SELECT' },
);
const def = viewDefQuery[0]['definition'];
try { try {
const { ast } = sqlParser.parse(def); const { ast } = this.parseSQL(def);
const columns = ast[0].columns; const columns = ast[0].columns;
const usages = columns const usages = columns
@ -96,7 +108,7 @@ export default class PostgresQueryInterface extends QueryInterface {
return Object.fromEntries(usages); return Object.fromEntries(usages);
} catch (e) { } catch (e) {
this.db.logger.warn(e); console.log(e);
return {}; return {};
} }
} }

View File

@ -1,6 +1,6 @@
import Database from '../database';
import { Collection } from '../collection';
import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequelize'; import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequelize';
import { Collection } from '../collection';
import Database from '../database';
export default abstract class QueryInterface { export default abstract class QueryInterface {
sequelizeQueryInterface: SequelizeQueryInterface; sequelizeQueryInterface: SequelizeQueryInterface;
@ -13,6 +13,8 @@ export default abstract class QueryInterface {
abstract listViews(); abstract listViews();
abstract viewDef(viewName: string): Promise<string>;
abstract viewColumnUsage(options: { viewName: string; schema?: string }): Promise<{ abstract viewColumnUsage(options: { viewName: string; schema?: string }): Promise<{
[view_column_name: string]: { [view_column_name: string]: {
column_name: string; column_name: string;
@ -21,6 +23,8 @@ export default abstract class QueryInterface {
}; };
}>; }>;
abstract parseSQL(sql: string): any;
async dropAll(options) { async dropAll(options) {
if (options.drop !== true) return; if (options.drop !== true) return;

View File

@ -1,6 +1,6 @@
import QueryInterface from './query-interface';
import { Collection } from '../collection'; import { Collection } from '../collection';
import sqlParser from '../sql-parser'; import sqlParser from '../sql-parser';
import QueryInterface from './query-interface';
export default class SqliteQueryInterface extends QueryInterface { export default class SqliteQueryInterface extends QueryInterface {
constructor(db) { constructor(db) {
@ -41,19 +41,7 @@ export default class SqliteQueryInterface extends QueryInterface {
}; };
}> { }> {
try { try {
const viewDefinition = await this.db.sequelize.query( const { ast } = this.parseSQL(await this.viewDef(options.viewName));
`SELECT sql FROM sqlite_master WHERE name = '${options.viewName}' AND type = 'view'`,
{
type: 'SELECT',
},
);
const createView = viewDefinition[0]['sql'];
const regex = /(?<=AS\s)([\s\S]*)/i;
const match = createView.match(regex);
const sql = match[0];
const { ast } = sqlParser.parse(sql);
const columns = ast.columns; const columns = ast.columns;
@ -76,4 +64,26 @@ export default class SqliteQueryInterface extends QueryInterface {
return {}; return {};
} }
} }
parseSQL(sql: string): any {
return sqlParser.parse(sql);
}
async viewDef(viewName: string): Promise<string> {
const viewDefinition = await this.db.sequelize.query(
`SELECT sql
FROM sqlite_master
WHERE name = '${viewName}' AND type = 'view'`,
{
type: 'SELECT',
},
);
const createView = viewDefinition[0]['sql'];
const regex = /(?<=AS\s)([\s\S]*)/i;
const match = createView.match(regex);
const sql = match[0];
return sql;
}
} }

View File

@ -1,5 +1,5 @@
import { FindOptions, Repository } from '../repository';
import lodash from 'lodash'; import lodash from 'lodash';
import { FindOptions, Repository } from '../../repository';
export class AdjacencyListRepository extends Repository { export class AdjacencyListRepository extends Repository {
async update(options): Promise<any> { async update(options): Promise<any> {
@ -143,15 +143,13 @@ export class AdjacencyListRepository extends Repository {
const q = queryInterface.quoteIdentifier.bind(queryInterface); const q = queryInterface.quoteIdentifier.bind(queryInterface);
return ` return `
WITH RECURSIVE cte AS ( WITH RECURSIVE cte AS (SELECT ${q(primaryKey)}, ${q(foreignKeyField)}, 1 AS level
SELECT ${q(primaryKey)}, ${q(foreignKeyField)}, 1 AS level
FROM ${collection.quotedTableName()} FROM ${collection.quotedTableName()}
WHERE ${q(foreignKeyField)} IN (${rootIds.join(',')}) WHERE ${q(foreignKeyField)} IN (${rootIds.join(',')})
UNION ALL UNION ALL
SELECT t.${q(primaryKey)}, t.${q(foreignKeyField)}, cte.level + 1 AS level SELECT t.${q(primaryKey)}, t.${q(foreignKeyField)}, cte.level + 1 AS level
FROM ${collection.quotedTableName()} t FROM ${collection.quotedTableName()} t
JOIN cte ON t.${q(foreignKeyField)} = cte.${q(primaryKey)} JOIN cte ON t.${q(foreignKeyField)} = cte.${q(primaryKey)})
)
SELECT ${q(primaryKey)}, ${q(foreignKeyField)} as ${q(foreignKey)}, level SELECT ${q(primaryKey)}, ${q(foreignKeyField)} as ${q(foreignKey)}, level
FROM cte FROM cte
`; `;

View File

@ -0,0 +1,3 @@
import { Repository } from '../repository';
export class ViewRepository extends Repository {}

File diff suppressed because it is too large Load Diff

View File

@ -8,13 +8,13 @@ export class ViewCollection extends Collection {
super(options, context); super(options, context);
} }
isView() {
return true;
}
protected sequelizeModelOptions(): any { protected sequelizeModelOptions(): any {
const modelOptions = super.sequelizeModelOptions(); const modelOptions = super.sequelizeModelOptions();
modelOptions.tableName = this.options.viewName || this.options.name; modelOptions.tableName = this.options.viewName || this.options.name;
return modelOptions; return modelOptions;
} }
isView() {
return true;
}
} }

View File

@ -1,6 +1,6 @@
import { isArray } from 'mathjs';
import Database from '../database'; import Database from '../database';
import FieldTypeMap from './field-type-map'; import FieldTypeMap from './field-type-map';
import { isArray } from 'mathjs';
type InferredField = { type InferredField = {
name: string; name: string;
@ -31,6 +31,7 @@ export class ViewFieldInference {
}); });
const rawFields = []; const rawFields = [];
for (const [name, column] of Object.entries(columns)) { for (const [name, column] of Object.entries(columns)) {
const inferResult: any = { name }; const inferResult: any = { name };

View File

@ -1,7 +1,7 @@
import { MockServer } from '@nocobase/test';
import { createApp } from '../index';
import { uid } from '@nocobase/utils';
import { Database, Repository } from '@nocobase/database'; import { Database, Repository } from '@nocobase/database';
import { MockServer } from '@nocobase/test';
import { uid } from '@nocobase/utils';
import { createApp } from '../index';
describe('view collection', () => { describe('view collection', () => {
let app: MockServer; let app: MockServer;

View File

@ -1,7 +1,7 @@
import Database, { Repository, ViewCollection, ViewFieldInference } from '@nocobase/database'; import Database, { Repository, ViewCollection, ViewFieldInference } from '@nocobase/database';
import Application from '@nocobase/server'; import Application from '@nocobase/server';
import { createApp } from '../index';
import { uid } from '@nocobase/utils'; import { uid } from '@nocobase/utils';
import { createApp } from '../index';
describe('view collection', function () { describe('view collection', function () {
let db: Database; let db: Database;

View File

@ -222,12 +222,7 @@ export class CollectionManagerPlugin extends Plugin {
} catch (error) { } catch (error) {
this.app.logger.warn(error); this.app.logger.warn(error);
await this.app.db.sync(); await this.app.db.sync();
try {
await this.app.db.getRepository<CollectionRepository>('collections').load(); await this.app.db.getRepository<CollectionRepository>('collections').load();
} catch (error) {
throw error;
}
} }
} }
}); });
@ -287,7 +282,7 @@ export class CollectionManagerPlugin extends Plugin {
const newOptions = {}; const newOptions = {};
// write original field options // write original field options
lodash.merge(newOptions, collectionField.options); lodash.merge(newOptions, lodash.omit(collectionField.options, 'name'));
// merge with current field options // merge with current field options
lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => { lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => {

View File

@ -20,7 +20,7 @@ export const ImportInitializerProvider = (props: any) => {
}, },
visible: function useVisible() { visible: function useVisible() {
const collection = useCollection(); const collection = useCollection();
return collection.template !== 'view' && collection.template !== 'file'; return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file';
}, },
}); });
return props.children; return props.children;

View File

@ -5744,7 +5744,7 @@
"@remix-run/router@1.7.1": "@remix-run/router@1.7.1":
version "1.7.1" version "1.7.1"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498" resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498"
integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ== integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==
"@restart/hooks@^0.4.7": "@restart/hooks@^0.4.7":
@ -24891,7 +24891,7 @@ react-router-dom@6.3.0, react-router-dom@^6.11.2:
react-router@6.14.1, react-router@6.3.0, react-router@^6.11.2: react-router@6.14.1, react-router@6.3.0, react-router@^6.11.2:
version "6.14.1" version "6.14.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810" resolved "https://registry.npmjs.org/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810"
integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g== integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==
dependencies: dependencies:
"@remix-run/router" "1.7.1" "@remix-run/router" "1.7.1"