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:
parent
bcc417a79f
commit
4f87de7da5
@ -25,13 +25,15 @@ services:
|
||||
networks:
|
||||
- nocobase
|
||||
postgres:
|
||||
image: postgres:10
|
||||
image: postgres:latest
|
||||
restart: always
|
||||
networks:
|
||||
- nocobase
|
||||
command: postgres -c wal_level=logical
|
||||
ports:
|
||||
- "${DB_POSTGRES_PORT}:5432"
|
||||
volumes:
|
||||
- ./storage/db/postgres/backups:/backups
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_DB: ${DB_DATABASE}
|
||||
|
@ -9,6 +9,7 @@ describe('create action', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer();
|
||||
await app.db.clean({ drop: true });
|
||||
registerActions(app);
|
||||
|
||||
Post = app.collection({
|
||||
|
@ -13,6 +13,8 @@ describe('remove action', () => {
|
||||
app = mockServer();
|
||||
registerActions(app);
|
||||
|
||||
await app.db.clean({ drop: true });
|
||||
|
||||
PostTag = app.collection({
|
||||
name: 'posts_tags',
|
||||
fields: [{ type: 'string', name: 'tagged_at' }],
|
||||
|
@ -10,6 +10,7 @@ describe('set action', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer();
|
||||
await app.db.clean({ drop: true });
|
||||
registerActions(app);
|
||||
|
||||
PostTag = app.collection({
|
||||
|
@ -10,6 +10,7 @@ describe('toggle action', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer();
|
||||
await app.db.clean({ drop: true });
|
||||
registerActions(app);
|
||||
|
||||
PostTag = app.collection({
|
||||
|
@ -292,9 +292,27 @@ export const useSourceIdFromParentRecord = () => {
|
||||
|
||||
export const useParamsFromRecord = () => {
|
||||
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,
|
||||
};
|
||||
if (!filterByTk) {
|
||||
obj['filter'] = filter;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export const RecordLink = (props) => {
|
||||
|
@ -13,7 +13,7 @@ const InternalField: React.FC = (props) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const { name, interface: interfaceType, uiSchema, defaultValue } = useCollectionField();
|
||||
const collectionField = useCollectionField();
|
||||
const component = useComponent(uiSchema?.['x-component']);
|
||||
const component = useComponent(uiSchema?.['x-component'] || 'Input');
|
||||
const compile = useCompile();
|
||||
const setFieldProps = (key, value) => {
|
||||
field[key] = typeof field[key] === 'undefined' ? value : field[key];
|
||||
@ -73,7 +73,6 @@ const InternalField: React.FC = (props) => {
|
||||
if (!uiSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.createElement(component, props, props.children);
|
||||
};
|
||||
|
||||
@ -107,7 +106,6 @@ export const CollectionField = connect((props) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const field = fieldSchema?.['x-component-props']?.['field'];
|
||||
const { snapshot } = useActionContext();
|
||||
|
||||
return (
|
||||
<CollectionFieldProvider
|
||||
name={fieldSchema.name}
|
||||
|
@ -26,6 +26,8 @@ import {
|
||||
ConfigurationTabs,
|
||||
EditCategory,
|
||||
EditCategoryAction,
|
||||
SyncFieldsAction,
|
||||
SyncFieldsActionCom
|
||||
} from './Configuration';
|
||||
|
||||
import { CollectionCategroriesProvider } from './CollectionManagerProvider';
|
||||
@ -81,6 +83,8 @@ export const CollectionManagerPane = () => {
|
||||
ViewFieldAction,
|
||||
EditCategory,
|
||||
EditCategoryAction,
|
||||
SyncFieldsAction,
|
||||
SyncFieldsActionCom
|
||||
}}
|
||||
/>
|
||||
// </Card>
|
||||
|
@ -28,8 +28,9 @@ const getSchema = (schema, category, compile): ISchema => {
|
||||
properties['defaultValue']['x-decorator'] = 'FormItem';
|
||||
}
|
||||
const initialValue: any = {
|
||||
name: `t_${uid()}`,
|
||||
name: schema.name !== 'view' ? `t_${uid()}` : null,
|
||||
template: schema.name,
|
||||
view: schema.name === 'view',
|
||||
category,
|
||||
...cloneDeep(schema.default),
|
||||
};
|
||||
@ -201,7 +202,7 @@ const useCreateCollection = (schema?: any) => {
|
||||
if (schema?.events?.beforeSubmit) {
|
||||
schema.events.beforeSubmit(values);
|
||||
}
|
||||
const fields = useDefaultCollectionFields(values);
|
||||
const fields = values?.template !== 'view' ? useDefaultCollectionFields(values) : values.fields;
|
||||
if (values.autoCreateReverseField) {
|
||||
} else {
|
||||
delete values.reverseField;
|
||||
@ -235,8 +236,15 @@ export const AddCollectionAction = (props) => {
|
||||
const [schema, setSchema] = useState({});
|
||||
const compile = useCompile();
|
||||
const { t } = useTranslation();
|
||||
const items = templateOptions().map((option) => {
|
||||
return { label: compile(option.title), key: option.name };
|
||||
const collectionTemplates = templateOptions();
|
||||
const items = [];
|
||||
collectionTemplates.forEach((item) => {
|
||||
if (item.divider) {
|
||||
items.push({
|
||||
type: 'divider',
|
||||
});
|
||||
}
|
||||
items.push({ label: compile(item.title), key: item.name });
|
||||
});
|
||||
const {
|
||||
state: { category },
|
||||
|
@ -212,6 +212,7 @@ export const AddFieldAction = (props) => {
|
||||
return optionArr;
|
||||
};
|
||||
return (
|
||||
record.template !== 'view' && (
|
||||
<RecordProvider record={record}>
|
||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||
<Dropdown
|
||||
@ -279,5 +280,6 @@ export const AddFieldAction = (props) => {
|
||||
/>
|
||||
</ActionContext.Provider>
|
||||
</RecordProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
@ -14,6 +14,7 @@ import { AddSubFieldAction } from './AddSubFieldAction';
|
||||
import { FieldSummary } from './components/FieldSummary';
|
||||
import { EditSubFieldAction } from './EditSubFieldAction';
|
||||
import { collectionSchema } from './schemas/collections';
|
||||
import { useAPIClient } from '../../api-client';
|
||||
|
||||
const useAsyncDataSource = (service: any) => {
|
||||
return (field: any, options?: any) => {
|
||||
@ -77,20 +78,29 @@ export const ConfigurationTable = () => {
|
||||
const {
|
||||
data: { database },
|
||||
} = useCurrentAppInfo();
|
||||
|
||||
const data = useContext(CollectionCategroriesContext);
|
||||
const api = useAPIClient();
|
||||
const resource = api.resource('dbViews');
|
||||
const collectonsRef: any = useRef();
|
||||
collectonsRef.current = collections;
|
||||
const compile = useCompile();
|
||||
const loadCollections = async (field, options) => {
|
||||
const { targetScope } = options;
|
||||
return collectonsRef.current
|
||||
?.filter((item) => !(item.autoCreate && item.isThrough))
|
||||
.filter((item) =>
|
||||
targetScope
|
||||
? targetScope['template']?.includes(item.template) || targetScope[field.props.name]?.includes(item.name)
|
||||
: true,
|
||||
)
|
||||
.map((item: any) => ({
|
||||
const isFieldInherits = field.props?.name === 'inherits';
|
||||
const filteredItems = collectonsRef.current.filter((item) => {
|
||||
const isAutoCreateAndThrough = item.autoCreate && item.isThrough;
|
||||
if (isAutoCreateAndThrough) {
|
||||
return false;
|
||||
}
|
||||
if (isFieldInherits && item.template === 'view') {
|
||||
return false;
|
||||
}
|
||||
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,
|
||||
}));
|
||||
@ -101,6 +111,18 @@ export const ConfigurationTable = () => {
|
||||
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);
|
||||
return (
|
||||
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
|
||||
@ -119,11 +141,13 @@ export const ConfigurationTable = () => {
|
||||
useAsyncDataSource,
|
||||
loadCollections,
|
||||
loadCategories,
|
||||
loadDBViews,
|
||||
useCurrentFields,
|
||||
useNewId,
|
||||
useCancelAction,
|
||||
interfaces,
|
||||
enableInherits: database?.dialect === 'postgres',
|
||||
isPG:database?.dialect === 'postgres',
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentContext.Provider>
|
||||
|
@ -19,10 +19,12 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
|
||||
return;
|
||||
}
|
||||
const properties = cloneDeep(schema.properties) as any;
|
||||
if (properties?.name) {
|
||||
properties.name['x-disabled'] = 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']['x-decorator'] = 'FormItem';
|
||||
properties['defaultValue']['x-reactions'] = {
|
||||
@ -156,7 +158,7 @@ export const EditFieldAction = (props) => {
|
||||
const defaultValues: any = cloneDeep(data?.data) || {};
|
||||
if (!defaultValues?.reverseField) {
|
||||
defaultValues.autoCreateReverseField = false;
|
||||
defaultValues.reverseField = interfaceConf.default?.reverseField;
|
||||
defaultValues.reverseField = interfaceConf?.default?.reverseField;
|
||||
set(defaultValues.reverseField, 'name', `f_${uid()}`);
|
||||
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
};
|
@ -13,7 +13,8 @@ export * from './AddCollectionAction';
|
||||
export * from './EditCollectionAction';
|
||||
export * from './ConfigurationTabs';
|
||||
export * from './AddCategoryAction';
|
||||
export * from './EditCategoryAction'
|
||||
export * from './EditCategoryAction';
|
||||
export * from './SyncFieldsAction';
|
||||
|
||||
registerValidateFormats({
|
||||
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,
|
||||
|
@ -73,7 +73,7 @@ export const collectionFieldSchema: ISchema = {
|
||||
params: {
|
||||
paginate: false,
|
||||
filter: {
|
||||
'interface.$not': null,
|
||||
$or: [{ 'interface.$not': null }, { 'options.source.$notEmpty': true }],
|
||||
},
|
||||
sort: ['sort'],
|
||||
// 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: {
|
||||
type: 'void',
|
||||
title: '{{ t("Add new") }}',
|
||||
|
@ -258,7 +258,8 @@ export const collectionTableSchema: ISchema = {
|
||||
},
|
||||
'x-reactions': (field) => {
|
||||
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) {
|
||||
field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ export const useCollectionManager = () => {
|
||||
return getParents(name);
|
||||
};
|
||||
|
||||
const getChildrenCollections = (name) => {
|
||||
const getChildrenCollections = (name, isSupportView = false) => {
|
||||
const children = [];
|
||||
const getChildren = (name) => {
|
||||
const inheritCollections = collections.filter((v) => {
|
||||
@ -73,6 +73,16 @@ export const useCollectionManager = () => {
|
||||
children.push(v);
|
||||
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 getChildren(name);
|
||||
|
@ -22,6 +22,7 @@ export const attachment: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsToMany'],
|
||||
schemaInitialize(schema: ISchema, { block }) {
|
||||
if (['Table', 'Kanban'].includes(block)) {
|
||||
schema['x-component-props'] = schema['x-component-props'] || {};
|
||||
|
@ -15,6 +15,7 @@ export const checkbox: IField = {
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
},
|
||||
availableTypes: ['boolean'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -17,6 +17,7 @@ export const checkboxGroup: IField = {
|
||||
'x-component': 'Checkbox.Group',
|
||||
},
|
||||
},
|
||||
availableTypes:['array'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -34,6 +34,7 @@ export const chinaRegion: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsToMany'],
|
||||
initialize: (values: any) => {
|
||||
if (!values.through) {
|
||||
values.through = `t_${uid()}`;
|
||||
|
@ -20,6 +20,7 @@ export const createdAt: IField = {
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
availableTypes:['date'],
|
||||
properties: {
|
||||
...defaultProps,
|
||||
...dateTimeProps,
|
||||
|
@ -28,6 +28,7 @@ export const createdBy: IField = {
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsTo'],
|
||||
properties: {
|
||||
...defaultProps,
|
||||
},
|
||||
|
@ -20,6 +20,7 @@ export const datetime: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['date'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -18,6 +18,7 @@ export const email: IField = {
|
||||
'x-validator': 'email',
|
||||
},
|
||||
},
|
||||
availableTypes:['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -16,6 +16,7 @@ export const icon: IField = {
|
||||
'x-component': 'IconPicker',
|
||||
},
|
||||
},
|
||||
availableTypes:['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -21,6 +21,7 @@ export const id: IField = {
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
availableTypes:['bigInt','integer'],
|
||||
properties: {
|
||||
'uiSchema.title': {
|
||||
type: 'string',
|
||||
|
@ -18,6 +18,7 @@ export const input: IField = {
|
||||
'x-component': 'Input',
|
||||
},
|
||||
},
|
||||
availableTypes:['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -29,6 +29,7 @@ export const integer: IField = {
|
||||
'x-validator': 'integer',
|
||||
},
|
||||
},
|
||||
availableTypes:['bigInt','integer'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -39,6 +39,7 @@ export const json: IField = {
|
||||
default: null
|
||||
},
|
||||
},
|
||||
availableTypes:['json','array'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -45,6 +45,7 @@ export const linkTo: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsToMany'],
|
||||
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
||||
if (block === 'Form') {
|
||||
if (schema['x-component'] === 'AssociationSelect') {
|
||||
|
@ -51,6 +51,7 @@ export const m2m: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsToMany'],
|
||||
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
||||
if (block === 'Form') {
|
||||
if (schema['x-component'] === 'AssociationSelect') {
|
||||
|
@ -50,6 +50,7 @@ export const m2o: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['belongsTo'],
|
||||
schemaInitialize(schema: ISchema, { block, readPretty }) {
|
||||
if (block === 'Form') {
|
||||
if (schema['x-component'] === 'AssociationSelect') {
|
||||
|
@ -17,6 +17,7 @@ export const markdown: IField = {
|
||||
'x-component': 'Markdown',
|
||||
},
|
||||
},
|
||||
availableTypes:['text'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -21,6 +21,7 @@ export const multipleSelect: IField = {
|
||||
enum: [],
|
||||
},
|
||||
},
|
||||
availableTypes:['array'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -22,6 +22,7 @@ export const number: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['double'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -50,6 +50,7 @@ export const o2m: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['hasMany'],
|
||||
schemaInitialize(schema: ISchema, { field, block, readPretty }) {
|
||||
if (block === 'Form') {
|
||||
if (schema['x-component'] === 'TableField') {
|
||||
|
@ -117,6 +117,7 @@ export const o2o: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes:['hasOne'],
|
||||
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
|
||||
internalSchameInitialize(schema, { field, block, readPretty, action });
|
||||
if (['Table', 'Kanban'].includes(block)) {
|
||||
|
@ -18,6 +18,7 @@ export const password: IField = {
|
||||
'x-component': 'Password',
|
||||
},
|
||||
},
|
||||
availableTypes:['password'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -13,7 +13,7 @@ registerValidateRules({
|
||||
return {
|
||||
type: 'error',
|
||||
message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ registerValidateRules({
|
||||
return {
|
||||
type: 'error',
|
||||
message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,12 +36,12 @@ registerValidateRules({
|
||||
return {
|
||||
type: 'error',
|
||||
message: `${i18n.t('The field value is not an integer number')}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
// registerValidateFormats({
|
||||
// percentInteger: /^(\d+)(.\d{0,2})?$/,
|
||||
@ -68,6 +68,7 @@ export const percent: IField = {
|
||||
},
|
||||
},
|
||||
},
|
||||
availableTypes: ['float'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
@ -104,7 +105,9 @@ export const percent: IField = {
|
||||
'x-reactions': `{{(field) => {
|
||||
const targetValue = field.query('.minimum').value();
|
||||
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: {
|
||||
@ -119,7 +122,9 @@ export const percent: IField = {
|
||||
dependencies: ['.maximum'],
|
||||
fulfill: {
|
||||
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': {
|
||||
allowClear: true,
|
||||
},
|
||||
enum: [{
|
||||
enum: [
|
||||
{
|
||||
label: '{{ t("Integer") }}',
|
||||
value: 'Integer',
|
||||
}]
|
||||
},
|
||||
],
|
||||
},
|
||||
pattern: {
|
||||
type: 'string',
|
||||
@ -145,8 +152,8 @@ export const percent: IField = {
|
||||
'x-component-props': {
|
||||
prefix: '/',
|
||||
suffix: '/',
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -21,6 +21,7 @@ export const phone: IField = {
|
||||
// 'x-validator': 'phone',
|
||||
},
|
||||
},
|
||||
availableTypes: ['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -17,6 +17,7 @@ export const radioGroup: IField = {
|
||||
'x-component': 'Radio.Group',
|
||||
},
|
||||
},
|
||||
availableTypes: ['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -18,6 +18,7 @@ export const richText: IField = {
|
||||
'x-component': 'RichText',
|
||||
},
|
||||
},
|
||||
availableTypes: ['text'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -18,6 +18,7 @@ export const select: IField = {
|
||||
enum: [],
|
||||
},
|
||||
},
|
||||
availableTypes: ['string'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -20,6 +20,7 @@ export const subTable: IField = {
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
availableTypes: ['hasMany'],
|
||||
schemaInitialize(schema: ISchema, { field, readPretty }) {
|
||||
const association = `${field.collectionName}.${field.name}`;
|
||||
schema['type'] = 'void';
|
||||
|
@ -18,6 +18,7 @@ export const textarea: IField = {
|
||||
'x-component': 'Input.TextArea',
|
||||
},
|
||||
},
|
||||
availableTypes: ['text'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -16,6 +16,7 @@ export const time: IField = {
|
||||
'x-component': 'TimePicker',
|
||||
},
|
||||
},
|
||||
availableTypes: ['time'],
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
|
@ -20,6 +20,7 @@ export const updatedAt: IField = {
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
availableTypes: ['date'],
|
||||
properties: {
|
||||
...defaultProps,
|
||||
...dateTimeProps,
|
||||
|
@ -27,6 +27,7 @@ export const updatedBy: IField = {
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
availableTypes: ['belongsTo'],
|
||||
properties: {
|
||||
...defaultProps,
|
||||
},
|
||||
|
@ -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);
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
export * from './calendar';
|
||||
export * from './general';
|
||||
export * from './tree';
|
||||
|
||||
export * from './view';
|
||||
|
@ -14,6 +14,8 @@ export interface ICollectionTemplate {
|
||||
configurableProperties?: Record<string, ISchema>;
|
||||
/** 当前模板可用的字段类型 */
|
||||
availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude;
|
||||
/** 是否分割线 */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
interface AvailableFieldInterfacesInclude {
|
||||
@ -24,7 +26,6 @@ interface AvailableFieldInterfacesExclude {
|
||||
exclude?: any[];
|
||||
}
|
||||
|
||||
|
||||
interface CollectionOptions {
|
||||
/**
|
||||
* 自动生成 id
|
||||
|
106
packages/core/client/src/collection-manager/templates/view.tsx
Normal file
106
packages/core/client/src/collection-manager/templates/view.tsx
Normal 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'),
|
||||
},
|
||||
};
|
@ -180,6 +180,10 @@ export default {
|
||||
"Collection template": "Collection template",
|
||||
"Calendar collection": "Calendar 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.",
|
||||
"Storage type": "Storage type",
|
||||
"Edit": "Edit",
|
||||
|
@ -171,6 +171,10 @@ export default {
|
||||
"Collection template":"データテーブルテンプレート",
|
||||
"Calendar 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.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。",
|
||||
"Storage type": "ストレージタイプ",
|
||||
"Edit": "編集",
|
||||
|
@ -188,6 +188,10 @@ export default {
|
||||
"Collection template": "数据表模板",
|
||||
"Calendar 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.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
|
||||
"Storage type": "存储类型",
|
||||
"Types will be used in database": "数据库使用的类型",
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useCollection } from '../../';
|
||||
|
||||
// 日历的操作配置
|
||||
export const CalendarActionInitializers = {
|
||||
title: '{{t("Configure actions")}}',
|
||||
@ -72,6 +74,10 @@ export const CalendarActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useCollection } from '../..';
|
||||
|
||||
// 表单的操作配置
|
||||
export const CalendarFormActionInitializers = {
|
||||
title: '{{t("Configure actions")}}',
|
||||
@ -21,6 +23,10 @@ export const CalendarFormActionInitializers = {
|
||||
type: 'primary',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -30,6 +36,10 @@ export const CalendarFormActionInitializers = {
|
||||
'x-component': 'Action',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -39,6 +49,10 @@ export const CalendarFormActionInitializers = {
|
||||
'x-component': 'Action',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -130,6 +144,10 @@ export const CalendarFormActionInitializers = {
|
||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -153,6 +171,10 @@ export const CalendarFormActionInitializers = {
|
||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useCollection } from '../../';
|
||||
|
||||
export const KanbanActionInitializers = {
|
||||
title: "{{t('Configure actions')}}",
|
||||
icon: 'SettingOutlined',
|
||||
@ -28,6 +30,10 @@ export const KanbanActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useCollection } from '../..';
|
||||
|
||||
// 表单的操作配置
|
||||
export const ReadPrettyFormActionInitializers = {
|
||||
title: '{{t("Configure actions")}}',
|
||||
@ -21,6 +23,10 @@ export const ReadPrettyFormActionInitializers = {
|
||||
type: 'primary',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -30,6 +36,10 @@ export const ReadPrettyFormActionInitializers = {
|
||||
'x-component': 'Action',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -122,6 +132,10 @@ export const ReadPrettyFormActionInitializers = {
|
||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -145,6 +159,10 @@ export const ReadPrettyFormActionInitializers = {
|
||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -166,8 +166,11 @@ export const RecordBlockInitializers = (props: any) => {
|
||||
const { insertPosition, component, actionInitializers } = props;
|
||||
const collection = useCollection();
|
||||
const { getChildrenCollections } = useCollectionManager();
|
||||
const childrenCollections = getChildrenCollections(collection.name);
|
||||
const hasChildCollection = childrenCollections?.length > 0;
|
||||
const formChildrenCollections = getChildrenCollections(collection.name);
|
||||
const hasFormChildCollection = formChildrenCollections?.length > 0;
|
||||
const detailChildrenCollections = getChildrenCollections(collection.name, true);
|
||||
const hasDetailChildCollection = detailChildrenCollections?.length > 0;
|
||||
const modifyFlag = (collection as any).template !== 'view';
|
||||
return (
|
||||
<SchemaInitializer.Button
|
||||
wrap={gridRowColWrap}
|
||||
@ -179,30 +182,37 @@ export const RecordBlockInitializers = (props: any) => {
|
||||
{
|
||||
type: 'itemGroup',
|
||||
title: '{{t("Current record blocks")}}',
|
||||
children: hasChildCollection
|
||||
? [
|
||||
{
|
||||
children: [
|
||||
hasDetailChildCollection
|
||||
? {
|
||||
key: 'details',
|
||||
type: 'subMenu',
|
||||
title: '{{t("Details")}}',
|
||||
children: useDetailCollections({ ...props, childrenCollections, collection }),
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
type: 'subMenu',
|
||||
title: '{{t("Form")}}',
|
||||
children: useFormCollections({ ...props, childrenCollections, collection }),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
children: useDetailCollections({
|
||||
...props,
|
||||
childrenCollections: detailChildrenCollections,
|
||||
collection,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
key: 'details',
|
||||
type: 'item',
|
||||
title: '{{t("Details")}}',
|
||||
component: 'RecordReadPrettyFormBlockInitializer',
|
||||
actionInitializers,
|
||||
},
|
||||
{
|
||||
hasFormChildCollection
|
||||
? {
|
||||
key: 'form',
|
||||
type: 'subMenu',
|
||||
title: '{{t("Form")}}',
|
||||
children: useFormCollections({
|
||||
...props,
|
||||
childrenCollections: formChildrenCollections,
|
||||
collection,
|
||||
}),
|
||||
}
|
||||
: modifyFlag && {
|
||||
key: 'form',
|
||||
type: 'item',
|
||||
title: '{{t("Form")}}',
|
||||
|
@ -51,7 +51,8 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
const { refresh } = useDesignable();
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<SchemaInitializer.Button
|
||||
insertPosition={'beforeEnd'}
|
||||
@ -98,8 +99,12 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
'x-action': 'update',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
{
|
||||
},
|
||||
modifyFlag && {
|
||||
type: 'item',
|
||||
title: t('Delete'),
|
||||
component: 'DestroyActionInitializer',
|
||||
@ -202,6 +207,10 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -224,6 +233,10 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -32,6 +32,10 @@ export const TableActionInitializers = {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -41,6 +45,10 @@ export const TableActionInitializers = {
|
||||
'x-align': 'right',
|
||||
'x-decorator': 'ACLActionProvider',
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -60,7 +68,7 @@ export const TableActionInitializers = {
|
||||
visible: () => {
|
||||
const schema = useFieldSchema();
|
||||
const collection = useCollection();
|
||||
const { treeTable } = schema?.parent?.['x-decorator-props']||{};
|
||||
const { treeTable } = schema?.parent?.['x-decorator-props'] || {};
|
||||
return collection.tree && treeTable !== false;
|
||||
},
|
||||
},
|
||||
@ -68,6 +76,10 @@ export const TableActionInitializers = {
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
@ -82,9 +94,17 @@ export const TableActionInitializers = {
|
||||
)?.[1];
|
||||
return resultSchema;
|
||||
},
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subMenu',
|
||||
@ -135,6 +155,10 @@ export const TableActionInitializers = {
|
||||
},
|
||||
},
|
||||
],
|
||||
visible: () => {
|
||||
const collection = useCollection();
|
||||
return (collection as any).template !== 'view';
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useCollectionManager } from "../../collection-manager";
|
||||
import { createDetailsBlockSchema } from "../utils";
|
||||
import { DataBlockInitializer } from "./DataBlockInitializer";
|
||||
import { useCollectionManager } from '../../collection-manager';
|
||||
import { createDetailsBlockSchema } from '../utils';
|
||||
import { DataBlockInitializer } from './DataBlockInitializer';
|
||||
|
||||
export const DetailsBlockInitializer = (props) => {
|
||||
const { insert } = props;
|
||||
@ -15,7 +15,11 @@ export const DetailsBlockInitializer = (props) => {
|
||||
componentType={'Details'}
|
||||
onCreateBlockSchema={async ({ item }) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
@ -1,9 +1,8 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useCollectionManager } from "../../collection-manager";
|
||||
import { DataBlockInitializer } from "./DataBlockInitializer";
|
||||
import { createTableBlockSchema } from "../utils";
|
||||
import { useCollectionManager } from '../../collection-manager';
|
||||
import { DataBlockInitializer } from './DataBlockInitializer';
|
||||
import { createTableBlockSchema } from '../utils';
|
||||
|
||||
export const TableBlockInitializer = (props) => {
|
||||
const { insert } = props;
|
||||
@ -15,7 +14,10 @@ export const TableBlockInitializer = (props) => {
|
||||
componentType={'Table'}
|
||||
onCreateBlockSchema={async ({ item }) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
@ -705,6 +705,8 @@ export const useCollectionDataSourceItems = (componentName) => {
|
||||
return false;
|
||||
} else if (item.autoGenId === false && !item.fields.find((v) => v.primaryKey)) {
|
||||
return false;
|
||||
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view') {
|
||||
return false;
|
||||
} else {
|
||||
return b && !(item?.isThrough && item?.autoCreate);
|
||||
}
|
||||
|
@ -311,6 +311,20 @@ describe('repository.update', () => {
|
||||
name: 'post1',
|
||||
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 () => {
|
||||
|
13
packages/core/database/src/__tests__/sql-parser.test.ts
Normal file
13
packages/core/database/src/__tests__/sql-parser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
34
packages/core/database/src/__tests__/view/list-view.test.ts
Normal file
34
packages/core/database/src/__tests__/view/list-view.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
103
packages/core/database/src/__tests__/view/view-inference.test.ts
Normal file
103
packages/core/database/src/__tests__/view/view-inference.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -34,6 +34,8 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
|
||||
|
||||
tableName?: string;
|
||||
inherits?: string[] | string;
|
||||
viewName?: string;
|
||||
|
||||
filterTargetKey?: string;
|
||||
fields?: FieldOptions[];
|
||||
model?: string | ModelStatic<Model>;
|
||||
@ -69,7 +71,12 @@ export class Collection<
|
||||
repository: Repository<TModelAttributes, TCreationAttributes>;
|
||||
|
||||
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() {
|
||||
@ -111,7 +118,11 @@ export class Collection<
|
||||
this.modelInit();
|
||||
|
||||
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) {
|
||||
this.setFields(options.fields);
|
||||
@ -141,7 +152,7 @@ export class Collection<
|
||||
return this.db.options.underscored ? snakeCase(tName) : tName;
|
||||
}
|
||||
|
||||
private sequelizeModelOptions() {
|
||||
protected sequelizeModelOptions() {
|
||||
const { name } = this.options;
|
||||
return {
|
||||
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
|
||||
@ -261,6 +272,17 @@ export class Collection<
|
||||
this.checkFieldType(name, options);
|
||||
|
||||
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 });
|
||||
|
||||
const field = database.buildField(
|
||||
@ -334,9 +356,10 @@ export class Collection<
|
||||
|
||||
async removeFromDb(options?: QueryInterfaceDropTableOptions) {
|
||||
if (
|
||||
await this.existsInDb({
|
||||
!this.isView() &&
|
||||
(await this.existsInDb({
|
||||
transaction: options?.transaction,
|
||||
})
|
||||
}))
|
||||
) {
|
||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
|
||||
@ -575,13 +598,23 @@ export class Collection<
|
||||
public getTableNameWithSchema() {
|
||||
const tableName = this.model.tableName;
|
||||
|
||||
if (this.collectionSchema()) {
|
||||
if (this.collectionSchema() && this.db.inDialect('postgres')) {
|
||||
return this.db.utils.addSchema(tableName, this.collectionSchema());
|
||||
}
|
||||
|
||||
return tableName;
|
||||
}
|
||||
|
||||
public getTableNameWithSchemaAsString() {
|
||||
const tableName = this.model.tableName;
|
||||
|
||||
if (this.collectionSchema() && this.db.inDialect('postgres')) {
|
||||
return `${this.collectionSchema()}.${tableName}`;
|
||||
}
|
||||
|
||||
return tableName;
|
||||
}
|
||||
|
||||
public quotedTableName() {
|
||||
return this.db.utils.quoteTable(this.getTableNameWithSchema());
|
||||
}
|
||||
@ -601,4 +634,8 @@ export class Collection<
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public isView() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ export default class DatabaseUtils {
|
||||
constructor(public db: Database) {}
|
||||
|
||||
addSchema(tableName, schema?) {
|
||||
if (!this.db.inDialect('postgres')) return tableName;
|
||||
if (this.db.options.schema && !schema) {
|
||||
schema = this.db.options.schema;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
Sequelize,
|
||||
SyncOptions,
|
||||
Transactionable,
|
||||
Utils
|
||||
Utils,
|
||||
} from 'sequelize';
|
||||
import { SequelizeStorage, Umzug } from 'umzug';
|
||||
import { Collection, CollectionOptions, RepositoryType } from './collection';
|
||||
@ -58,7 +58,7 @@ import {
|
||||
SyncListener,
|
||||
UpdateListener,
|
||||
UpdateWithAssociationsListener,
|
||||
ValidateListener
|
||||
ValidateListener,
|
||||
} from './types';
|
||||
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 { Logger } from '@nocobase/logger';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
import { ViewCollection } from './view-collection';
|
||||
|
||||
export interface MergeOptions extends merge.Options {}
|
||||
|
||||
@ -221,7 +222,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
}
|
||||
this.options = opts;
|
||||
|
||||
this.sequelize = new Sequelize(opts);
|
||||
this.sequelize = new Sequelize(this.sequelizeOptions(this.options));
|
||||
|
||||
this.queryInterface = buildQueryInterface(this);
|
||||
|
||||
@ -297,6 +298,17 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
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() {
|
||||
this.on('beforeDefine', (model, options) => {
|
||||
if (this.options.underscored) {
|
||||
@ -416,14 +428,21 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
return options.inherits && lodash.castArray(options.inherits).length > 0;
|
||||
})();
|
||||
|
||||
const collection = hasValidInheritsOptions
|
||||
? new InheritedCollection(options, {
|
||||
database: this,
|
||||
})
|
||||
: new Collection(options, {
|
||||
database: this,
|
||||
});
|
||||
const hasViewOptions = options.viewName || options.view;
|
||||
|
||||
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.emit('afterDefineCollection', collection);
|
||||
@ -647,7 +666,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sequelize.getQueryInterface().dropAllTables(others);
|
||||
await this.queryInterface.dropAll(options);
|
||||
}
|
||||
|
||||
async collectionExistsInDb(name: string, options?: Transactionable) {
|
||||
|
@ -157,6 +157,11 @@ export abstract class Field {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (this.collection.isView()) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const columnReferencesCount = _.filter(
|
||||
this.collection.model.rawAttributes,
|
||||
(attr) => attr.field == this.columnName(),
|
||||
@ -232,6 +237,7 @@ export abstract class Field {
|
||||
if (this.dataType) {
|
||||
Object.assign(opts, { type: this.dataType });
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,7 @@ export class JsonbField extends Field {
|
||||
return DataTypes.JSON;
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonbFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'jsonb';
|
||||
}
|
||||
|
@ -21,3 +21,5 @@ export * from './update-associations';
|
||||
export { snakeCase } from './utils';
|
||||
export * from './value-parsers';
|
||||
export * from './collection-group-manager';
|
||||
export * from './view-collection';
|
||||
export * from './view/view-inference';
|
||||
|
@ -14,7 +14,7 @@ export class MockDatabase extends Database {
|
||||
}
|
||||
|
||||
export function getConfigByEnv() {
|
||||
return {
|
||||
const options = {
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
@ -33,10 +33,14 @@ export function getConfigByEnv() {
|
||||
timezone: process.env.DB_TIMEZONE,
|
||||
underscored: process.env.DB_UNDERSCORED === 'true',
|
||||
schema: process.env.DB_SCHEMA !== 'public' ? process.env.DB_SCHEMA : undefined,
|
||||
dialectOptions: {
|
||||
application_name: process.env.DB_DIALECT == 'postgres' ? 'nocobase.main' : undefined,
|
||||
},
|
||||
dialectOptions: {},
|
||||
};
|
||||
|
||||
if (process.env.DB_DIALECT == 'postgres') {
|
||||
options.dialectOptions['application_name'] = 'nocobase.main';
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function customLogger(queryString, queryObject) {
|
||||
|
@ -151,6 +151,10 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
|
||||
}
|
||||
|
||||
static async sync(options) {
|
||||
if (this.collection.isView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this as any;
|
||||
|
||||
const _schema = model._schema;
|
||||
|
@ -109,11 +109,21 @@ export class OptionsParser {
|
||||
return filterParams;
|
||||
}
|
||||
|
||||
protected inheritFromSubQuery(): any {
|
||||
return [
|
||||
protected inheritFromSubQuery(include): any {
|
||||
include.push([
|
||||
Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`),
|
||||
'__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) {
|
||||
@ -132,7 +142,7 @@ export class OptionsParser {
|
||||
}; // out put all fields by default
|
||||
|
||||
if (this.collection.isParent()) {
|
||||
attributes.include.push(this.inheritFromSubQuery());
|
||||
this.inheritFromSubQuery(attributes.include);
|
||||
}
|
||||
|
||||
if (this.options?.fields) {
|
||||
@ -146,7 +156,7 @@ export class OptionsParser {
|
||||
if (!Array.isArray(attributes)) {
|
||||
attributes = [];
|
||||
if (this.collection.isParent()) {
|
||||
attributes.push(this.inheritFromSubQuery());
|
||||
this.inheritFromSubQuery(attributes);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import QueryInterface from './query-interface';
|
||||
import { Collection } from '../collection';
|
||||
import { Transactionable } from 'sequelize';
|
||||
import sqlParser from '../sql-parser';
|
||||
|
||||
export default class MysqlQueryInterface extends QueryInterface {
|
||||
constructor(db) {
|
||||
@ -12,9 +13,57 @@ export default class MysqlQueryInterface extends QueryInterface {
|
||||
|
||||
const tableName = collection.model.tableName;
|
||||
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 });
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,11 +12,35 @@ export default class PostgresQueryInterface extends QueryInterface {
|
||||
const tableName = collection.model.tableName;
|
||||
const schema = collection.collectionSchema() || 'public';
|
||||
|
||||
const sql = `SELECT EXISTS(SELECT 1 FROM information_schema.tables
|
||||
const sql = `SELECT EXISTS(SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = '${tableName}')`;
|
||||
|
||||
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -4,9 +4,40 @@ import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequ
|
||||
|
||||
export default abstract class QueryInterface {
|
||||
sequelizeQueryInterface: SequelizeQueryInterface;
|
||||
|
||||
protected constructor(public db: Database) {
|
||||
this.sequelizeQueryInterface = db.sequelize.getQueryInterface();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import QueryInterface from './query-interface';
|
||||
import { Collection } from '../collection';
|
||||
import sqlParser from '../sql-parser';
|
||||
|
||||
export default class SqliteQueryInterface extends QueryInterface {
|
||||
constructor(db) {
|
||||
@ -11,8 +12,65 @@ export default class SqliteQueryInterface extends QueryInterface {
|
||||
|
||||
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 });
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
Op,
|
||||
Transactionable,
|
||||
UpdateOptions as SequelizeUpdateOptions,
|
||||
WhereOperators
|
||||
WhereOperators,
|
||||
} from 'sequelize';
|
||||
import { Collection } from './collection';
|
||||
import { Database } from './database';
|
||||
@ -340,7 +340,9 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
|
||||
if (this.collection.isParent()) {
|
||||
for (const row of rows) {
|
||||
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;
|
||||
|
||||
options.raw
|
||||
|
10698
packages/core/database/src/sql-parser/index.js
Normal file
10698
packages/core/database/src/sql-parser/index.js
Normal file
File diff suppressed because it is too large
Load Diff
2
packages/core/database/src/sql-parser/readme.md
Normal file
2
packages/core/database/src/sql-parser/readme.md
Normal file
@ -0,0 +1,2 @@
|
||||
use peggy to transform pegjs to sql parser
|
||||
https://github.com/peggyjs/peggy
|
1297
packages/core/database/src/sql-parser/sql.pegjs
Normal file
1297
packages/core/database/src/sql-parser/sql.pegjs
Normal file
File diff suppressed because it is too large
Load Diff
20
packages/core/database/src/view-collection.ts
Normal file
20
packages/core/database/src/view-collection.ts
Normal 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;
|
||||
}
|
||||
}
|
56
packages/core/database/src/view/field-type-map.ts
Normal file
56
packages/core/database/src/view/field-type-map.ts
Normal 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 };
|
106
packages/core/database/src/view/view-inference.ts
Normal file
106
packages/core/database/src/view/view-inference.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import { Database, HasManyRepository } from '@nocobase/database';
|
||||
import {ACL} from '@nocobase/acl';
|
||||
import {Database, HasManyRepository} from '@nocobase/database';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { prepareApp } from './prepare';
|
||||
import {MockServer} from '@nocobase/test';
|
||||
import {prepareApp} from './prepare';
|
||||
|
||||
describe('association test', () => {
|
||||
let app: MockServer;
|
||||
@ -36,6 +36,7 @@ describe('association test', () => {
|
||||
context: {},
|
||||
});
|
||||
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'comments',
|
||||
|
@ -60,6 +60,7 @@ describe('field indexes', () => {
|
||||
|
||||
it('field value cannot be duplicated with unique index', async () => {
|
||||
const tableName = 'test1';
|
||||
|
||||
// create a field with unique constraint
|
||||
const field = await agent.resource('collections.fields', tableName).create({
|
||||
values: {
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
@ -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();
|
||||
},
|
||||
};
|
55
packages/plugins/collection-manager/src/resourcers/views.ts
Normal file
55
packages/plugins/collection-manager/src/resourcers/views.ts
Normal 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
Loading…
Reference in New Issue
Block a user