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:
parent
b63012d85a
commit
3510531182
8
.github/workflows/nocobase-test-backend.yml
vendored
8
.github/workflows/nocobase-test-backend.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
- name: Test with Sqlite
|
||||
run: yarn nocobase install -f && yarn test
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
DB_DIALECT: sqlite
|
||||
DB_STORAGE: /tmp/db.sqlite
|
||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||
@ -59,7 +59,7 @@ jobs:
|
||||
# Label used to access the service container
|
||||
postgres:
|
||||
# Docker Hub image
|
||||
image: postgres:10
|
||||
image: postgres:11
|
||||
# Provide the password for postgres
|
||||
env:
|
||||
POSTGRES_USER: nocobase
|
||||
@ -82,7 +82,7 @@ jobs:
|
||||
- name: Test with postgres
|
||||
run: yarn nocobase install -f && yarn test
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
DB_DIALECT: postgres
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@ -120,7 +120,7 @@ jobs:
|
||||
- name: Test with MySQL
|
||||
run: yarn nocobase install -f && yarn test
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
DB_DIALECT: mysql
|
||||
DB_HOST: mysql
|
||||
DB_PORT: 3306
|
||||
|
@ -88,7 +88,7 @@ export const useGanttBlockContext = () => {
|
||||
export const useGanttBlockProps = () => {
|
||||
const ctx = useGanttBlockContext();
|
||||
const [tasks, setTasks] = useState<any>([]);
|
||||
const { getPrimaryKey, name, template } = useCollection();
|
||||
const { getPrimaryKey, name, template, writableView } = useCollection();
|
||||
const { parseAction } = useACLRoleContext();
|
||||
const primaryKey = getPrimaryKey();
|
||||
const checkPermassion = (record) => {
|
||||
@ -96,7 +96,7 @@ export const useGanttBlockProps = () => {
|
||||
const schema = {};
|
||||
const recordPkValue = record?.[primaryKey];
|
||||
const params = parseAction(actionPath, { schema, recordPkValue });
|
||||
return template === 'view' || !params;
|
||||
return (template === 'view' && !writableView) || !params;
|
||||
};
|
||||
|
||||
const onExpanderClick = (task: any) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
|
||||
import { parse } from '@nocobase/utils/client';
|
||||
import { Modal, message } from 'antd';
|
||||
import { message, Modal } from 'antd';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import get from 'lodash/get';
|
||||
import omit from 'lodash/omit';
|
||||
@ -17,7 +17,7 @@ import { useRecord } from '../../record-provider';
|
||||
import { removeNullCondition, useActionContext, useCompile } from '../../schema-component';
|
||||
import { BulkEditFormItemValueType } from '../../schema-initializer/components';
|
||||
import { useCurrentUserContext } from '../../user';
|
||||
import { useBlockRequestContext, useFilterByTk } from '../BlockProvider';
|
||||
import { useBlockRequestContext, useFilterByTk, useParamsFromRecord } from '../BlockProvider';
|
||||
import { useDetailsBlockContext } from '../DetailsBlockProvider';
|
||||
import { mergeFilter } from '../SharedFilterProvider';
|
||||
import { TableFieldResource } from '../TableFieldProvider';
|
||||
@ -707,7 +707,7 @@ export const useCustomizeRequestActionProps = () => {
|
||||
export const useUpdateActionProps = () => {
|
||||
const form = useForm();
|
||||
const filterByTk = useFilterByTk();
|
||||
const { field, resource, __parent } = useBlockRequestContext();
|
||||
const { field, resource, __parent, service } = useBlockRequestContext();
|
||||
const { setVisible } = useActionContext();
|
||||
const actionSchema = useFieldSchema();
|
||||
const navigate = useNavigate();
|
||||
@ -718,6 +718,7 @@ export const useUpdateActionProps = () => {
|
||||
const currentRecord = useRecord();
|
||||
const currentUserContext = useCurrentUserContext();
|
||||
const currentUser = currentUserContext?.data?.data;
|
||||
const data = useParamsFromRecord();
|
||||
return {
|
||||
async onClick() {
|
||||
const {
|
||||
@ -742,6 +743,7 @@ export const useUpdateActionProps = () => {
|
||||
...overwriteValues,
|
||||
...assignedValues,
|
||||
},
|
||||
...data,
|
||||
updateAssociationValues,
|
||||
});
|
||||
actionField.data.loading = false;
|
||||
@ -778,10 +780,12 @@ export const useDestroyActionProps = () => {
|
||||
const filterByTk = useFilterByTk();
|
||||
const { resource, service, block, __parent } = useBlockRequestContext();
|
||||
const { setVisible } = useActionContext();
|
||||
const data = useParamsFromRecord();
|
||||
return {
|
||||
async onClick() {
|
||||
await resource.destroy({
|
||||
filterByTk,
|
||||
...data,
|
||||
});
|
||||
|
||||
const { count = 0, page = 0, pageSize = 0 } = service?.data?.meta || {};
|
||||
|
@ -9,9 +9,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useRequest } from '../../api-client';
|
||||
import { RecordProvider, useRecord } from '../../record-provider';
|
||||
import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
|
||||
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
|
||||
import { useCancelAction } from '../action-hooks';
|
||||
import { useCollectionManager } from '../hooks';
|
||||
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
|
||||
import * as components from './components';
|
||||
import { TemplateSummay } from './components/TemplateSummay';
|
||||
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 { autoGenId = true, createdAt = true, createdBy = true, updatedAt = true, updatedBy = true } = values;
|
||||
if (autoGenId) {
|
||||
@ -207,9 +210,8 @@ const useCreateCollection = (schema?: any) => {
|
||||
if (schema?.events?.beforeSubmit) {
|
||||
schema.events.beforeSubmit(values);
|
||||
}
|
||||
const fields = values?.template !== 'view' ? useDefaultCollectionFields(values) : values.fields;
|
||||
if (values.autoCreateReverseField) {
|
||||
} else {
|
||||
const fields = getDefaultCollectionFields(values);
|
||||
if (!values.autoCreateReverseField) {
|
||||
delete values.reverseField;
|
||||
}
|
||||
delete values.id;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { getConfigurableProperties } from './properties';
|
||||
import { ICollectionTemplate } from './types';
|
||||
import { PreviewFields } from './components/PreviewFields';
|
||||
import { PreviewTable } from './components/PreviewTable';
|
||||
import { getConfigurableProperties } from './properties';
|
||||
import { ICollectionTemplate } from './types';
|
||||
|
||||
export const view: ICollectionTemplate = {
|
||||
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: {
|
||||
type: 'array',
|
||||
title: '{{ t("Source collections") }}',
|
||||
@ -128,6 +135,7 @@ export const view: ICollectionTemplate = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...getConfigurableProperties('category'),
|
||||
},
|
||||
};
|
||||
|
@ -29,6 +29,7 @@ export interface CollectionOptions {
|
||||
inherits?: string[];
|
||||
tree?: string;
|
||||
template?: string;
|
||||
writableView?: boolean;
|
||||
}
|
||||
|
||||
export interface ICollectionProviderProps {
|
||||
|
@ -707,4 +707,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":"Allow add new, update and delete actions"
|
||||
};
|
||||
|
@ -618,4 +618,5 @@ export default {
|
||||
"Current form":"現在のフォーム",
|
||||
"Current object":"現在のオブジェクト",
|
||||
"Linkage with form fields":"フォームデータから連動",
|
||||
"Allow add new, update and delete actions":"削除変更操作の許可"
|
||||
}
|
||||
|
@ -792,4 +792,5 @@ export default {
|
||||
"Copy into the form and continue to fill in": "复制到表单并继续填写",
|
||||
"Linkage with form fields":"从表单字段联动",
|
||||
"Failed to load plugin": "插件加载失败",
|
||||
"Allow add new, update and delete actions":"允许增删改操作"
|
||||
}
|
||||
|
@ -74,9 +74,9 @@ export const CalendarActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -23,9 +23,9 @@ export const CalendarFormActionInitializers = {
|
||||
type: 'primary',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
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-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
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-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -144,9 +144,9 @@ export const CalendarFormActionInitializers = {
|
||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -173,7 +173,7 @@ export const CalendarFormActionInitializers = {
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useFieldSchema, Schema } from '@formily/react';
|
||||
import { useCollection } from '../../collection-manager';
|
||||
|
||||
// 表单的操作配置
|
||||
@ -34,7 +33,7 @@ export const GridCardActionInitializers = {
|
||||
},
|
||||
visible: () => {
|
||||
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: () => {
|
||||
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: () => {
|
||||
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: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return (collection as any).template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -30,9 +30,9 @@ export const KanbanActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return (collection as any).template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useFieldSchema, Schema } from '@formily/react';
|
||||
import { useCollection } from '../../collection-manager';
|
||||
|
||||
// 表单的操作配置
|
||||
@ -32,9 +31,9 @@ export const ListActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
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-align': 'left',
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
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 }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
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 }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return (collection as any).template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -2,7 +2,7 @@ import { useCollection } from '../..';
|
||||
|
||||
const useVisibleCollection = () => {
|
||||
const collection = useCollection();
|
||||
return collection.template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
};
|
||||
// 表单的操作配置
|
||||
export const ReadPrettyFormActionInitializers = {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Schema, useFieldSchema } from '@formily/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SchemaInitializer, useCollection, useCollectionManager, SchemaInitializerItemOptions } from '../..';
|
||||
import { SchemaInitializer, SchemaInitializerItemOptions, useCollection, useCollectionManager } from '../..';
|
||||
import { gridRowColWrap } from '../utils';
|
||||
|
||||
const recursiveParent = (schema: Schema) => {
|
||||
@ -170,7 +170,7 @@ export const RecordBlockInitializers = (props: any) => {
|
||||
const hasFormChildCollection = formChildrenCollections?.length > 0;
|
||||
const detailChildrenCollections = getChildrenCollections(collection.name, true);
|
||||
const hasDetailChildCollection = detailChildrenCollections?.length > 0;
|
||||
const modifyFlag = (collection as any).template !== 'view';
|
||||
const modifyFlag = (collection as any).template !== 'view' || collection?.writableView;
|
||||
return (
|
||||
<SchemaInitializer.Button
|
||||
wrap={gridRowColWrap}
|
||||
|
@ -53,7 +53,6 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
const { t } = useTranslation();
|
||||
const collection = useCollection();
|
||||
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props'] || {};
|
||||
const modifyFlag = (collection as any).template !== 'view';
|
||||
return (
|
||||
<SchemaInitializer.Button
|
||||
insertPosition={'beforeEnd'}
|
||||
@ -102,11 +101,11 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
|
||||
modifyFlag && {
|
||||
{
|
||||
type: 'item',
|
||||
title: t('Delete'),
|
||||
component: 'DestroyActionInitializer',
|
||||
@ -115,6 +114,9 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
'x-action': 'destroy',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
collection.tree &&
|
||||
treeTable !== false && {
|
||||
@ -137,7 +139,7 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -223,7 +225,7 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -248,7 +250,7 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -34,7 +34,7 @@ export const TableActionInitializers = {
|
||||
},
|
||||
visible: function useVisible() {
|
||||
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() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -78,7 +78,7 @@ export const TableActionInitializers = {
|
||||
type: 'divider',
|
||||
visible: function useVisible() {
|
||||
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() {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
return collection.template !== 'view' || collection?.writableView;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollectionManager } from '../../collection-manager';
|
||||
import { createDetailsBlockSchema } from '../utils';
|
||||
import { DataBlockInitializer } from './DataBlockInitializer';
|
||||
@ -18,7 +17,8 @@ export const DetailsBlockInitializer = (props) => {
|
||||
const schema = createDetailsBlockSchema({
|
||||
collection: item.name,
|
||||
rowKey: collection.filterTargetKey || 'id',
|
||||
actionInitializers: collection.template !== 'view' && 'DetailsActionInitializers',
|
||||
actionInitializers:
|
||||
(collection.template !== 'view' || collection?.writableView) && 'DetailsActionInitializers',
|
||||
});
|
||||
insert(schema);
|
||||
}}
|
||||
|
@ -860,7 +860,7 @@ export const useCollectionDataSourceItems = (componentName) => {
|
||||
const fields = getCollectionFields(item.name);
|
||||
if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) {
|
||||
return false;
|
||||
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view') {
|
||||
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view' && !item.writableView) {
|
||||
return false;
|
||||
} else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) {
|
||||
return false;
|
||||
|
@ -15,6 +15,7 @@
|
||||
"excel-date-to-js": "^1.1.5",
|
||||
"flat": "^5.0.2",
|
||||
"glob": "^7.1.6",
|
||||
"graphlib": "^2.1.8",
|
||||
"mathjs": "^10.6.1",
|
||||
"semver": "^7.3.7",
|
||||
"sequelize": "^6.26.0",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Database } from '../database';
|
||||
import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository';
|
||||
import { mockDatabase } from './';
|
||||
import { AdjacencyListRepository } from '../tree-repository/adjacency-list-repository';
|
||||
|
||||
describe('tree test', function () {
|
||||
let db: Database;
|
||||
|
@ -1,6 +1,112 @@
|
||||
import { Database, mockDatabase } from '@nocobase/database';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { Database, mockDatabase } from '../../index';
|
||||
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', () => {
|
||||
let db: Database;
|
||||
@ -9,6 +115,7 @@ describe('create view', () => {
|
||||
db = mockDatabase({
|
||||
tablePrefix: '',
|
||||
});
|
||||
|
||||
await db.clean({ drop: true });
|
||||
});
|
||||
|
||||
@ -119,7 +226,6 @@ describe('create view', () => {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(fooData);
|
||||
expect(fooData.get('name')).toBe('foo');
|
||||
expect(fooData.get('age')).toBe(18);
|
||||
});
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Database, mockDatabase } from '@nocobase/database';
|
||||
import { ViewFieldInference } from '../../view/view-inference';
|
||||
import { uid } from '@nocobase/utils';
|
||||
|
||||
describe('view inference', function () {
|
||||
let db: Database;
|
||||
|
@ -12,9 +12,9 @@ import {
|
||||
import { Database } from './database';
|
||||
import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
|
||||
import { Model } from './model';
|
||||
import { AdjacencyListRepository } from './repositories/tree-repository/adjacency-list-repository';
|
||||
import { Repository } from './repository';
|
||||
import { checkIdentifier, md5, snakeCase } from './utils';
|
||||
import { AdjacencyListRepository } from './tree-repository/adjacency-list-repository';
|
||||
|
||||
export type RepositoryType = typeof Repository;
|
||||
|
||||
@ -44,6 +44,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
|
||||
tableName?: string;
|
||||
inherits?: string[] | string;
|
||||
viewName?: string;
|
||||
writableView?: boolean;
|
||||
|
||||
filterTargetKey?: string;
|
||||
fields?: FieldOptions[];
|
||||
@ -272,7 +273,7 @@ export class Collection<
|
||||
`source field "${sourceFieldName}" not found for field "${name}" at collection "${this.name}"`,
|
||||
);
|
||||
} else {
|
||||
options = { ...sourceField.options, ...options };
|
||||
options = { ...lodash.omit(sourceField.options, 'name'), ...options };
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { applyMixins, AsyncEmitter, requireModule } from '@nocobase/utils';
|
||||
import merge from 'deepmerge';
|
||||
import { EventEmitter } from 'events';
|
||||
@ -19,7 +20,9 @@ import {
|
||||
} from 'sequelize';
|
||||
import { SequelizeStorage, Umzug } from 'umzug';
|
||||
import { Collection, CollectionOptions, RepositoryType } from './collection';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
import { ImporterReader, ImportFileExtension } from './collection-importer';
|
||||
import DatabaseUtils from './database-utils';
|
||||
import ReferencesMap from './features/ReferencesMap';
|
||||
import { referentialIntegrityCheck } from './features/referential-integrity-check';
|
||||
import { ArrayFieldRepository } from './field-repository/array-field-repository';
|
||||
@ -27,10 +30,13 @@ import * as FieldTypes from './fields';
|
||||
import { Field, FieldContext, RelationField } from './fields';
|
||||
import { InheritedCollection } from './inherited-collection';
|
||||
import InheritanceMap from './inherited-map';
|
||||
import { registerBuiltInListeners } from './listeners';
|
||||
import { MigrationItem, Migrations } from './migration';
|
||||
import { Model } from './model';
|
||||
import { ModelHook } from './model-hook';
|
||||
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 { Repository } from './repository';
|
||||
import {
|
||||
@ -61,13 +67,6 @@ import {
|
||||
ValidateListener,
|
||||
} from './types';
|
||||
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 { ViewCollection } from './view-collection';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import QueryInterface from './query-interface';
|
||||
import { Collection } from '../collection';
|
||||
import { Transactionable } from 'sequelize';
|
||||
import { Collection } from '../collection';
|
||||
import sqlParser from '../sql-parser';
|
||||
import QueryInterface from './query-interface';
|
||||
|
||||
export default class MysqlQueryInterface extends QueryInterface {
|
||||
constructor(db) {
|
||||
@ -39,13 +39,7 @@ export default class MysqlQueryInterface extends QueryInterface {
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const viewDefinition = await this.db.sequelize.query(`SHOW CREATE VIEW ${options.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];
|
||||
|
||||
const { ast } = sqlParser.parse(sql);
|
||||
const { ast } = this.parseSQL(await this.viewDef(options.viewName));
|
||||
|
||||
const columns = ast.columns;
|
||||
|
||||
@ -69,4 +63,17 @@ export default class MysqlQueryInterface extends QueryInterface {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import QueryInterface from './query-interface';
|
||||
import lodash from 'lodash';
|
||||
import { Collection } from '../collection';
|
||||
import sqlParser from '../sql-parser/postgres';
|
||||
import QueryInterface from './query-interface';
|
||||
|
||||
export default class PostgresQueryInterface extends QueryInterface {
|
||||
constructor(db) {
|
||||
@ -33,6 +34,23 @@ export default class PostgresQueryInterface extends QueryInterface {
|
||||
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<{
|
||||
[view_column_name: string]: {
|
||||
column_name: string;
|
||||
@ -54,16 +72,10 @@ export default class PostgresQueryInterface extends QueryInterface {
|
||||
table_schema: string;
|
||||
}>;
|
||||
|
||||
const viewDefQuery = await this.db.sequelize.query(
|
||||
`
|
||||
select pg_get_viewdef(format('%I.%I', '${schema}', '${viewName}')::regclass, true) as definition
|
||||
`,
|
||||
{ type: 'SELECT' },
|
||||
);
|
||||
const def = await this.viewDef(`${schema}.${viewName}`);
|
||||
|
||||
const def = viewDefQuery[0]['definition'];
|
||||
try {
|
||||
const { ast } = sqlParser.parse(def);
|
||||
const { ast } = this.parseSQL(def);
|
||||
const columns = ast[0].columns;
|
||||
|
||||
const usages = columns
|
||||
@ -96,7 +108,7 @@ export default class PostgresQueryInterface extends QueryInterface {
|
||||
|
||||
return Object.fromEntries(usages);
|
||||
} catch (e) {
|
||||
this.db.logger.warn(e);
|
||||
console.log(e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Database from '../database';
|
||||
import { Collection } from '../collection';
|
||||
import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequelize';
|
||||
import { Collection } from '../collection';
|
||||
import Database from '../database';
|
||||
|
||||
export default abstract class QueryInterface {
|
||||
sequelizeQueryInterface: SequelizeQueryInterface;
|
||||
@ -13,6 +13,8 @@ export default abstract class QueryInterface {
|
||||
|
||||
abstract listViews();
|
||||
|
||||
abstract viewDef(viewName: string): Promise<string>;
|
||||
|
||||
abstract viewColumnUsage(options: { viewName: string; schema?: string }): Promise<{
|
||||
[view_column_name: string]: {
|
||||
column_name: string;
|
||||
@ -21,6 +23,8 @@ export default abstract class QueryInterface {
|
||||
};
|
||||
}>;
|
||||
|
||||
abstract parseSQL(sql: string): any;
|
||||
|
||||
async dropAll(options) {
|
||||
if (options.drop !== true) return;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import QueryInterface from './query-interface';
|
||||
import { Collection } from '../collection';
|
||||
import sqlParser from '../sql-parser';
|
||||
import QueryInterface from './query-interface';
|
||||
|
||||
export default class SqliteQueryInterface extends QueryInterface {
|
||||
constructor(db) {
|
||||
@ -41,19 +41,7 @@ export default class SqliteQueryInterface extends QueryInterface {
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const viewDefinition = await this.db.sequelize.query(
|
||||
`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 { ast } = this.parseSQL(await this.viewDef(options.viewName));
|
||||
|
||||
const columns = ast.columns;
|
||||
|
||||
@ -76,4 +64,26 @@ export default class SqliteQueryInterface extends QueryInterface {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FindOptions, Repository } from '../repository';
|
||||
import lodash from 'lodash';
|
||||
import { FindOptions, Repository } from '../../repository';
|
||||
|
||||
export class AdjacencyListRepository extends Repository {
|
||||
async update(options): Promise<any> {
|
||||
@ -143,15 +143,13 @@ export class AdjacencyListRepository extends Repository {
|
||||
const q = queryInterface.quoteIdentifier.bind(queryInterface);
|
||||
|
||||
return `
|
||||
WITH RECURSIVE cte AS (
|
||||
SELECT ${q(primaryKey)}, ${q(foreignKeyField)}, 1 AS level
|
||||
WITH RECURSIVE cte AS (SELECT ${q(primaryKey)}, ${q(foreignKeyField)}, 1 AS level
|
||||
FROM ${collection.quotedTableName()}
|
||||
WHERE ${q(foreignKeyField)} IN (${rootIds.join(',')})
|
||||
UNION ALL
|
||||
SELECT t.${q(primaryKey)}, t.${q(foreignKeyField)}, cte.level + 1 AS level
|
||||
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
|
||||
FROM cte
|
||||
`;
|
@ -0,0 +1,3 @@
|
||||
import { Repository } from '../repository';
|
||||
|
||||
export class ViewRepository extends Repository {}
|
File diff suppressed because it is too large
Load Diff
@ -8,13 +8,13 @@ export class ViewCollection extends Collection {
|
||||
super(options, context);
|
||||
}
|
||||
|
||||
isView() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected sequelizeModelOptions(): any {
|
||||
const modelOptions = super.sequelizeModelOptions();
|
||||
modelOptions.tableName = this.options.viewName || this.options.name;
|
||||
return modelOptions;
|
||||
}
|
||||
|
||||
isView() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { isArray } from 'mathjs';
|
||||
import Database from '../database';
|
||||
import FieldTypeMap from './field-type-map';
|
||||
import { isArray } from 'mathjs';
|
||||
|
||||
type InferredField = {
|
||||
name: string;
|
||||
@ -31,6 +31,7 @@ export class ViewFieldInference {
|
||||
});
|
||||
|
||||
const rawFields = [];
|
||||
|
||||
for (const [name, column] of Object.entries(columns)) {
|
||||
const inferResult: any = { name };
|
||||
|
||||
|
@ -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 { MockServer } from '@nocobase/test';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { createApp } from '../index';
|
||||
|
||||
describe('view collection', () => {
|
||||
let app: MockServer;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Database, { Repository, ViewCollection, ViewFieldInference } from '@nocobase/database';
|
||||
import Application from '@nocobase/server';
|
||||
import { createApp } from '../index';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { createApp } from '../index';
|
||||
|
||||
describe('view collection', function () {
|
||||
let db: Database;
|
||||
|
@ -222,12 +222,7 @@ export class CollectionManagerPlugin extends Plugin {
|
||||
} catch (error) {
|
||||
this.app.logger.warn(error);
|
||||
await this.app.db.sync();
|
||||
|
||||
try {
|
||||
await this.app.db.getRepository<CollectionRepository>('collections').load();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -287,7 +282,7 @@ export class CollectionManagerPlugin extends Plugin {
|
||||
const newOptions = {};
|
||||
|
||||
// write original field options
|
||||
lodash.merge(newOptions, collectionField.options);
|
||||
lodash.merge(newOptions, lodash.omit(collectionField.options, 'name'));
|
||||
|
||||
// merge with current field options
|
||||
lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => {
|
||||
|
@ -20,7 +20,7 @@ export const ImportInitializerProvider = (props: any) => {
|
||||
},
|
||||
visible: function useVisible() {
|
||||
const collection = useCollection();
|
||||
return collection.template !== 'view' && collection.template !== 'file';
|
||||
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file';
|
||||
},
|
||||
});
|
||||
return props.children;
|
||||
|
@ -5744,7 +5744,7 @@
|
||||
|
||||
"@remix-run/router@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==
|
||||
|
||||
"@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:
|
||||
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==
|
||||
dependencies:
|
||||
"@remix-run/router" "1.7.1"
|
||||
|
Loading…
Reference in New Issue
Block a user