feat: database view collection (#1587)

* test: create view collection

* feat: view collection class

* feat: list view

* chore: skip sync view collection

* test: should create view collection in difference schema

* test: create view collection in collection manager

* feat: create view collection by user sql

* test: view resourcer

* feat: view collection

* feat: view collection cannot be added, deleted, or modified

* feat: view collection cannot be added, deleted, or modified

* feat: view collection cannot be added, deleted, or modified

* feat: view collection cannot be added, deleted, or modified

* refactor: connect to database view

* refactor: sync from database

* chore: rename list view sql

* chore: list view fields api

* chore: create collection without viewName

* feat: bring out fields when selecting a view

* chore: bring out fields when selecting a view

* feat: view field inference class

* chore: bring out fields when selecting a view

* chore: sync form database view

* chore: sync form database view

* refactor: view collection local

* feat: view get api

* feat: database type infer

* feat: integer map

* chore: remove from in view list

* chore: build error

* chore: uniq collection

* fix: typo

* chore: replace collection list source field

* fix: destroy view collection

* chore: timestamp field map

* refactor: interface avalableTypes

* refactor: interface avalableTypes

* chore: list fields test

* refactor: interface avalableTypes

* chore: uiSchema response in field source

* fix: view query

* chore: collection snippet

* refactor: view collection support preview

* fix: handle field source

* fix: typo

* fix: configure fileds title

* fix: configure fileds title

* fix: configure fileds title

* fix: sync from databse interface

* fix: sync from databse interface

* feat: set fields api

* fix: sync from databse fix

* feat: possibleTypes

* chore: fields get

* fix: sync from databse

* fix: list view test

* fix: view test in difference schema

* chore: comment

* feat: when there is only one source  collection, the view is a subset of a Collection

* feat: view collection add field

* fix: inherit query with schema

* fix: test

* fix: ci test

* fix: test with schema

* chore: set pg default search path

* chore: mysql test

* fix: test with schema

* chore: test

* chore: action test

* chore: view column usage return type

* feat: mysql field inference

* fix: tableName

* chore: node sql parser

* fix: sql build

* fix: database build

* fix: mysql test

* feat: view collection uiSchema title

* fix: incorrect field source display  when switching views

* refactor: view collection not allow modify

* fix: view collection is allow add, delete, and modify

* fix: mysql test

* fix: sqlite test

* fix: sqlite test

* fix: sqlite test

* fix: sqlite test

* chore: add id field as default target key

* style: style improve

* feat: load source field options

* style: style improve

* chore: disable remove column in view collection

* chore: support creating view collection with different schemas with the same name

* chore: support creating view collection with different schemas with the same name

* fix: query view in difference schema

* refactor: view collection viewname

* fix: query view collection in difference schema

* fix: field load

* chore: field options

* fix: mysql test

* fix: uiSchema component error when using a view field in a block

* fix: sqlite test

* chore: test

* fix: dump user views

* fix: view collection can be updated and edited in table block

* chore: sync from database display last field configuration

* chore: loadCollections

* chore: sync from database display last field configuration

* fix: field options merge issues

* style: preview table

* fix: view collection is allow using in kanban blocks

* refactor: code improve

* fix: view collection can be updated an edited in calendar block

* chore: disable infer field without interface

* feat: preview only shows source or interface fields

* fix: test

* refactor: locale

* feat: sql parser

* chore: remove node-sql-parser

* fix: yarn.lock

* test: view repository

* fix: view repository test

* chore: console.log

* chore: console.log

* fix: mysql without schema

* fix: mysql without schema

* chore: preview with field schema

* chore: tableActionInitializers

* style: preview style improve

* chore:  parameter is filter when there is no filterByTk

* fix: preview pagination

* fix: preview pagination

* style: preview table style improve

* fix: sync from database loading

* chore: preview performance optimization

* chore: preview performance optimization

* feat: limit & offset

* chore: preview performance optimization

* test: field with dot column

* fix: datetime interface display

* fix: missing boolean type

* fix: sync

* fix: sync from database

* style: style improve

* style: style improve

* style: style improve

* chore: preview table

* chore: preview table

* chore: preview table

* fix: styling

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-04-01 21:56:01 +08:00 committed by GitHub
parent bcc417a79f
commit 4f87de7da5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 14610 additions and 217 deletions

View File

@ -25,13 +25,15 @@ services:
networks: networks:
- nocobase - nocobase
postgres: postgres:
image: postgres:10 image: postgres:latest
restart: always restart: always
networks: networks:
- nocobase - nocobase
command: postgres -c wal_level=logical command: postgres -c wal_level=logical
ports: ports:
- "${DB_POSTGRES_PORT}:5432" - "${DB_POSTGRES_PORT}:5432"
volumes:
- ./storage/db/postgres/backups:/backups
environment: environment:
POSTGRES_USER: ${DB_USER} POSTGRES_USER: ${DB_USER}
POSTGRES_DB: ${DB_DATABASE} POSTGRES_DB: ${DB_DATABASE}

View File

@ -9,6 +9,7 @@ describe('create action', () => {
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = mockServer();
await app.db.clean({ drop: true });
registerActions(app); registerActions(app);
Post = app.collection({ Post = app.collection({

View File

@ -13,6 +13,8 @@ describe('remove action', () => {
app = mockServer(); app = mockServer();
registerActions(app); registerActions(app);
await app.db.clean({ drop: true });
PostTag = app.collection({ PostTag = app.collection({
name: 'posts_tags', name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }], fields: [{ type: 'string', name: 'tagged_at' }],

View File

@ -10,6 +10,7 @@ describe('set action', () => {
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = mockServer();
await app.db.clean({ drop: true });
registerActions(app); registerActions(app);
PostTag = app.collection({ PostTag = app.collection({

View File

@ -10,6 +10,7 @@ describe('toggle action', () => {
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = mockServer();
await app.db.clean({ drop: true });
registerActions(app); registerActions(app);
PostTag = app.collection({ PostTag = app.collection({

View File

@ -292,9 +292,27 @@ export const useSourceIdFromParentRecord = () => {
export const useParamsFromRecord = () => { export const useParamsFromRecord = () => {
const filterByTk = useFilterByTk(); const filterByTk = useFilterByTk();
return { const record = useRecord();
const { fields } = useCollection();
const filterFields = fields
.filter((v) => {
return ['boolean', 'date', 'integer', 'radio', 'sort', 'string', 'time', 'uid', 'uuid'].includes(v.type);
})
.map((v) => v.name);
const filter = Object.keys(record)
.filter((key) => filterFields.includes(key))
.reduce((result, key) => {
result[key] = record[key];
return result;
}, {});
const obj = {
filterByTk: filterByTk, filterByTk: filterByTk,
}; };
if (!filterByTk) {
obj['filter'] = filter;
}
return obj;
}; };
export const RecordLink = (props) => { export const RecordLink = (props) => {

View File

@ -13,7 +13,7 @@ const InternalField: React.FC = (props) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const { name, interface: interfaceType, uiSchema, defaultValue } = useCollectionField(); const { name, interface: interfaceType, uiSchema, defaultValue } = useCollectionField();
const collectionField = useCollectionField(); const collectionField = useCollectionField();
const component = useComponent(uiSchema?.['x-component']); const component = useComponent(uiSchema?.['x-component'] || 'Input');
const compile = useCompile(); const compile = useCompile();
const setFieldProps = (key, value) => { const setFieldProps = (key, value) => {
field[key] = typeof field[key] === 'undefined' ? value : field[key]; field[key] = typeof field[key] === 'undefined' ? value : field[key];
@ -73,7 +73,6 @@ const InternalField: React.FC = (props) => {
if (!uiSchema) { if (!uiSchema) {
return null; return null;
} }
return React.createElement(component, props, props.children); return React.createElement(component, props, props.children);
}; };
@ -107,7 +106,6 @@ export const CollectionField = connect((props) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const field = fieldSchema?.['x-component-props']?.['field']; const field = fieldSchema?.['x-component-props']?.['field'];
const { snapshot } = useActionContext(); const { snapshot } = useActionContext();
return ( return (
<CollectionFieldProvider <CollectionFieldProvider
name={fieldSchema.name} name={fieldSchema.name}

View File

@ -26,6 +26,8 @@ import {
ConfigurationTabs, ConfigurationTabs,
EditCategory, EditCategory,
EditCategoryAction, EditCategoryAction,
SyncFieldsAction,
SyncFieldsActionCom
} from './Configuration'; } from './Configuration';
import { CollectionCategroriesProvider } from './CollectionManagerProvider'; import { CollectionCategroriesProvider } from './CollectionManagerProvider';
@ -81,6 +83,8 @@ export const CollectionManagerPane = () => {
ViewFieldAction, ViewFieldAction,
EditCategory, EditCategory,
EditCategoryAction, EditCategoryAction,
SyncFieldsAction,
SyncFieldsActionCom
}} }}
/> />
// </Card> // </Card>

View File

@ -28,8 +28,9 @@ const getSchema = (schema, category, compile): ISchema => {
properties['defaultValue']['x-decorator'] = 'FormItem'; properties['defaultValue']['x-decorator'] = 'FormItem';
} }
const initialValue: any = { const initialValue: any = {
name: `t_${uid()}`, name: schema.name !== 'view' ? `t_${uid()}` : null,
template: schema.name, template: schema.name,
view: schema.name === 'view',
category, category,
...cloneDeep(schema.default), ...cloneDeep(schema.default),
}; };
@ -201,7 +202,7 @@ const useCreateCollection = (schema?: any) => {
if (schema?.events?.beforeSubmit) { if (schema?.events?.beforeSubmit) {
schema.events.beforeSubmit(values); schema.events.beforeSubmit(values);
} }
const fields = useDefaultCollectionFields(values); const fields = values?.template !== 'view' ? useDefaultCollectionFields(values) : values.fields;
if (values.autoCreateReverseField) { if (values.autoCreateReverseField) {
} else { } else {
delete values.reverseField; delete values.reverseField;
@ -235,8 +236,15 @@ export const AddCollectionAction = (props) => {
const [schema, setSchema] = useState({}); const [schema, setSchema] = useState({});
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const items = templateOptions().map((option) => { const collectionTemplates = templateOptions();
return { label: compile(option.title), key: option.name }; const items = [];
collectionTemplates.forEach((item) => {
if (item.divider) {
items.push({
type: 'divider',
});
}
items.push({ label: compile(item.title), key: item.name });
}); });
const { const {
state: { category }, state: { category },

View File

@ -212,72 +212,74 @@ export const AddFieldAction = (props) => {
return optionArr; return optionArr;
}; };
return ( return (
<RecordProvider record={record}> record.template !== 'view' && (
<ActionContext.Provider value={{ visible, setVisible }}> <RecordProvider record={record}>
<Dropdown <ActionContext.Provider value={{ visible, setVisible }}>
getPopupContainer={getContainer} <Dropdown
trigger={trigger} getPopupContainer={getContainer}
align={align} trigger={trigger}
overlay={ align={align}
<Menu overlay={
style={{ <Menu
maxHeight: '60vh', style={{
overflow: 'auto', maxHeight: '60vh',
}} overflow: 'auto',
onClick={(e) => { }}
//@ts-ignore onClick={(e) => {
const targetScope = e.item.props['data-targetScope']; //@ts-ignore
targetScope && setTargetScope(targetScope); const targetScope = e.item.props['data-targetScope'];
const schema = getSchema(getInterface(e.key), record, compile); targetScope && setTargetScope(targetScope);
if (schema) { const schema = getSchema(getInterface(e.key), record, compile);
setSchema(schema); if (schema) {
setVisible(true); setSchema(schema);
} setVisible(true);
}} }
> }}
{getFieldOptions().map((option) => { >
return ( {getFieldOptions().map((option) => {
option.children.length > 0 && ( return (
<Menu.ItemGroup key={option.label} title={compile(option.label)}> option.children.length > 0 && (
{option.children <Menu.ItemGroup key={option.label} title={compile(option.label)}>
.filter((child) => !['o2o', 'subTable'].includes(child.name)) {option.children
.map((child) => { .filter((child) => !['o2o', 'subTable'].includes(child.name))
return ( .map((child) => {
<Menu.Item key={child.name} data-targetScope={child.targetScope}> return (
{compile(child.title)} <Menu.Item key={child.name} data-targetScope={child.targetScope}>
</Menu.Item> {compile(child.title)}
); </Menu.Item>
})} );
</Menu.ItemGroup> })}
) </Menu.ItemGroup>
); )
})} );
</Menu> })}
} </Menu>
> }
{children || ( >
<Button icon={<PlusOutlined />} type={'primary'}> {children || (
{t('Add field')} <Button icon={<PlusOutlined />} type={'primary'}>
</Button> {t('Add field')}
)} </Button>
</Dropdown> )}
<SchemaComponent </Dropdown>
schema={schema} <SchemaComponent
components={{ ...components, ArrayTable }} schema={schema}
scope={{ components={{ ...components, ArrayTable }}
getContainer, scope={{
useCancelAction, getContainer,
createOnly: true, useCancelAction,
isOverride: false, createOnly: true,
override: false, isOverride: false,
useCreateCollectionField, override: false,
record, useCreateCollectionField,
showReverseFieldConfig: true, record,
targetScope, showReverseFieldConfig: true,
...scope, targetScope,
}} ...scope,
/> }}
</ActionContext.Provider> />
</RecordProvider> </ActionContext.Provider>
</RecordProvider>
)
); );
}; };

View File

@ -14,6 +14,7 @@ import { AddSubFieldAction } from './AddSubFieldAction';
import { FieldSummary } from './components/FieldSummary'; import { FieldSummary } from './components/FieldSummary';
import { EditSubFieldAction } from './EditSubFieldAction'; import { EditSubFieldAction } from './EditSubFieldAction';
import { collectionSchema } from './schemas/collections'; import { collectionSchema } from './schemas/collections';
import { useAPIClient } from '../../api-client';
const useAsyncDataSource = (service: any) => { const useAsyncDataSource = (service: any) => {
return (field: any, options?: any) => { return (field: any, options?: any) => {
@ -77,23 +78,32 @@ export const ConfigurationTable = () => {
const { const {
data: { database }, data: { database },
} = useCurrentAppInfo(); } = useCurrentAppInfo();
const data = useContext(CollectionCategroriesContext); const data = useContext(CollectionCategroriesContext);
const api = useAPIClient();
const resource = api.resource('dbViews');
const collectonsRef: any = useRef(); const collectonsRef: any = useRef();
collectonsRef.current = collections; collectonsRef.current = collections;
const compile = useCompile(); const compile = useCompile();
const loadCollections = async (field, options) => { const loadCollections = async (field, options) => {
const { targetScope } = options; const { targetScope } = options;
return collectonsRef.current const isFieldInherits = field.props?.name === 'inherits';
?.filter((item) => !(item.autoCreate && item.isThrough)) const filteredItems = collectonsRef.current.filter((item) => {
.filter((item) => const isAutoCreateAndThrough = item.autoCreate && item.isThrough;
targetScope if (isAutoCreateAndThrough) {
? targetScope['template']?.includes(item.template) || targetScope[field.props.name]?.includes(item.name) return false;
: true, }
) if (isFieldInherits && item.template === 'view') {
.map((item: any) => ({ return false;
label: compile(item.title), }
value: item.name, const templateIncluded = !targetScope?.template || targetScope.template.includes(item.template);
})); const nameIncluded = !targetScope?.[field.props?.name] || targetScope[field.props.name].includes(item.name);
return templateIncluded && nameIncluded;
});
return filteredItems.map((item) => ({
label: compile(item.title),
value: item.name,
}));
}; };
const loadCategories = async () => { const loadCategories = async () => {
return data.data.map((item: any) => ({ return data.data.map((item: any) => ({
@ -101,31 +111,45 @@ export const ConfigurationTable = () => {
value: item.id, value: item.id,
})); }));
}; };
const loadDBViews = async () => {
return resource.list().then(({ data }) => {
return data?.data?.map((item: any) => {
const schema = item.schema;
return {
label: schema ? `${schema}.${compile(item.name)}` : item.name,
value: schema?`${schema}_${item.name}`:item.name
};
});
});
};
const ctx = useContext(SchemaComponentContext); const ctx = useContext(SchemaComponentContext);
return ( return (
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}> <SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
<SchemaComponent <SchemaComponent
schema={collectionSchema} schema={collectionSchema}
components={{ components={{
AddSubFieldAction, AddSubFieldAction,
EditSubFieldAction, EditSubFieldAction,
FieldSummary, FieldSummary,
CollectionFieldsTable, CollectionFieldsTable,
}} }}
scope={{ scope={{
useDestroySubField, useDestroySubField,
useBulkDestroySubField, useBulkDestroySubField,
useSelectedRowKeys, useSelectedRowKeys,
useAsyncDataSource, useAsyncDataSource,
loadCollections, loadCollections,
loadCategories, loadCategories,
useCurrentFields, loadDBViews,
useNewId, useCurrentFields,
useCancelAction, useNewId,
interfaces, useCancelAction,
enableInherits: database?.dialect === 'postgres', interfaces,
}} enableInherits: database?.dialect === 'postgres',
/> isPG:database?.dialect === 'postgres',
</SchemaComponentContext.Provider> }}
/>
</SchemaComponentContext.Provider>
); );
}; };

View File

@ -19,10 +19,12 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
return; return;
} }
const properties = cloneDeep(schema.properties) as any; const properties = cloneDeep(schema.properties) as any;
properties.name['x-disabled'] = true; if (properties?.name) {
properties.name['x-disabled'] = true;
}
if (schema.hasDefaultValue === true) { if (schema.hasDefaultValue === true) {
properties['defaultValue'] = cloneDeep(schema.default.uiSchema); properties['defaultValue'] = cloneDeep(schema.default.uiSchema)||{};
properties['defaultValue']['title'] = compile('{{ t("Default value") }}'); properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem'; properties['defaultValue']['x-decorator'] = 'FormItem';
properties['defaultValue']['x-reactions'] = { properties['defaultValue']['x-reactions'] = {
@ -156,7 +158,7 @@ export const EditFieldAction = (props) => {
const defaultValues: any = cloneDeep(data?.data) || {}; const defaultValues: any = cloneDeep(data?.data) || {};
if (!defaultValues?.reverseField) { if (!defaultValues?.reverseField) {
defaultValues.autoCreateReverseField = false; defaultValues.autoCreateReverseField = false;
defaultValues.reverseField = interfaceConf.default?.reverseField; defaultValues.reverseField = interfaceConf?.default?.reverseField;
set(defaultValues.reverseField, 'name', `f_${uid()}`); set(defaultValues.reverseField, 'name', `f_${uid()}`);
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title); set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
} }

View File

@ -0,0 +1,194 @@
import { PlusOutlined } from '@ant-design/icons';
import { ArrayTable } from '@formily/antd';
import { useForm } from '@formily/react';
import { uid } from '@formily/shared';
import { Button } from 'antd';
import { cloneDeep } from 'lodash';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useCancelAction } from '../action-hooks';
import { useCollectionManager } from '../hooks';
import { IField } from '../interfaces/types';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components';
import { useAPIClient } from '../../api-client';
import { PreviewFields } from '../templates/components/PreviewFields';
import { PreviewTable } from '../templates/components/PreviewTable';
const getSchema = (schema: IField, record: any, compile) => {
if (!schema) {
return;
}
const properties = cloneDeep(schema.properties) as any;
if (schema.hasDefaultValue === true) {
properties['defaultValue'] = cloneDeep(schema?.default?.uiSchema);
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem';
}
const initialValue: any = {
name: `f_${uid()}`,
...cloneDeep(schema.default),
interface: schema.name,
};
if (initialValue.reverseField) {
initialValue.reverseField.name = `f_${uid()}`;
}
// initialValue.uiSchema.title = schema.title;
return {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Action.Drawer',
'x-component-props': {
getContainer: '{{ getContainer }}',
},
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
return useRequest(
() =>
Promise.resolve({
data: initialValue,
}),
options,
);
},
},
title: `${compile('{{ t("Sync from database") }}')}`,
properties: {
schema: {
type: 'string',
'x-hidden': true,
default: record?.schema,
},
viewName: {
type: 'string',
'x-hidden': true,
default: record?.viewName,
},
fields: {
type: 'array',
'x-component': PreviewFields,
'x-component-props': {
...record,
},
default: record.fields,
},
preview: {
type: 'object',
'x-component': PreviewTable,
'x-component-props': {
...record,
},
'x-reactions': {
dependencies: ['fields'],
fulfill: {
schema: {
'x-component-props': '{{{...record,...$form.values}}}', //任意层次属性都支持表达式
},
},
},
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useSyncFromDatabase }}',
},
},
},
},
},
},
},
};
};
const useSyncFromDatabase = () => {
const form = useForm();
const { refreshCM } = useCollectionManager();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
const api = useAPIClient();
return {
async run() {
await form.submit();
await api.resource(`collections`).setFields({
filterByTk,
values: form.values,
});
ctx.setVisible(false);
await form.reset();
refresh();
await refreshCM();
},
};
};
export const SyncFieldsAction = (props) => {
const record = useRecord();
return <SyncFieldsActionCom item={record} {...props} />;
};
export const SyncFieldsActionCom = (props) => {
const { scope, getContainer, item: record, children } = props;
const [visible, setVisible] = useState(false);
const [schema, setSchema] = useState({});
const compile = useCompile();
const { t } = useTranslation();
return (
record.template === 'view' && (
<RecordProvider record={record}>
<ActionContext.Provider value={{ visible, setVisible }}>
{children || (
<Button
icon={<PlusOutlined />}
onClick={(e) => {
const schema = getSchema({}, record, compile);
if (schema) {
setSchema(schema);
setVisible(true);
}
}}
>
{t('Sync from database')}
</Button>
)}
<SchemaComponent
schema={schema}
components={{ ...components, ArrayTable }}
scope={{
getContainer,
useCancelAction,
createOnly: true,
isOverride: false,
useSyncFromDatabase,
record,
...scope,
}}
/>
</ActionContext.Provider>
</RecordProvider>
)
);
};

View File

@ -13,7 +13,8 @@ export * from './AddCollectionAction';
export * from './EditCollectionAction'; export * from './EditCollectionAction';
export * from './ConfigurationTabs'; export * from './ConfigurationTabs';
export * from './AddCategoryAction'; export * from './AddCategoryAction';
export * from './EditCategoryAction' export * from './EditCategoryAction';
export * from './SyncFieldsAction';
registerValidateFormats({ registerValidateFormats({
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/, uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,

View File

@ -73,7 +73,7 @@ export const collectionFieldSchema: ISchema = {
params: { params: {
paginate: false, paginate: false,
filter: { filter: {
'interface.$not': null, $or: [{ 'interface.$not': null }, { 'options.source.$notEmpty': true }],
}, },
sort: ['sort'], sort: ['sort'],
// appends: ['uiSchema'], // appends: ['uiSchema'],
@ -106,6 +106,14 @@ export const collectionFieldSchema: ISchema = {
}, },
}, },
}, },
syncfromDatabase: {
type: 'void',
title: '{{ t("Sync from database") }}',
'x-component': 'SyncFieldsAction',
'x-component-props': {
type: 'primary',
},
},
create: { create: {
type: 'void', type: 'void',
title: '{{ t("Add new") }}', title: '{{ t("Add new") }}',

View File

@ -258,7 +258,8 @@ export const collectionTableSchema: ISchema = {
}, },
'x-reactions': (field) => { 'x-reactions': (field) => {
const i = field.path.segments[1]; const i = field.path.segments[1];
const table = field.form.getValuesIn(`table.${i}`); const key = field.path.segments[0];
const table = field.form.getValuesIn(`${key}.${i}`);
if (table) { if (table) {
field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`; field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`;
} }

View File

@ -62,7 +62,7 @@ export const useCollectionManager = () => {
return getParents(name); return getParents(name);
}; };
const getChildrenCollections = (name) => { const getChildrenCollections = (name, isSupportView = false) => {
const children = []; const children = [];
const getChildren = (name) => { const getChildren = (name) => {
const inheritCollections = collections.filter((v) => { const inheritCollections = collections.filter((v) => {
@ -73,6 +73,16 @@ export const useCollectionManager = () => {
children.push(v); children.push(v);
return getChildren(collectionKey); return getChildren(collectionKey);
}); });
if (isSupportView) {
const sourceCollections = collections.filter((v) => {
return v.sources?.length === 1 && v?.sources[0] === name;
});
sourceCollections.forEach((v) => {
const collectionKey = v.name;
children.push(v);
return getChildren(collectionKey);
});
}
return uniqBy(children, 'key'); return uniqBy(children, 'key');
}; };
return getChildren(name); return getChildren(name);

View File

@ -22,6 +22,7 @@ export const attachment: IField = {
}, },
}, },
}, },
availableTypes:['belongsToMany'],
schemaInitialize(schema: ISchema, { block }) { schemaInitialize(schema: ISchema, { block }) {
if (['Table', 'Kanban'].includes(block)) { if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {}; schema['x-component-props'] = schema['x-component-props'] || {};

View File

@ -15,6 +15,7 @@ export const checkbox: IField = {
'x-component': 'Checkbox', 'x-component': 'Checkbox',
}, },
}, },
availableTypes: ['boolean'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -17,6 +17,7 @@ export const checkboxGroup: IField = {
'x-component': 'Checkbox.Group', 'x-component': 'Checkbox.Group',
}, },
}, },
availableTypes:['array'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -34,6 +34,7 @@ export const chinaRegion: IField = {
}, },
}, },
}, },
availableTypes:['belongsToMany'],
initialize: (values: any) => { initialize: (values: any) => {
if (!values.through) { if (!values.through) {
values.through = `t_${uid()}`; values.through = `t_${uid()}`;

View File

@ -20,6 +20,7 @@ export const createdAt: IField = {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}, },
availableTypes:['date'],
properties: { properties: {
...defaultProps, ...defaultProps,
...dateTimeProps, ...dateTimeProps,

View File

@ -28,6 +28,7 @@ export const createdBy: IField = {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}, },
availableTypes:['belongsTo'],
properties: { properties: {
...defaultProps, ...defaultProps,
}, },

View File

@ -20,6 +20,7 @@ export const datetime: IField = {
}, },
}, },
}, },
availableTypes:['date'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -18,6 +18,7 @@ export const email: IField = {
'x-validator': 'email', 'x-validator': 'email',
}, },
}, },
availableTypes:['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -16,6 +16,7 @@ export const icon: IField = {
'x-component': 'IconPicker', 'x-component': 'IconPicker',
}, },
}, },
availableTypes:['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -21,6 +21,7 @@ export const id: IField = {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}, },
availableTypes:['bigInt','integer'],
properties: { properties: {
'uiSchema.title': { 'uiSchema.title': {
type: 'string', type: 'string',

View File

@ -18,6 +18,7 @@ export const input: IField = {
'x-component': 'Input', 'x-component': 'Input',
}, },
}, },
availableTypes:['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -29,6 +29,7 @@ export const integer: IField = {
'x-validator': 'integer', 'x-validator': 'integer',
}, },
}, },
availableTypes:['bigInt','integer'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -39,6 +39,7 @@ export const json: IField = {
default: null default: null
}, },
}, },
availableTypes:['json','array'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -45,6 +45,7 @@ export const linkTo: IField = {
}, },
}, },
}, },
availableTypes:['belongsToMany'],
schemaInitialize(schema: ISchema, { readPretty, block }) { schemaInitialize(schema: ISchema, { readPretty, block }) {
if (block === 'Form') { if (block === 'Form') {
if (schema['x-component'] === 'AssociationSelect') { if (schema['x-component'] === 'AssociationSelect') {

View File

@ -51,6 +51,7 @@ export const m2m: IField = {
}, },
}, },
}, },
availableTypes:['belongsToMany'],
schemaInitialize(schema: ISchema, { readPretty, block }) { schemaInitialize(schema: ISchema, { readPretty, block }) {
if (block === 'Form') { if (block === 'Form') {
if (schema['x-component'] === 'AssociationSelect') { if (schema['x-component'] === 'AssociationSelect') {

View File

@ -50,6 +50,7 @@ export const m2o: IField = {
}, },
}, },
}, },
availableTypes:['belongsTo'],
schemaInitialize(schema: ISchema, { block, readPretty }) { schemaInitialize(schema: ISchema, { block, readPretty }) {
if (block === 'Form') { if (block === 'Form') {
if (schema['x-component'] === 'AssociationSelect') { if (schema['x-component'] === 'AssociationSelect') {

View File

@ -17,6 +17,7 @@ export const markdown: IField = {
'x-component': 'Markdown', 'x-component': 'Markdown',
}, },
}, },
availableTypes:['text'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -21,6 +21,7 @@ export const multipleSelect: IField = {
enum: [], enum: [],
}, },
}, },
availableTypes:['array'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -22,6 +22,7 @@ export const number: IField = {
}, },
}, },
}, },
availableTypes:['double'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -50,6 +50,7 @@ export const o2m: IField = {
}, },
}, },
}, },
availableTypes:['hasMany'],
schemaInitialize(schema: ISchema, { field, block, readPretty }) { schemaInitialize(schema: ISchema, { field, block, readPretty }) {
if (block === 'Form') { if (block === 'Form') {
if (schema['x-component'] === 'TableField') { if (schema['x-component'] === 'TableField') {

View File

@ -117,6 +117,7 @@ export const o2o: IField = {
}, },
}, },
}, },
availableTypes:['hasOne'],
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) { schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
internalSchameInitialize(schema, { field, block, readPretty, action }); internalSchameInitialize(schema, { field, block, readPretty, action });
if (['Table', 'Kanban'].includes(block)) { if (['Table', 'Kanban'].includes(block)) {

View File

@ -18,6 +18,7 @@ export const password: IField = {
'x-component': 'Password', 'x-component': 'Password',
}, },
}, },
availableTypes:['password'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -13,7 +13,7 @@ registerValidateRules({
return { return {
type: 'error', type: 'error',
message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`, message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`,
} };
} }
} }
@ -22,7 +22,7 @@ registerValidateRules({
return { return {
type: 'error', type: 'error',
message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`, message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`,
} };
} }
} }
@ -36,12 +36,12 @@ registerValidateRules({
return { return {
type: 'error', type: 'error',
message: `${i18n.t('The field value is not an integer number')}`, message: `${i18n.t('The field value is not an integer number')}`,
} };
} }
return true; return true;
} },
}) });
// registerValidateFormats({ // registerValidateFormats({
// percentInteger: /^(\d+)(.\d{0,2})?$/, // percentInteger: /^(\d+)(.\d{0,2})?$/,
@ -68,6 +68,7 @@ export const percent: IField = {
}, },
}, },
}, },
availableTypes: ['float'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
@ -104,7 +105,9 @@ export const percent: IField = {
'x-reactions': `{{(field) => { 'x-reactions': `{{(field) => {
const targetValue = field.query('.minimum').value(); const targetValue = field.query('.minimum').value();
field.selfErrors = field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Maximum must greater than minimum')}' : '' !!targetValue && !!field.value && targetValue > field.value ? '${i18n.t(
'Maximum must greater than minimum',
)}' : ''
}}}`, }}}`,
}, },
minValue: { minValue: {
@ -119,7 +122,9 @@ export const percent: IField = {
dependencies: ['.maximum'], dependencies: ['.maximum'],
fulfill: { fulfill: {
state: { state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Minimum must less than maximum')}' : ''}}`, selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t(
'Minimum must less than maximum',
)}' : ''}}`,
}, },
}, },
}, },
@ -132,10 +137,12 @@ export const percent: IField = {
'x-component-props': { 'x-component-props': {
allowClear: true, allowClear: true,
}, },
enum: [{ enum: [
label: '{{ t("Integer") }}', {
value: 'Integer', label: '{{ t("Integer") }}',
}] value: 'Integer',
},
],
}, },
pattern: { pattern: {
type: 'string', type: 'string',
@ -145,8 +152,8 @@ export const percent: IField = {
'x-component-props': { 'x-component-props': {
prefix: '/', prefix: '/',
suffix: '/', suffix: '/',
} },
}, },
}; };
} },
}; };

View File

@ -21,6 +21,7 @@ export const phone: IField = {
// 'x-validator': 'phone', // 'x-validator': 'phone',
}, },
}, },
availableTypes: ['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -17,6 +17,7 @@ export const radioGroup: IField = {
'x-component': 'Radio.Group', 'x-component': 'Radio.Group',
}, },
}, },
availableTypes: ['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -18,6 +18,7 @@ export const richText: IField = {
'x-component': 'RichText', 'x-component': 'RichText',
}, },
}, },
availableTypes: ['text'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -18,6 +18,7 @@ export const select: IField = {
enum: [], enum: [],
}, },
}, },
availableTypes: ['string'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -20,6 +20,7 @@ export const subTable: IField = {
'x-component-props': {}, 'x-component-props': {},
}, },
}, },
availableTypes: ['hasMany'],
schemaInitialize(schema: ISchema, { field, readPretty }) { schemaInitialize(schema: ISchema, { field, readPretty }) {
const association = `${field.collectionName}.${field.name}`; const association = `${field.collectionName}.${field.name}`;
schema['type'] = 'void'; schema['type'] = 'void';

View File

@ -18,6 +18,7 @@ export const textarea: IField = {
'x-component': 'Input.TextArea', 'x-component': 'Input.TextArea',
}, },
}, },
availableTypes: ['text'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -16,6 +16,7 @@ export const time: IField = {
'x-component': 'TimePicker', 'x-component': 'TimePicker',
}, },
}, },
availableTypes: ['time'],
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,

View File

@ -20,6 +20,7 @@ export const updatedAt: IField = {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}, },
availableTypes: ['date'],
properties: { properties: {
...defaultProps, ...defaultProps,
...dateTimeProps, ...dateTimeProps,

View File

@ -27,6 +27,7 @@ export const updatedBy: IField = {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}, },
availableTypes: ['belongsTo'],
properties: { properties: {
...defaultProps, ...defaultProps,
}, },

View File

@ -0,0 +1,216 @@
import { Cascader } from '@formily/antd';
import { useField, useForm } from '@formily/react';
import { Input, Select, Spin, Table, Tag } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ResourceActionContext, useCompile } from '../../../';
import { useAPIClient } from '../../../api-client';
import { getOptions } from '../../Configuration/interfaces';
import { useCollectionManager } from '../../hooks/useCollectionManager';
const getInterfaceOptions = (data, type) => {
const interfaceOptions = [];
data.forEach((item) => {
const options = item.children.filter((h) => h?.availableTypes?.includes(type));
interfaceOptions.push({
label: item.label,
key: item.key,
children: options,
});
});
return interfaceOptions.filter((v) => v.children.length > 0);
};
const PreviewCom = (props) => {
const { name, sources, viewName, schema } = props;
const { data: fields } = useContext(ResourceActionContext);
const api = useAPIClient();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [dataSource, setDataSource] = useState([]);
const [sourceFields, setSourceFields] = useState([]);
const field: any = useField();
const form = useForm();
const { getCollection } = useCollectionManager();
const compile = useCompile();
const initOptions = getOptions().filter((v) => !['relation', 'systemInfo'].includes(v.key));
useEffect(() => {
const data = [];
sources.forEach((item) => {
const collection = getCollection(item);
const children = collection.fields?.map((v) => {
return { value: v.name, label: v.uiSchema?.title };
});
data.push({
value: item,
label: collection.title,
children,
});
});
setSourceFields(data);
}, [sources, name]);
useEffect(() => {
if (name) {
setLoading(true);
api
.resource(`dbViews`)
.get({ filterByTk: viewName, schema })
.then(({ data }) => {
if (data) {
setLoading(false);
setDataSource([]);
const fieldsData = Object.values(data?.data?.fields)?.map((v: any) => {
if (v.source) {
return v;
} else {
return fields?.data.find((h) => h.name === v.name) || v;
}
});
field.value = fieldsData;
setDataSource(fieldsData);
form.setValuesIn('sources', data.data?.sources);
}
});
}
}, [name]);
const handleFieldChange = (record, index) => {
dataSource.splice(index, 1, record);
setDataSource(dataSource);
field.value = dataSource.map((v) => {
return {
...v,
source: typeof v.source === 'string' ? v.source : v.source?.join('.'),
};
});
};
const columns = [
{
title: t('Field name'),
dataIndex: 'name',
key: 'name',
width: 130,
},
{
title: t('Field source'),
dataIndex: 'source',
key: 'source',
width: 200,
render: (text, record, index) => {
return (
<Cascader
defaultValue={typeof text === 'string' ? text?.split('.') : text}
allowClear
style={{ width: '100%' }}
options={compile(sourceFields)}
onChange={(value, selectedOptions) => {
handleFieldChange({ ...record, source: value }, index);
}}
placeholder={t('Select field source')}
/>
);
},
},
{
title: t('Field type'),
dataIndex: 'type',
width: 140,
key: 'type',
render: (text, _, index) => {
const item = dataSource[index];
return item?.source || !item?.possibleTypes ? (
<Tag>{text}</Tag>
) : (
<Select
defaultValue={text}
style={{ width: '100%' }}
options={
item?.possibleTypes.map((v) => {
return { label: v, value: v };
}) || []
}
onChange={(value) => handleFieldChange({ ...item, type: value }, index)}
/>
);
},
},
{
title: t('Field interface'),
dataIndex: 'interface',
key: 'interface',
width: 150,
render: (text, _, index) => {
const item = dataSource[index];
const data = getInterfaceOptions(initOptions, item.type);
return item.source ? (
text
) : (
<Select
defaultValue={text}
style={{ width: '100%' }}
onChange={(value) => handleFieldChange({ ...item, interface: value }, index)}
>
{data.map((group) => (
<Select.OptGroup key={group.key} label={compile(group.label)}>
{group.children.map((item) => (
<Select.Option key={item.value} value={item.value}>
{compile(item.label)}
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
);
},
},
{
title: t('Field display name'),
dataIndex: 'title',
key: 'title',
width: 180,
render: (text, record, index) => {
const item = dataSource[index];
return item.source ? (
record?.uiSchema?.title
) : (
<Input
defaultValue={record?.uiSchema?.title}
onChange={(e) => handleFieldChange({ ...item, uiSchema: { title: e.target.value } }, index)}
/>
);
},
},
];
return (
<Spin spinning={loading}>
{dataSource.length > 0 && (
<>
<div className="ant-formily-item-label">
<div className="ant-formily-item-label-content">
<span>
<label>{t('Fields')}</label>
</span>
</div>
<span className="ant-formily-item-colon">:</span>
</div>
<Table
bordered
size={'middle'}
columns={columns}
dataSource={dataSource}
scroll={{ y: 300 }}
pagination={false}
rowClassName="editable-row"
key={name}
/>
</>
)}
</Spin>
);
};
function areEqual(prevProps, nextProps) {
return nextProps.name === prevProps.name && nextProps.sources === prevProps.sources;
}
export const PreviewFields = React.memo(PreviewCom, areEqual);

View File

@ -0,0 +1,105 @@
import { RecursionField, useForm } from '@formily/react';
import { Spin, Table } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EllipsisWithTooltip, useCompile } from '../../../';
import { useAPIClient } from '../../../api-client';
import { useCollectionManager } from '../../hooks/useCollectionManager';
export const PreviewTable = (props) => {
const { name, viewName, schema, fields } = props;
const [previewColumns, setPreviewColumns] = useState([]);
const [previewData, setPreviewData] = useState([]);
const compile = useCompile();
const [loading, setLoading] = useState(false);
const { getCollection, getCollectionField, getInterface } = useCollectionManager();
const api = useAPIClient();
const { t } = useTranslation();
const form = useForm();
useEffect(() => {
if (name) {
getPreviewData();
}
}, [name]);
useEffect(() => {
const pColumns = formatPreviewColumns(fields);
setPreviewColumns(pColumns);
}, [form.values.fields]);
const getPreviewData = () => {
setLoading(true);
api
.resource(`dbViews`)
.query({ filterByTk: viewName, schema })
.then(({ data }) => {
if (data) {
setLoading(false);
setPreviewData(data?.data || []);
}
});
};
const formatPreviewColumns = (data) => {
return data
.filter((k) => k.source || k.interface)
?.map((item) => {
const fieldSource = typeof item?.source === 'string' ? item?.source?.split('.') : item?.source;
const sourceField = getCollection(fieldSource?.[0])?.fields.find((v) => v.name === fieldSource?.[1])?.uiSchema
?.title;
const target = sourceField || item?.uiSchema?.title || item.name;
const schema: any = item.source
? getCollectionField(typeof item.source === 'string' ? item.source : item.source.join('.'))?.uiSchema
: getInterface(item.interface)?.default?.uiSchema;
return {
title: compile(target),
dataIndex: item.name,
key: item.name,
width: 200,
render: (v, record, index) => {
const content = record[item.name];
const objSchema: any = {
type: 'object',
properties: {
[item.name]: { ...schema, default: content, 'x-read-pretty': true, title: null },
},
};
return (
<EllipsisWithTooltip ellipsis={true}>
<RecursionField schema={objSchema} name={index} onlyRenderProperties />
</EllipsisWithTooltip>
);
},
};
});
};
return (
<Spin spinning={loading}>
<div
style={{
marginBottom: 22,
}}
>
{previewColumns?.length > 0 && [
<div className="ant-formily-item-label" style={{ marginTop: 24 }}>
<div className="ant-formily-item-label-content">
<span>
<label>{t('Preview')}</label>
</span>
</div>
<span className="ant-formily-item-colon">:</span>
</div>,
<Table
size={'middle'}
pagination={false}
bordered
columns={previewColumns}
dataSource={previewData}
scroll={{ x: 1000, y: 300 }}
key={name}
/>,
]}
</div>
</Spin>
);
};

View File

@ -1,4 +1,4 @@
export * from './calendar'; export * from './calendar';
export * from './general'; export * from './general';
export * from './tree'; export * from './tree';
export * from './view';

View File

@ -14,6 +14,8 @@ export interface ICollectionTemplate {
configurableProperties?: Record<string, ISchema>; configurableProperties?: Record<string, ISchema>;
/** 当前模板可用的字段类型 */ /** 当前模板可用的字段类型 */
availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude; availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude;
/** 是否分割线 */
divider?: boolean;
} }
interface AvailableFieldInterfacesInclude { interface AvailableFieldInterfacesInclude {
@ -24,7 +26,6 @@ interface AvailableFieldInterfacesExclude {
exclude?: any[]; exclude?: any[];
} }
interface CollectionOptions { interface CollectionOptions {
/** /**
* id * id

View File

@ -0,0 +1,106 @@
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
import { PreviewFields } from './components/PreviewFields';
import { PreviewTable } from './components/PreviewTable';
export const view: ICollectionTemplate = {
name: 'view',
title: '{{t("Connect to database view")}}',
order: 4,
color: 'yellow',
default: {
fields: [],
},
divider: true,
configurableProperties: {
title: {
type: 'string',
title: '{{ t("Collection display name") }}',
required: true,
'x-decorator': 'FormItem',
'x-component': 'Input',
},
name: {
title: '{{t("Connect to database view")}}',
type: 'single',
required: true,
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-reactions': ['{{useAsyncDataSource(loadDBViews)}}'],
'x-disabled': '{{ !createOnly }}',
},
schema: {
type: 'string',
'x-hidden': true,
'x-reactions': {
dependencies: ['name'],
when: "{{isPG}}",
fulfill: {
state: {
value: "{{$deps[0].split('_')?.[0]}}",
},
},
otherwise: {
state: {
value: null,
},
},
},
},
viewName: {
type: 'string',
'x-hidden': true,
'x-reactions': {
dependencies: ['name'],
when: "{{isPG}}",
fulfill: {
state: {
value: '{{$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}',
},
},
otherwise: {
state: {
value: '{{$deps[0]}}',
},
},
},
},
sources: {
type: 'array',
title: '{{ t("Source collections") }}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
'x-disabled': true,
},
fields: {
type: 'array',
'x-component': PreviewFields,
'x-reactions': {
dependencies: ['name'],
fulfill: {
schema: {
'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式
},
},
},
},
preview: {
type: 'object',
'x-component': PreviewTable,
'x-reactions': {
dependencies: ['name','fields'],
fulfill: {
schema: {
'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式
},
},
},
},
...getConfigurableProperties('category'),
},
};

View File

@ -180,6 +180,10 @@ export default {
"Collection template": "Collection template", "Collection template": "Collection template",
"Calendar collection": "Calendar collection", "Calendar collection": "Calendar collection",
"General collection": "General collection", "General collection": "General collection",
"Connect to database view":"Connect to database view",
"Source collections":"Source collections",
"Field source":"Field source",
"Preview":"Preview",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
"Storage type": "Storage type", "Storage type": "Storage type",
"Edit": "Edit", "Edit": "Edit",

View File

@ -171,6 +171,10 @@ export default {
"Collection template":"データテーブルテンプレート", "Collection template":"データテーブルテンプレート",
"Calendar collection":"カレンダデータテーブル", "Calendar collection":"カレンダデータテーブル",
"General collection":"一般データテーブル", "General collection":"一般データテーブル",
"Connect to database view":"ビューに接続",
"Source collections":"ソースデータセット",
"Field source":"ソースフィールド",
"Preview":"プレビュー",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。",
"Storage type": "ストレージタイプ", "Storage type": "ストレージタイプ",
"Edit": "編集", "Edit": "編集",

View File

@ -188,6 +188,10 @@ export default {
"Collection template": "数据表模板", "Collection template": "数据表模板",
"Calendar collection": "日历数据表", "Calendar collection": "日历数据表",
"General collection": "普通数据表", "General collection": "普通数据表",
"Connect to database view":"连接数据库视图",
"Source collections":"来源数据表",
"Field source":"来源字段",
"Preview":"预览",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
"Storage type": "存储类型", "Storage type": "存储类型",
"Types will be used in database": "数据库使用的类型", "Types will be used in database": "数据库使用的类型",

View File

@ -1,3 +1,5 @@
import { useCollection } from '../../';
// 日历的操作配置 // 日历的操作配置
export const CalendarActionInitializers = { export const CalendarActionInitializers = {
title: '{{t("Configure actions")}}', title: '{{t("Configure actions")}}',
@ -72,6 +74,10 @@ export const CalendarActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}, },

View File

@ -1,3 +1,5 @@
import { useCollection } from '../..';
// 表单的操作配置 // 表单的操作配置
export const CalendarFormActionInitializers = { export const CalendarFormActionInitializers = {
title: '{{t("Configure actions")}}', title: '{{t("Configure actions")}}',
@ -21,6 +23,10 @@ export const CalendarFormActionInitializers = {
type: 'primary', type: 'primary',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -30,6 +36,10 @@ export const CalendarFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -39,6 +49,10 @@ export const CalendarFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -130,6 +144,10 @@ export const CalendarFormActionInitializers = {
useProps: '{{ useCustomizeUpdateActionProps }}', useProps: '{{ useCustomizeUpdateActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -153,6 +171,10 @@ export const CalendarFormActionInitializers = {
useProps: '{{ useCustomizeRequestActionProps }}', useProps: '{{ useCustomizeRequestActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}, },

View File

@ -1,3 +1,5 @@
import { useCollection } from '../../';
export const KanbanActionInitializers = { export const KanbanActionInitializers = {
title: "{{t('Configure actions')}}", title: "{{t('Configure actions')}}",
icon: 'SettingOutlined', icon: 'SettingOutlined',
@ -28,6 +30,10 @@ export const KanbanActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}, },

View File

@ -1,3 +1,5 @@
import { useCollection } from '../..';
// 表单的操作配置 // 表单的操作配置
export const ReadPrettyFormActionInitializers = { export const ReadPrettyFormActionInitializers = {
title: '{{t("Configure actions")}}', title: '{{t("Configure actions")}}',
@ -21,6 +23,10 @@ export const ReadPrettyFormActionInitializers = {
type: 'primary', type: 'primary',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -30,6 +36,10 @@ export const ReadPrettyFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -122,6 +132,10 @@ export const ReadPrettyFormActionInitializers = {
useProps: '{{ useCustomizeUpdateActionProps }}', useProps: '{{ useCustomizeUpdateActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -145,6 +159,10 @@ export const ReadPrettyFormActionInitializers = {
useProps: '{{ useCustomizeRequestActionProps }}', useProps: '{{ useCustomizeRequestActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}, },

View File

@ -166,8 +166,11 @@ export const RecordBlockInitializers = (props: any) => {
const { insertPosition, component, actionInitializers } = props; const { insertPosition, component, actionInitializers } = props;
const collection = useCollection(); const collection = useCollection();
const { getChildrenCollections } = useCollectionManager(); const { getChildrenCollections } = useCollectionManager();
const childrenCollections = getChildrenCollections(collection.name); const formChildrenCollections = getChildrenCollections(collection.name);
const hasChildCollection = childrenCollections?.length > 0; const hasFormChildCollection = formChildrenCollections?.length > 0;
const detailChildrenCollections = getChildrenCollections(collection.name, true);
const hasDetailChildCollection = detailChildrenCollections?.length > 0;
const modifyFlag = (collection as any).template !== 'view';
return ( return (
<SchemaInitializer.Button <SchemaInitializer.Button
wrap={gridRowColWrap} wrap={gridRowColWrap}
@ -179,36 +182,43 @@ export const RecordBlockInitializers = (props: any) => {
{ {
type: 'itemGroup', type: 'itemGroup',
title: '{{t("Current record blocks")}}', title: '{{t("Current record blocks")}}',
children: hasChildCollection children: [
? [ hasDetailChildCollection
{ ? {
key: 'details', key: 'details',
type: 'subMenu', type: 'subMenu',
title: '{{t("Details")}}', title: '{{t("Details")}}',
children: useDetailCollections({ ...props, childrenCollections, collection }), children: useDetailCollections({
}, ...props,
{ childrenCollections: detailChildrenCollections,
key: 'form', collection,
type: 'subMenu', }),
title: '{{t("Form")}}', }
children: useFormCollections({ ...props, childrenCollections, collection }), : {
},
]
: [
{
key: 'details', key: 'details',
type: 'item', type: 'item',
title: '{{t("Details")}}', title: '{{t("Details")}}',
component: 'RecordReadPrettyFormBlockInitializer', component: 'RecordReadPrettyFormBlockInitializer',
actionInitializers, actionInitializers,
}, },
{ hasFormChildCollection
? {
key: 'form',
type: 'subMenu',
title: '{{t("Form")}}',
children: useFormCollections({
...props,
childrenCollections: formChildrenCollections,
collection,
}),
}
: modifyFlag && {
key: 'form', key: 'form',
type: 'item', type: 'item',
title: '{{t("Form")}}', title: '{{t("Form")}}',
component: 'RecordFormBlockInitializer', component: 'RecordFormBlockInitializer',
}, },
], ],
}, },
{ {
type: 'itemGroup', type: 'itemGroup',

View File

@ -51,7 +51,8 @@ export const TableActionColumnInitializers = (props: any) => {
const { refresh } = useDesignable(); const { refresh } = useDesignable();
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'}
@ -98,8 +99,12 @@ export const TableActionColumnInitializers = (props: any) => {
'x-action': 'update', 'x-action': 'update',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ modifyFlag && {
type: 'item', type: 'item',
title: t('Delete'), title: t('Delete'),
component: 'DestroyActionInitializer', component: 'DestroyActionInitializer',
@ -202,6 +207,10 @@ export const TableActionColumnInitializers = (props: any) => {
useProps: '{{ useCustomizeUpdateActionProps }}', useProps: '{{ useCustomizeUpdateActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -224,6 +233,10 @@ export const TableActionColumnInitializers = (props: any) => {
useProps: '{{ useCustomizeRequestActionProps }}', useProps: '{{ useCustomizeRequestActionProps }}',
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}, },

View File

@ -32,6 +32,10 @@ export const TableActionInitializers = {
skipScopeCheck: true, skipScopeCheck: true,
}, },
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -41,6 +45,10 @@ export const TableActionInitializers = {
'x-align': 'right', 'x-align': 'right',
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -60,7 +68,7 @@ export const TableActionInitializers = {
visible: () => { visible: () => {
const schema = useFieldSchema(); const schema = useFieldSchema();
const collection = useCollection(); const collection = useCollection();
const { treeTable } = schema?.parent?.['x-decorator-props']||{}; const { treeTable } = schema?.parent?.['x-decorator-props'] || {};
return collection.tree && treeTable !== false; return collection.tree && treeTable !== false;
}, },
}, },
@ -68,6 +76,10 @@ export const TableActionInitializers = {
}, },
{ {
type: 'divider', type: 'divider',
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'item', type: 'item',
@ -82,9 +94,17 @@ export const TableActionInitializers = {
)?.[1]; )?.[1];
return resultSchema; return resultSchema;
}, },
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'divider', type: 'divider',
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
{ {
type: 'subMenu', type: 'subMenu',
@ -135,6 +155,10 @@ export const TableActionInitializers = {
}, },
}, },
], ],
visible: () => {
const collection = useCollection();
return (collection as any).template !== 'view';
},
}, },
], ],
}; };

View File

@ -1,9 +1,9 @@
import React from "react"; import React from 'react';
import { TableOutlined } from '@ant-design/icons'; import { TableOutlined } from '@ant-design/icons';
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';
export const DetailsBlockInitializer = (props) => { export const DetailsBlockInitializer = (props) => {
const { insert } = props; const { insert } = props;
@ -15,7 +15,11 @@ export const DetailsBlockInitializer = (props) => {
componentType={'Details'} componentType={'Details'}
onCreateBlockSchema={async ({ item }) => { onCreateBlockSchema={async ({ item }) => {
const collection = getCollection(item.name); const collection = getCollection(item.name);
const schema = createDetailsBlockSchema({ collection: item.name, rowKey: collection.filterTargetKey || 'id' }); const schema = createDetailsBlockSchema({
collection: item.name,
rowKey: collection.filterTargetKey || 'id',
actionInitializers: collection.template !== 'view' && 'DetailsActionInitializers',
});
insert(schema); insert(schema);
}} }}
/> />

View File

@ -1,9 +1,8 @@
import React from "react"; import React from 'react';
import { TableOutlined } from '@ant-design/icons'; import { TableOutlined } from '@ant-design/icons';
import { useCollectionManager } from '../../collection-manager';
import { useCollectionManager } from "../../collection-manager"; import { DataBlockInitializer } from './DataBlockInitializer';
import { DataBlockInitializer } from "./DataBlockInitializer"; import { createTableBlockSchema } from '../utils';
import { createTableBlockSchema } from "../utils";
export const TableBlockInitializer = (props) => { export const TableBlockInitializer = (props) => {
const { insert } = props; const { insert } = props;
@ -15,7 +14,10 @@ export const TableBlockInitializer = (props) => {
componentType={'Table'} componentType={'Table'}
onCreateBlockSchema={async ({ item }) => { onCreateBlockSchema={async ({ item }) => {
const collection = getCollection(item.name); const collection = getCollection(item.name);
const schema = createTableBlockSchema({ collection: item.name, rowKey: collection.filterTargetKey || 'id' }); const schema = createTableBlockSchema({
collection: item.name,
rowKey: collection.filterTargetKey || 'id',
});
insert(schema); insert(schema);
}} }}
/> />

View File

@ -705,6 +705,8 @@ export const useCollectionDataSourceItems = (componentName) => {
return false; return false;
} else if (item.autoGenId === false && !item.fields.find((v) => v.primaryKey)) { } else if (item.autoGenId === false && !item.fields.find((v) => v.primaryKey)) {
return false; return false;
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view') {
return false;
} else { } else {
return b && !(item?.isThrough && item?.autoCreate); return b && !(item?.isThrough && item?.autoCreate);
} }

View File

@ -311,6 +311,20 @@ describe('repository.update', () => {
name: 'post1', name: 'post1',
userId: user.id, userId: user.id,
}); });
await User.repository.update({
filterByTk: user.id,
values: {
posts: [{ name: 'post2' }, { name: 'post3' }],
},
});
const updated2 = await User.repository.findOne({
filterByTk: user.id,
appends: ['posts'],
});
expect(updated2.posts.length).toBe(2);
}); });
it('update2', async () => { it('update2', async () => {

View File

@ -0,0 +1,13 @@
import sqlParser from '../sql-parser';
describe('sql parser', () => {
it('should parse sql', function () {
const sql = `select users.id as id from users`;
const { ast } = sqlParser.parse(sql);
const columns = ast.columns;
const firstColumn = columns[0];
expect(firstColumn['expr']['table']).toEqual('users');
expect(firstColumn['expr']['column']).toEqual('id');
});
});

View File

@ -0,0 +1,34 @@
import { Database, mockDatabase } from '@nocobase/database';
describe('list view', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
tablePrefix: '',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should list view', async () => {
const dropViewSQL1 = `DROP VIEW IF EXISTS test1`;
await db.sequelize.query(dropViewSQL1);
const dropViewSQL2 = `DROP VIEW IF EXISTS test2`;
await db.sequelize.query(dropViewSQL2);
const sql1 = `CREATE VIEW test1 AS SELECT 1`;
const sql2 = `CREATE VIEW test2 AS SELECT 2`;
await db.sequelize.query(sql1);
await db.sequelize.query(sql2);
const results = await db.queryInterface.listViews();
expect(results.find((item) => item.name === 'test1')).toBeTruthy();
expect(results.find((item) => item.name === 'test2')).toBeTruthy();
});
});

View File

@ -0,0 +1,199 @@
import { Database, mockDatabase } from '@nocobase/database';
import { uid } from '@nocobase/utils';
import { ViewCollection } from '../../view-collection';
describe('create view', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
tablePrefix: '',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should create view collection in difference schema', async () => {
if (!db.inDialect('postgres')) return;
const schemaName = `t_${uid(6)}`;
const testSchemaSql = `CREATE SCHEMA IF NOT EXISTS ${schemaName};`;
await db.sequelize.query(testSchemaSql);
const viewName = 'test_view';
const viewSQL = `CREATE OR REPLACE VIEW ${schemaName}.test_view AS SELECT 1+1 as result`;
await db.sequelize.query(viewSQL);
const viewCollection = db.collection({
name: viewName,
schema: schemaName,
view: true,
fields: [
{
type: 'string',
name: 'result',
},
],
});
const results = await viewCollection.repository.find();
expect(results.length).toBe(1);
});
it('should create view collection', async () => {
const UserCollection = db.collection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'hasOne',
name: 'profile',
foreignKey: 'user_id',
},
],
});
const ProfileCollection = db.collection({
name: 'profiles',
fields: [
{
type: 'integer',
name: 'age',
},
{
type: 'belongsTo',
name: 'user',
foreignKey: 'user_id',
},
],
});
await db.sync();
await UserCollection.repository.create({
values: {
name: 'foo',
profile: {
age: 18,
},
},
});
const schema = UserCollection.collectionSchema();
const viewName = 'users_with_profile';
const appendSchema = db.inDialect('postgres') ? `"${schema}".` : '';
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;`;
await db.sequelize.query(viewSql);
db.collection({
name: viewName,
view: true,
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'integer',
name: 'age',
},
],
});
const UserWithProfileView = db.getCollection(viewName);
expect(UserWithProfileView).toBeInstanceOf(ViewCollection);
const fooData = await UserWithProfileView.repository.findOne({
filter: {
name: 'foo',
},
});
console.log(fooData);
expect(fooData.get('name')).toBe('foo');
expect(fooData.get('age')).toBe(18);
});
it('should not sync view collection', async () => {
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
await db.sequelize.query(dropViewSQL);
const viewSql = `CREATE VIEW test_view AS SELECT 1+1 as result`;
await db.sequelize.query(viewSql);
const viewCollection = db.collection({
name: 'view_collection',
viewName: 'test_view',
fields: [
{
type: 'string',
name: 'result',
},
],
});
const jestFn = jest.fn();
db.on('beforeSync', jestFn);
await viewCollection.sync();
expect(jestFn).not.toBeCalled();
});
it('should create view collection with source field options', async () => {
const UserCollection = db.collection({
name: 'users',
fields: [
{
name: 'name',
type: 'string',
patterns: [
{
type: 'integer',
options: { key: 1 },
},
],
},
],
});
await db.sync();
const viewName = 'users_view';
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
await db.sequelize.query(dropViewSQL);
const viewSQL = `
CREATE VIEW ${viewName} as SELECT users.* FROM ${UserCollection.quotedTableName()} as users
`;
await db.sequelize.query(viewSQL);
// create view collection
const ViewCollection = db.collection({
name: viewName,
view: true,
fields: [
{
name: 'name',
type: 'string',
source: 'users.name',
},
],
});
const viewNameField = ViewCollection.getField('name');
expect(viewNameField.options.patterns).toEqual(UserCollection.getField('name').options.patterns);
});
});

View File

@ -0,0 +1,103 @@
import { Database, mockDatabase } from '@nocobase/database';
import { ViewFieldInference } from '../../view/view-inference';
describe('view inference', function () {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
tablePrefix: '',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should infer collection fields', async () => {
const UserCollection = db.collection({
name: 'users',
fields: [
{
name: 'name',
type: 'string',
interface: 'test',
},
{
name: 'age',
type: 'integer',
interface: 'test',
},
{
name: 'profile',
type: 'json',
interface: 'test',
},
{
name: 'posts',
type: 'hasMany',
interface: 'test',
},
],
});
const PostCollection = db.collection({
name: 'posts',
fields: [
{
name: 'title',
type: 'string',
interface: 'test',
},
{
name: 'user',
type: 'belongsTo',
interface: 'test',
},
],
});
await db.sync();
const viewName = 'user_posts';
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
await db.sequelize.query(dropViewSQL);
const viewSQL = `
CREATE VIEW ${viewName} as SELECT 1 as const_field, users.* FROM ${UserCollection.quotedTableName()} as users
`;
await db.sequelize.query(viewSQL);
const inferredFields = await ViewFieldInference.inferFields({
db,
viewName,
viewSchema: 'public',
});
const createdAt = UserCollection.model.rawAttributes['createdAt'].field;
expect(inferredFields[createdAt]['type']).toBe('date');
if (db.options.dialect == 'sqlite') {
expect(inferredFields['name']).toMatchObject({
name: 'name',
type: 'string',
});
} else {
expect(inferredFields['name']).toMatchObject({
name: 'name',
type: 'string',
source: 'users.name',
});
expect(inferredFields['const_field']).toMatchObject({
name: 'const_field',
type: 'integer',
});
}
await db.sequelize.query(dropViewSQL);
});
});

View File

@ -0,0 +1,67 @@
import { Database, mockDatabase } from '@nocobase/database';
import { uid } from '@nocobase/utils';
describe('view repository', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
tablePrefix: '',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should support find view without primary key', async () => {
const UserCollection = await db.collection({
name: 'users',
fields: [
{
type: 'string',
name: 'name',
},
],
});
await db.sync();
await UserCollection.repository.create({
values: [{ name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }],
});
const viewName = `t_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${viewName};`;
await db.sequelize.query(dropSQL);
const viewSQL = `CREATE VIEW ${viewName} AS select id as aaa, name from ${UserCollection.quotedTableName()}`;
await db.sequelize.query(viewSQL);
const viewCollection = db.collection({
name: viewName,
view: true,
schema: db.inDialect('postgres') ? 'public' : undefined,
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'integer',
name: 'aaa',
},
],
});
const results = await viewCollection.repository.findAndCount({
offset: 1,
limit: 1,
});
expect(results[0].length).toBe(1);
expect(results[1]).toBe(4);
});
});

View File

@ -34,6 +34,8 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
tableName?: string; tableName?: string;
inherits?: string[] | string; inherits?: string[] | string;
viewName?: string;
filterTargetKey?: string; filterTargetKey?: string;
fields?: FieldOptions[]; fields?: FieldOptions[];
model?: string | ModelStatic<Model>; model?: string | ModelStatic<Model>;
@ -69,7 +71,12 @@ export class Collection<
repository: Repository<TModelAttributes, TCreationAttributes>; repository: Repository<TModelAttributes, TCreationAttributes>;
get filterTargetKey() { get filterTargetKey() {
return lodash.get(this.options, 'filterTargetKey', this.model.primaryKeyAttribute); let targetKey = lodash.get(this.options, 'filterTargetKey', this.model.primaryKeyAttribute);
if (!targetKey && this.model.rawAttributes['id']) {
return 'id';
}
return targetKey;
} }
get name() { get name() {
@ -111,7 +118,11 @@ export class Collection<
this.modelInit(); this.modelInit();
this.db.modelCollection.set(this.model, this); this.db.modelCollection.set(this.model, this);
this.db.tableNameCollectionMap.set(this.model.tableName, this);
// set tableName to collection map
// the form of key is `${schema}.${tableName}` if schema exists
// otherwise is `${tableName}`
this.db.tableNameCollectionMap.set(this.getTableNameWithSchemaAsString(), this);
if (!options.inherits) { if (!options.inherits) {
this.setFields(options.fields); this.setFields(options.fields);
@ -141,7 +152,7 @@ export class Collection<
return this.db.options.underscored ? snakeCase(tName) : tName; return this.db.options.underscored ? snakeCase(tName) : tName;
} }
private sequelizeModelOptions() { protected sequelizeModelOptions() {
const { name } = this.options; const { name } = this.options;
return { return {
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']), ..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
@ -261,6 +272,17 @@ export class Collection<
this.checkFieldType(name, options); this.checkFieldType(name, options);
const { database } = this.context; const { database } = this.context;
if (options.source) {
const [sourceCollectionName, sourceFieldName] = options.source.split('.');
const sourceCollection = this.db.collections.get(sourceCollectionName);
if (!sourceCollection) {
throw new Error(`source collection "${sourceCollectionName}" not found`);
}
const sourceField = sourceCollection.fields.get(sourceFieldName);
options = { ...sourceField.options, ...options };
}
this.emit('field.beforeAdd', name, options, { collection: this }); this.emit('field.beforeAdd', name, options, { collection: this });
const field = database.buildField( const field = database.buildField(
@ -334,9 +356,10 @@ export class Collection<
async removeFromDb(options?: QueryInterfaceDropTableOptions) { async removeFromDb(options?: QueryInterfaceDropTableOptions) {
if ( if (
await this.existsInDb({ !this.isView() &&
(await this.existsInDb({
transaction: options?.transaction, transaction: options?.transaction,
}) }))
) { ) {
const queryInterface = this.db.sequelize.getQueryInterface(); const queryInterface = this.db.sequelize.getQueryInterface();
await queryInterface.dropTable(this.getTableNameWithSchema(), options); await queryInterface.dropTable(this.getTableNameWithSchema(), options);
@ -575,13 +598,23 @@ export class Collection<
public getTableNameWithSchema() { public getTableNameWithSchema() {
const tableName = this.model.tableName; const tableName = this.model.tableName;
if (this.collectionSchema()) { if (this.collectionSchema() && this.db.inDialect('postgres')) {
return this.db.utils.addSchema(tableName, this.collectionSchema()); return this.db.utils.addSchema(tableName, this.collectionSchema());
} }
return tableName; return tableName;
} }
public getTableNameWithSchemaAsString() {
const tableName = this.model.tableName;
if (this.collectionSchema() && this.db.inDialect('postgres')) {
return `${this.collectionSchema()}.${tableName}`;
}
return tableName;
}
public quotedTableName() { public quotedTableName() {
return this.db.utils.quoteTable(this.getTableNameWithSchema()); return this.db.utils.quoteTable(this.getTableNameWithSchema());
} }
@ -601,4 +634,8 @@ export class Collection<
return undefined; return undefined;
} }
public isView() {
return false;
}
} }

View File

@ -5,6 +5,7 @@ export default class DatabaseUtils {
constructor(public db: Database) {} constructor(public db: Database) {}
addSchema(tableName, schema?) { addSchema(tableName, schema?) {
if (!this.db.inDialect('postgres')) return tableName;
if (this.db.options.schema && !schema) { if (this.db.options.schema && !schema) {
schema = this.db.options.schema; schema = this.db.options.schema;
} }

View File

@ -15,7 +15,7 @@ import {
Sequelize, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils Utils,
} 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';
@ -58,7 +58,7 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener ValidateListener,
} from './types'; } from './types';
import { patchSequelizeQueryInterface, snakeCase } from './utils'; import { patchSequelizeQueryInterface, snakeCase } from './utils';
@ -69,6 +69,7 @@ import buildQueryInterface from './query-interface/query-interface-builder';
import QueryInterface from './query-interface/query-interface'; import QueryInterface from './query-interface/query-interface';
import { Logger } from '@nocobase/logger'; import { Logger } from '@nocobase/logger';
import { CollectionGroupManager } from './collection-group-manager'; import { CollectionGroupManager } from './collection-group-manager';
import { ViewCollection } from './view-collection';
export interface MergeOptions extends merge.Options {} export interface MergeOptions extends merge.Options {}
@ -221,7 +222,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
this.options = opts; this.options = opts;
this.sequelize = new Sequelize(opts); this.sequelize = new Sequelize(this.sequelizeOptions(this.options));
this.queryInterface = buildQueryInterface(this); this.queryInterface = buildQueryInterface(this);
@ -297,6 +298,17 @@ export class Database extends EventEmitter implements AsyncEmitter {
this.logger = logger; this.logger = logger;
} }
sequelizeOptions(options) {
if (options.dialect === 'postgres') {
options.hooks = {
afterConnect: async (connection) => {
await connection.query('SET search_path TO public;');
},
};
}
return options;
}
initListener() { initListener() {
this.on('beforeDefine', (model, options) => { this.on('beforeDefine', (model, options) => {
if (this.options.underscored) { if (this.options.underscored) {
@ -416,14 +428,21 @@ export class Database extends EventEmitter implements AsyncEmitter {
return options.inherits && lodash.castArray(options.inherits).length > 0; return options.inherits && lodash.castArray(options.inherits).length > 0;
})(); })();
const collection = hasValidInheritsOptions const hasViewOptions = options.viewName || options.view;
? new InheritedCollection(options, {
database: this,
})
: new Collection(options, {
database: this,
});
const collectionKlass = (() => {
if (hasValidInheritsOptions) {
return InheritedCollection;
}
if (hasViewOptions) {
return ViewCollection;
}
return Collection;
})();
const collection = new collectionKlass(options, { database: this });
this.collections.set(collection.name, collection); this.collections.set(collection.name, collection);
this.emit('afterDefineCollection', collection); this.emit('afterDefineCollection', collection);
@ -647,7 +666,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
return; return;
} }
await this.sequelize.getQueryInterface().dropAllTables(others); await this.queryInterface.dropAll(options);
} }
async collectionExistsInDb(name: string, options?: Transactionable) { async collectionExistsInDb(name: string, options?: Transactionable) {

View File

@ -157,6 +157,11 @@ export abstract class Field {
// return; // return;
// } // }
if (this.collection.isView()) {
this.remove();
return;
}
const columnReferencesCount = _.filter( const columnReferencesCount = _.filter(
this.collection.model.rawAttributes, this.collection.model.rawAttributes,
(attr) => attr.field == this.columnName(), (attr) => attr.field == this.columnName(),
@ -232,6 +237,7 @@ export abstract class Field {
if (this.dataType) { if (this.dataType) {
Object.assign(opts, { type: this.dataType }); Object.assign(opts, { type: this.dataType });
} }
return opts; return opts;
} }

View File

@ -20,6 +20,7 @@ export class JsonbField extends Field {
return DataTypes.JSON; return DataTypes.JSON;
} }
} }
export interface JsonbFieldOptions extends BaseColumnFieldOptions { export interface JsonbFieldOptions extends BaseColumnFieldOptions {
type: 'jsonb'; type: 'jsonb';
} }

View File

@ -21,3 +21,5 @@ export * from './update-associations';
export { snakeCase } from './utils'; export { snakeCase } from './utils';
export * from './value-parsers'; export * from './value-parsers';
export * from './collection-group-manager'; export * from './collection-group-manager';
export * from './view-collection';
export * from './view/view-inference';

View File

@ -14,7 +14,7 @@ export class MockDatabase extends Database {
} }
export function getConfigByEnv() { export function getConfigByEnv() {
return { const options = {
username: process.env.DB_USER, username: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE, database: process.env.DB_DATABASE,
@ -33,10 +33,14 @@ export function getConfigByEnv() {
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
underscored: process.env.DB_UNDERSCORED === 'true', underscored: process.env.DB_UNDERSCORED === 'true',
schema: process.env.DB_SCHEMA !== 'public' ? process.env.DB_SCHEMA : undefined, schema: process.env.DB_SCHEMA !== 'public' ? process.env.DB_SCHEMA : undefined,
dialectOptions: { dialectOptions: {},
application_name: process.env.DB_DIALECT == 'postgres' ? 'nocobase.main' : undefined,
},
}; };
if (process.env.DB_DIALECT == 'postgres') {
options.dialectOptions['application_name'] = 'nocobase.main';
}
return options;
} }
function customLogger(queryString, queryObject) { function customLogger(queryString, queryObject) {

View File

@ -151,6 +151,10 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
} }
static async sync(options) { static async sync(options) {
if (this.collection.isView()) {
return;
}
const model = this as any; const model = this as any;
const _schema = model._schema; const _schema = model._schema;

View File

@ -109,11 +109,21 @@ export class OptionsParser {
return filterParams; return filterParams;
} }
protected inheritFromSubQuery(): any { protected inheritFromSubQuery(include): any {
return [ include.push([
Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`), Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`),
'__tableName', '__tableName',
]; ]);
include.push([
Sequelize.literal(`
(SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = "${this.collection.name}".tableoid)
`),
'__schemaName',
]);
} }
protected parseFields(filterParams: any) { protected parseFields(filterParams: any) {
@ -132,7 +142,7 @@ export class OptionsParser {
}; // out put all fields by default }; // out put all fields by default
if (this.collection.isParent()) { if (this.collection.isParent()) {
attributes.include.push(this.inheritFromSubQuery()); this.inheritFromSubQuery(attributes.include);
} }
if (this.options?.fields) { if (this.options?.fields) {
@ -146,7 +156,7 @@ export class OptionsParser {
if (!Array.isArray(attributes)) { if (!Array.isArray(attributes)) {
attributes = []; attributes = [];
if (this.collection.isParent()) { if (this.collection.isParent()) {
attributes.push(this.inheritFromSubQuery()); this.inheritFromSubQuery(attributes);
} }
} }

View File

@ -1,6 +1,7 @@
import QueryInterface from './query-interface'; import QueryInterface from './query-interface';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Transactionable } from 'sequelize'; import { Transactionable } from 'sequelize';
import sqlParser from '../sql-parser';
export default class MysqlQueryInterface extends QueryInterface { export default class MysqlQueryInterface extends QueryInterface {
constructor(db) { constructor(db) {
@ -12,9 +13,57 @@ export default class MysqlQueryInterface extends QueryInterface {
const tableName = collection.model.tableName; const tableName = collection.model.tableName;
const databaseName = this.db.options.database; const databaseName = this.db.options.database;
const sql = `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '${databaseName}' AND TABLE_NAME = '${tableName}'`; const sql = `SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = '${databaseName}'
AND TABLE_NAME = '${tableName}'`;
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction }); const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
return results.length > 0; return results.length > 0;
} }
async listViews() {
const sql = `SELECT TABLE_NAME as name, VIEW_DEFINITION as definition
FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;`;
return await this.db.sequelize.query(sql, { type: 'SELECT' });
}
async viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
Array<{
column_name: string;
table_name: string;
table_schema?: string;
}>
> {
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 columns = ast.columns;
const results = [];
for (const column of columns) {
if (column.expr.type === 'column_ref') {
results.push({
column_name: column.expr.column,
table_name: column.expr.table,
});
}
}
return results;
} catch (e) {
this.db.logger.warn(e);
return [];
}
}
} }

View File

@ -12,11 +12,35 @@ export default class PostgresQueryInterface extends QueryInterface {
const tableName = collection.model.tableName; const tableName = collection.model.tableName;
const schema = collection.collectionSchema() || 'public'; const schema = collection.collectionSchema() || 'public';
const sql = `SELECT EXISTS(SELECT 1 FROM information_schema.tables const sql = `SELECT EXISTS(SELECT 1
WHERE table_schema = '${schema}' FROM information_schema.tables
AND table_name = '${tableName}')`; WHERE table_schema = '${schema}'
AND table_name = '${tableName}')`;
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction }); const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
return results[0]['exists']; return results[0]['exists'];
} }
async listViews() {
const sql = `
SELECT viewname as name, definition, schemaname as schema
FROM pg_views
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY viewname;
`;
return await this.db.sequelize.query(sql, { type: 'SELECT' });
}
async viewColumnUsage(options) {
const { viewName, schema = 'public' } = options;
const sql = `
SELECT *
FROM information_schema.view_column_usage
WHERE view_schema = '${schema}'
AND view_name = '${viewName}';
`;
return (await this.db.sequelize.query(sql, { type: 'SELECT' })) as any;
}
} }

View File

@ -4,9 +4,40 @@ import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequ
export default abstract class QueryInterface { export default abstract class QueryInterface {
sequelizeQueryInterface: SequelizeQueryInterface; sequelizeQueryInterface: SequelizeQueryInterface;
protected constructor(public db: Database) { protected constructor(public db: Database) {
this.sequelizeQueryInterface = db.sequelize.getQueryInterface(); this.sequelizeQueryInterface = db.sequelize.getQueryInterface();
} }
abstract collectionTableExists(collection: Collection, options?: Transactionable): Promise<boolean>; abstract collectionTableExists(collection: Collection, options?: Transactionable): Promise<boolean>;
abstract listViews();
abstract viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
Array<{
column_name: string;
table_name: string;
table_schema?: string;
}>
>;
async dropAll(options) {
if (options.drop !== true) return;
const views = await this.listViews();
for (const view of views) {
let removeSql;
if (view.schema) {
removeSql = `DROP VIEW IF EXISTS "${view.schema}"."${view.name}"`;
} else {
removeSql = `DROP VIEW IF EXISTS ${view.name}`;
}
await this.db.sequelize.query(removeSql, { transaction: options.transaction });
}
await this.db.sequelize.getQueryInterface().dropAllTables(options);
}
} }

View File

@ -1,5 +1,6 @@
import QueryInterface from './query-interface'; import QueryInterface from './query-interface';
import { Collection } from '../collection'; import { Collection } from '../collection';
import sqlParser from '../sql-parser';
export default class SqliteQueryInterface extends QueryInterface { export default class SqliteQueryInterface extends QueryInterface {
constructor(db) { constructor(db) {
@ -11,8 +12,65 @@ export default class SqliteQueryInterface extends QueryInterface {
const tableName = collection.model.tableName; const tableName = collection.model.tableName;
const sql = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}';`; const sql = `SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = '${tableName}';`;
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction }); const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
return results.length > 0; return results.length > 0;
} }
async listViews() {
const sql = `
SELECT name , sql as definition
FROM sqlite_master
WHERE type = 'view'
ORDER BY name;
`;
return await this.db.sequelize.query(sql, {
type: 'SELECT',
});
}
async viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
Array<{
column_name: string;
table_name: string;
table_schema?: string;
}>
> {
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 columns = ast.columns;
const results = [];
for (const column of columns) {
if (column.expr.type === 'column_ref') {
results.push({
column_name: column.expr.column,
table_name: column.expr.table,
});
}
}
return results;
} catch (e) {
this.db.logger.warn(e);
return [];
}
}
} }

View File

@ -11,7 +11,7 @@ import {
Op, Op,
Transactionable, Transactionable,
UpdateOptions as SequelizeUpdateOptions, UpdateOptions as SequelizeUpdateOptions,
WhereOperators WhereOperators,
} from 'sequelize'; } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
@ -340,7 +340,9 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
if (this.collection.isParent()) { if (this.collection.isParent()) {
for (const row of rows) { for (const row of rows) {
const rowCollectionName = this.database.tableNameCollectionMap.get( const rowCollectionName = this.database.tableNameCollectionMap.get(
options.raw ? row['__tableName'] : row.get('__tableName'), options.raw
? `${row['__schemaName']}.${row['__tableName']}`
: `${row.get('__schemaName')}.${row.get('__tableName')}`,
).name; ).name;
options.raw options.raw

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
use peggy to transform pegjs to sql parser
https://github.com/peggyjs/peggy

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
import { Collection, CollectionContext, CollectionOptions } from './collection';
export class ViewCollection extends Collection {
constructor(options: CollectionOptions, context: CollectionContext) {
options.autoGenId = false;
options.timestamps = false;
super(options, context);
}
protected sequelizeModelOptions(): any {
const modelOptions = super.sequelizeModelOptions();
modelOptions.tableName = this.options.viewName || this.options.name;
return modelOptions;
}
isView() {
return true;
}
}

View File

@ -0,0 +1,56 @@
const postgres = {
'character varying': 'string',
varchar: 'string',
text: 'text',
char: 'string',
smallint: 'integer',
integer: 'integer',
bigint: 'bigInt',
decimal: 'float',
numeric: 'float',
'double precision': 'float',
'timestamp without time zone': 'date',
'timestamp with time zone': 'date',
date: 'date',
boolean: 'boolean',
json: ['json', 'array'],
jsonb: ['jsonb', 'array'],
};
const mysql = {
varchar: 'string',
text: 'text',
int: 'integer',
integer: 'integer',
bigint: 'bigInt',
float: 'float',
double: 'float',
boolean: 'boolean',
tinyint: 'integer',
datetime: 'date',
timestamp: 'date',
json: ['json', 'array'],
};
const sqlite = {
text: 'text',
varchar: 'string',
integer: 'integer',
real: 'real',
datetime: 'date',
date: 'date',
time: 'time',
boolean: 'boolean',
numeric: 'decimal',
json: ['json', 'array'],
};
export default { postgres, mysql, sqlite };

View File

@ -0,0 +1,106 @@
import Database from '../database';
import FieldTypeMap from './field-type-map';
import { isArray } from 'mathjs';
type InferredField = {
name: string;
type: string;
source?: string;
};
type InferredFieldResult = {
[key: string]: InferredField;
};
export class ViewFieldInference {
static async inferFields(options: {
db: Database;
viewName: string;
viewSchema?: string;
}): Promise<InferredFieldResult> {
const { db } = options;
if (!db.inDialect('postgres')) {
options.viewSchema = undefined;
}
const columns = await db.sequelize.getQueryInterface().describeTable(options.viewName, options.viewSchema);
const columnUsage = await db.queryInterface.viewColumnUsage({
viewName: options.viewName,
schema: options.viewSchema,
});
// @ts-ignore
return Object.fromEntries(
Object.entries(columns).map(([name, column]) => {
const usage = columnUsage.find((item) => item.column_name === name);
if (usage) {
const collectionField = (() => {
const tableName = `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`;
const collection = db.tableNameCollectionMap.get(tableName);
if (!collection) return false;
const fieldValue = Object.values(collection.model.rawAttributes).find(
(field) => field.field === usage.column_name,
);
if (!fieldValue) {
return false;
}
// @ts-ignore
const fieldName = fieldValue?.fieldName;
return collection.getField(fieldName);
})();
if (collectionField && collectionField.options.interface) {
return [
name,
{
name,
type: collectionField.type,
source: `${collectionField.collection.name}.${collectionField.name}`,
},
];
}
}
return [
name,
{
name,
...this.inferToFieldType({ db, name, type: column.type }),
},
];
}),
);
}
static inferToFieldType(options: { db: Database; name: string; type: string }) {
const { db } = options;
const dialect = db.sequelize.getDialect();
const fieldTypeMap = FieldTypeMap[dialect];
if (!options.type) {
return {
possibleTypes: Object.keys(fieldTypeMap),
};
}
const queryType = options.type.toLowerCase().replace(/\(\d+\)/, '');
const mappedType = fieldTypeMap[queryType];
if (isArray(mappedType)) {
return {
type: mappedType[0],
possibleTypes: mappedType,
};
}
return {
type: mappedType,
};
}
}

View File

@ -1,8 +1,8 @@
import { ACL } from '@nocobase/acl'; import {ACL} from '@nocobase/acl';
import { Database, HasManyRepository } from '@nocobase/database'; import {Database, HasManyRepository} from '@nocobase/database';
import UsersPlugin from '@nocobase/plugin-users'; import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import {MockServer} from '@nocobase/test';
import { prepareApp } from './prepare'; import {prepareApp} from './prepare';
describe('association test', () => { describe('association test', () => {
let app: MockServer; let app: MockServer;
@ -36,6 +36,7 @@ describe('association test', () => {
context: {}, context: {},
}); });
await db.getRepository('collections').create({ await db.getRepository('collections').create({
values: { values: {
name: 'comments', name: 'comments',

View File

@ -60,6 +60,7 @@ describe('field indexes', () => {
it('field value cannot be duplicated with unique index', async () => { it('field value cannot be duplicated with unique index', async () => {
const tableName = 'test1'; const tableName = 'test1';
// create a field with unique constraint // create a field with unique constraint
const field = await agent.resource('collections.fields', tableName).create({ const field = await agent.resource('collections.fields', tableName).create({
values: { values: {

View File

@ -0,0 +1,381 @@
import { MockServer } from '@nocobase/test';
import { createApp } from '../index';
import { uid } from '@nocobase/utils';
describe('view collection', () => {
let app: MockServer;
let agent;
let testViewName;
beforeEach(async () => {
app = await createApp({
database: {
tablePrefix: '',
},
});
agent = app.agent();
testViewName = `view_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${testViewName}`;
await app.db.sequelize.query(dropSQL);
const viewSQL = (() => {
if (app.db.inDialect('sqlite')) {
return `CREATE VIEW ${testViewName} AS WITH RECURSIVE numbers(n) AS (
SELECT CAST(1 AS INTEGER)
UNION ALL
SELECT CAST(1 + n AS INTEGER) FROM numbers WHERE n < 20
)
SELECT * FROM numbers;
`;
}
return `CREATE VIEW ${testViewName} AS WITH RECURSIVE numbers(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 20
)
SELECT * FROM numbers;
`;
})();
await app.db.sequelize.query(viewSQL);
});
afterEach(async () => {
await app.destroy();
});
it('should list views', async () => {
const response = await agent.resource('dbViews').list();
expect(response.status).toBe(200);
expect(response.body.data.find((item) => item.name === testViewName)).toBeTruthy();
});
it('should query views data', async () => {
const response = await agent.resource('dbViews').query({
filterByTk: testViewName,
pageSize: 20,
});
expect(response.status).toBe(200);
expect(response.body.data.length).toBe(20);
});
it('should list views fields', async () => {
const response = await agent.resource('dbViews').get({
filterByTk: testViewName,
schema: 'public',
});
expect(response.status).toBe(200);
const data = response.body.data;
if (app.db.options.dialect === 'mysql') {
expect(data.fields.n.type).toBe('bigInt');
} else if (app.db.options.dialect == 'postgres') {
expect(data.fields.n.type).toBe('integer');
}
// cannot get field type in sqlite
if (app.db.options.dialect === 'sqlite') {
expect(data.fields.n.possibleTypes).toBeTruthy();
}
});
it('should return possible types for json fields', async () => {
const jsonViewName = 'json_view';
const dropSql = `DROP VIEW IF EXISTS ${jsonViewName}`;
await app.db.sequelize.query(dropSql);
const jsonViewSQL = (() => {
if (app.db.inDialect('postgres')) {
return `CREATE VIEW ${jsonViewName} AS SELECT '{"a": 1}'::json as json_field`;
}
return `CREATE VIEW ${jsonViewName} AS SELECT JSON_OBJECT('key1', 1, 'key2', 'abc') as json_field`;
})();
await app.db.sequelize.query(jsonViewSQL);
const response = await agent.resource('dbViews').get({
filterByTk: jsonViewName,
schema: app.db.inDialect('postgres') ? 'public' : undefined,
});
expect(response.status).toBe(200);
const data = response.body.data;
if (!app.db.inDialect('sqlite')) {
expect(data.fields.json_field.type).toBe('json');
}
expect(data.fields.json_field.possibleTypes).toBeTruthy();
});
it('should list collections fields with source interface', async () => {
await app.db.getCollection('collections').repository.create({
values: {
name: 'users',
fields: [
{
name: 'name',
type: 'string',
interface: 'text',
uiSchema: 'name-uiSchema',
},
{
name: 'age',
type: 'integer',
interface: 'number',
uiSchema: 'age-uiSchema',
},
],
},
context: {},
});
await app.db.sync();
const UserCollection = app.db.getCollection('users');
const viewName = `t_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
await app.db.sequelize.query(dropSQL);
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
await app.db.sequelize.query(viewSQL);
// create view collection
const viewCollection = await app.db.getCollection('collections').repository.create({
values: {
name: viewName,
view: true,
schema: app.db.inDialect('postgres') ? 'public' : undefined,
fields: [
{
name: 'name',
type: 'string',
source: 'users.name',
},
{
name: 'age',
type: 'integer',
source: 'users.age',
},
],
},
context: {},
});
const response = await agent.resource('collections').list({
appends: ['fields'],
paginate: false,
});
const listResult = response.body.data.find((item) => item.name === viewName);
const fields = listResult.fields;
const nameField = fields.find((item) => item.name === 'name');
expect(nameField.interface).toBe('text');
expect(nameField.uiSchema).toBe('name-uiSchema');
const viewFieldsResponse = await agent.resource('collections.fields', viewName).list({
filter: {
$or: {
'interface.$not': null,
'options.source.$notEmpty': true,
},
},
});
expect(viewFieldsResponse.status).toEqual(200);
const viewFieldsData = viewFieldsResponse.body.data;
expect(viewFieldsData.length).toEqual(2);
expect(viewFieldsData.find((item) => item.name === 'name').interface).toEqual('text');
const fieldDetailResponse = await agent.resource('collections.fields', viewName).get({
filterByTk: 'name',
});
const fieldDetailData = fieldDetailResponse.body.data;
expect(fieldDetailData.interface).toEqual('text');
UserCollection.addField('email', { type: 'string' });
await app.db.sync();
// update view in database
await app.db.sequelize.query(dropSQL);
const viewSQL2 = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
await app.db.sequelize.query(viewSQL2);
const viewDetailResponse = await agent.resource('dbViews').get({
filterByTk: viewName,
schema: 'public',
});
const viewDetail = viewDetailResponse.body.data;
const viewFields = viewDetail.fields;
const updateFieldsResponse = await agent.resource('collections').setFields({
filterByTk: viewName,
values: {
fields: Object.values(viewFields),
},
});
expect(updateFieldsResponse.status).toEqual(200);
const viewCollectionWithEmail = app.db.getCollection(viewName);
expect(viewCollectionWithEmail.getField('email')).toBeTruthy();
});
it('should access view collection resource', async () => {
const UserCollection = app.db.collection({
name: 'users',
fields: [
{
name: 'name',
type: 'string',
},
],
});
await app.db.sync();
await UserCollection.repository.create({
values: {
name: 'John',
},
});
// create view
const viewName = `t_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
await app.db.sequelize.query(dropSQL);
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
await app.db.sequelize.query(viewSQL);
// create view collection
await app.db.getCollection('collections').repository.create({
values: {
name: viewName,
view: true,
schema: app.db.inDialect('postgres') ? 'public' : undefined,
fields: [
{
name: 'id',
type: 'integer',
},
{
name: 'name',
type: 'string',
},
],
},
context: {},
});
const viewCollection = app.db.getCollection(viewName);
// access view collection list
const listResponse = await agent.resource(viewCollection.name).list({});
expect(listResponse.status).toEqual(200);
const item = listResponse.body.data[0];
// access detail
const detailResponse = await agent.resource(viewCollection.name).get({
filterByTk: item['id'],
});
expect(detailResponse.status).toEqual(200);
});
it('should get view in difference schema', async () => {
if (!app.db.inDialect('postgres')) return;
const schemaName = `t_${uid(6)}`;
const testSchemaSql = `CREATE SCHEMA IF NOT EXISTS ${schemaName};`;
await app.db.sequelize.query(testSchemaSql);
const viewName = `v_${uid(6)}`;
const viewSQL = `CREATE OR REPLACE VIEW ${schemaName}.${viewName} AS SELECT 1+1 as result`;
await app.db.sequelize.query(viewSQL);
const response = await agent.resource('dbViews').query({
filterByTk: viewName,
schema: schemaName,
pageSize: 20,
});
expect(response.status).toEqual(200);
});
it('should edit uiSchema in view collection field', async () => {
await app.db.getCollection('collections').repository.create({
values: {
name: 'users',
fields: [
{
name: 'name',
type: 'string',
uiSchema: {
title: 'hello',
},
},
],
},
context: {},
});
await app.db.sync();
const UserCollection = app.db.getCollection('users');
// create view
const viewName = `t_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
await app.db.sequelize.query(dropSQL);
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
await app.db.sequelize.query(viewSQL);
// create view collection
await app.db.getCollection('collections').repository.create({
values: {
name: viewName,
view: true,
schema: 'public',
fields: [
{
name: 'id',
type: 'integer',
},
{
name: 'name',
type: 'string',
source: 'users.name',
},
],
},
context: {},
});
await app.db.getCollection('fields').repository.update({
filter: {
name: 'name',
collectionName: viewName,
},
values: {
uiSchema: {
title: 'bars',
},
},
context: {},
});
const viewCollection = app.db.getCollection(viewName);
expect(viewCollection.getField('name').options.uiSchema.title).toEqual('bars');
const viewFieldsResponse = await agent.resource('collections.fields', viewName).list({});
const nameField = viewFieldsResponse.body.data.find((item) => item.name === 'name');
expect(nameField.uiSchema.title).toEqual('bars');
});
});

View File

@ -0,0 +1,122 @@
import Database, { Repository, ViewCollection } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '../index';
describe('view collection', function () {
let db: Database;
let app: Application;
let collectionRepository: Repository;
let fieldsRepository: Repository;
beforeEach(async () => {
app = await createApp({
database: {
tablePrefix: '',
},
});
db = app.db;
collectionRepository = db.getCollection('collections').repository;
fieldsRepository = db.getCollection('fields').repository;
});
afterEach(async () => {
await app.destroy();
});
it('should support view with dot field', async () => {
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
await db.sequelize.query(dropViewSQL);
const viewSQL = `CREATE VIEW test_view AS select 1+1 as "dot.results"`;
await db.sequelize.query(viewSQL);
await collectionRepository.create({
values: {
name: 'view_collection',
viewName: 'test_view',
fields: [{ type: 'string', name: 'dot_result', field: 'dot.results' }],
schema: db.inDialect('postgres') ? 'public' : undefined,
},
context: {},
});
const viewCollection = db.getCollection('view_collection');
const results = await viewCollection.repository.find();
expect(results.length).toBe(1);
});
it('should create view collection by view name', async () => {
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
await db.sequelize.query(dropViewSQL);
const viewSQL = `CREATE VIEW test_view AS select 1+1 as result`;
await db.sequelize.query(viewSQL);
await collectionRepository.create({
values: {
name: 'view_collection',
viewName: 'test_view',
fields: [{ type: 'string', name: 'result' }],
schema: db.inDialect('postgres') ? 'public' : undefined,
},
context: {},
});
const viewCollection = db.getCollection('view_collection');
expect(viewCollection).toBeInstanceOf(ViewCollection);
const results = await viewCollection.repository.find();
expect(results.length).toBe(1);
});
it('should destroy collection view', async () => {
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
await db.sequelize.query(dropViewSQL);
const viewSQL = `CREATE VIEW test_view AS select 1+1 as result`;
await db.sequelize.query(viewSQL);
await collectionRepository.create({
values: {
name: 'view_collection',
viewName: 'test_view',
fields: [{ type: 'string', name: 'result' }],
},
context: {},
});
expect(
await fieldsRepository.findOne({
filter: {
collectionName: 'view_collection',
name: 'result',
},
}),
).toBeTruthy();
await fieldsRepository.destroy({
filter: {
collectionName: 'view_collection',
name: 'result',
},
context: {},
});
expect(
await fieldsRepository.findOne({
filter: {
collectionName: 'view_collection',
name: 'result',
},
}),
).toBeFalsy();
await collectionRepository.destroy({
filterByTk: 'view_collection',
});
expect(db.getCollection('view_collection')).toBeUndefined();
});
});

View File

@ -0,0 +1,16 @@
import { Database } from '@nocobase/database';
export function beforeCreateForViewCollection(db: Database) {
return async (model, { transaction, context }) => {
if (model.get('viewSQL')) {
const name = model.get('name');
const sql = model.get('viewSQL');
await db.sequelize.query(`CREATE OR REPLACE VIEW "${name}" AS ${sql}`, {
transaction,
});
model.set('viewName', name);
}
};
}

View File

@ -0,0 +1,32 @@
import { Database } from '@nocobase/database';
export default {
async ['collections:setFields'](ctx, next) {
const { filterByTk, values } = ctx.action.params;
// WARN: 删掉 key 才能保存
const fields = values.fields?.map(f => {
delete f.key;
return f;
});
const db = ctx.app.db as Database;
const collection = await db.getRepository('collections').findOne({
filter: {
name: filterByTk,
},
});
await db.getRepository('collections').update({
filterByTk,
values: {
fields,
},
});
await collection.loadFields();
await next();
},
};

View File

@ -0,0 +1,55 @@
import { Database, ViewFieldInference } from '@nocobase/database';
export default {
name: 'dbViews',
actions: {
async get(ctx, next) {
const { filterByTk, schema } = ctx.action.params;
const db = ctx.app.db as Database;
const fields = await ViewFieldInference.inferFields({
db,
viewName: filterByTk,
viewSchema: schema,
});
ctx.body = {
fields,
sources: [
...new Set(
Object.values(fields)
.map((field) => field.source)
.filter(Boolean)
.map((source) => source.split('.')[0]),
),
],
};
await next();
},
async list(ctx, next) {
const db = ctx.app.db as Database;
const dbViews = await db.queryInterface.listViews();
ctx.body = dbViews.map((dbView) => {
return {
...dbView,
};
});
await next();
},
async query(ctx, next) {
const { filterByTk, schema = 'public', page = 1, pageSize = 10 } = ctx.action.params;
const offset = (page - 1) * pageSize;
const limit = 1 * pageSize;
const sql = `SELECT *
FROM ${ctx.app.db.utils.quoteTable(ctx.app.db.utils.addSchema(filterByTk, schema))} LIMIT ${limit} OFFSET ${offset}`;
ctx.body = await ctx.app.db.sequelize.query(sql, { type: 'SELECT' });
await next();
},
},
};

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