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

@ -25,8 +25,8 @@ jobs:
sqlite-test:
strategy:
matrix:
node_version: [ '18' ]
underscored: [ true, false ]
node_version: ['18']
underscored: [true, false]
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
steps:
@ -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 }}
@ -49,17 +49,17 @@ jobs:
postgres-test:
strategy:
matrix:
node_version: [ '18' ]
underscored: [ true, false ]
schema: [ public, nocobase ]
collection_schema: [ public, user_schema ]
node_version: ['18']
underscored: [true, false]
schema: [public, nocobase]
collection_schema: [public, user_schema]
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
services:
# 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
@ -97,8 +97,8 @@ jobs:
mysql-test:
strategy:
matrix:
node_version: [ '18' ]
underscored: [ true, false ]
node_version: ['18']
underscored: [true, false]
runs-on: ubuntu-latest
container: node:${{ matrix.node_version }}
services:
@ -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

View File

@ -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) => {

View File

@ -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 || {};

View File

@ -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;

View File

@ -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'),
},
};

View File

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

View File

@ -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"
};

View File

@ -618,4 +618,5 @@ export default {
"Current form":"現在のフォーム",
"Current object":"現在のオブジェクト",
"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": "复制到表单并继续填写",
"Linkage with form fields":"从表单字段联动",
"Failed to load plugin": "插件加载失败",
"Allow add new, update and delete actions":"允许增删改操作"
}

View File

@ -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;
},
},
],

View File

@ -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;
},
},
],

View File

@ -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;
},
},
],

View File

@ -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;
},
},
],

View File

@ -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;
},
},
],

View File

@ -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 = {

View File

@ -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}

View File

@ -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;
},
},
],

View File

@ -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;
},
},
],

View File

@ -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);
}}

View File

@ -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;

View File

@ -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",

View File

@ -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;

View File

@ -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 });
});
@ -92,7 +199,7 @@ describe('create view', () => {
const dropViewSQL = `DROP VIEW IF EXISTS ${appendSchema}${viewName}`;
await db.sequelize.query(dropViewSQL);
const viewSql = `CREATE VIEW ${appendSchema}${viewName} AS SELECT users.name, profiles.age FROM ${appendSchema}${UserCollection.model.tableName} as users LEFT JOIN ${appendSchema}${ProfileCollection.model.tableName} as profiles ON users.id = profiles.user_id;`;
const viewSql = `CREATE VIEW ${appendSchema}${viewName} AS SELECT users.name, profiles.age FROM ${appendSchema}${UserCollection.model.tableName} as users LEFT JOIN ${appendSchema}${ProfileCollection.model.tableName} as profiles ON users.id = profiles.user_id;`;
await db.sequelize.query(viewSql);
@ -119,7 +226,6 @@ describe('create view', () => {
},
});
console.log(fooData);
expect(fooData.get('name')).toBe('foo');
expect(fooData.get('age')).toBe(18);
});

View File

@ -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;

View File

@ -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 };
}
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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 {};
}
}

View File

@ -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;

View File

@ -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) {
@ -22,7 +22,7 @@ export default class SqliteQueryInterface extends QueryInterface {
async listViews() {
const sql = `
SELECT name , sql as definition
SELECT name, sql as definition
FROM sqlite_master
WHERE type = 'view'
ORDER BY name;
@ -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;
}
}

View File

@ -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
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)}
)
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)})
SELECT ${q(primaryKey)}, ${q(foreignKeyField)} as ${q(foreignKey)}, level
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);
}
isView() {
return true;
}
protected sequelizeModelOptions(): any {
const modelOptions = super.sequelizeModelOptions();
modelOptions.tableName = this.options.viewName || this.options.name;
return modelOptions;
}
isView() {
return true;
}
}

View File

@ -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 };

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 { MockServer } from '@nocobase/test';
import { uid } from '@nocobase/utils';
import { createApp } from '../index';
describe('view collection', () => {
let app: MockServer;

View File

@ -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;

View File

@ -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;
}
await this.app.db.getRepository<CollectionRepository>('collections').load();
}
}
});
@ -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) => {

View File

@ -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;

View File

@ -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"