feat: database view collection (#1587)

* test: create view collection

* feat: view collection class

* feat: list view

* chore: skip sync view collection

* test: should create view collection in difference schema

* test: create view collection in collection manager

* feat: create view collection by user sql

* test: view resourcer

* feat: view collection

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

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

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

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

* refactor: connect to database view

* refactor: sync from database

* chore: rename list view sql

* chore: list view fields api

* chore: create collection without viewName

* feat: bring out fields when selecting a view

* chore: bring out fields when selecting a view

* feat: view field inference class

* chore: bring out fields when selecting a view

* chore: sync form database view

* chore: sync form database view

* refactor: view collection local

* feat: view get api

* feat: database type infer

* feat: integer map

* chore: remove from in view list

* chore: build error

* chore: uniq collection

* fix: typo

* chore: replace collection list source field

* fix: destroy view collection

* chore: timestamp field map

* refactor: interface avalableTypes

* refactor: interface avalableTypes

* chore: list fields test

* refactor: interface avalableTypes

* chore: uiSchema response in field source

* fix: view query

* chore: collection snippet

* refactor: view collection support preview

* fix: handle field source

* fix: typo

* fix: configure fileds title

* fix: configure fileds title

* fix: configure fileds title

* fix: sync from databse interface

* fix: sync from databse interface

* feat: set fields api

* fix: sync from databse fix

* feat: possibleTypes

* chore: fields get

* fix: sync from databse

* fix: list view test

* fix: view test in difference schema

* chore: comment

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

* feat: view collection add field

* fix: inherit query with schema

* fix: test

* fix: ci test

* fix: test with schema

* chore: set pg default search path

* chore: mysql test

* fix: test with schema

* chore: test

* chore: action test

* chore: view column usage return type

* feat: mysql field inference

* fix: tableName

* chore: node sql parser

* fix: sql build

* fix: database build

* fix: mysql test

* feat: view collection uiSchema title

* fix: incorrect field source display  when switching views

* refactor: view collection not allow modify

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

* fix: mysql test

* fix: sqlite test

* fix: sqlite test

* fix: sqlite test

* fix: sqlite test

* chore: add id field as default target key

* style: style improve

* feat: load source field options

* style: style improve

* chore: disable remove column in view collection

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

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

* fix: query view in difference schema

* refactor: view collection viewname

* fix: query view collection in difference schema

* fix: field load

* chore: field options

* fix: mysql test

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

* fix: sqlite test

* chore: test

* fix: dump user views

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

* chore: sync from database display last field configuration

* chore: loadCollections

* chore: sync from database display last field configuration

* fix: field options merge issues

* style: preview table

* fix: view collection is allow using in kanban blocks

* refactor: code improve

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

* chore: disable infer field without interface

* feat: preview only shows source or interface fields

* fix: test

* refactor: locale

* feat: sql parser

* chore: remove node-sql-parser

* fix: yarn.lock

* test: view repository

* fix: view repository test

* chore: console.log

* chore: console.log

* fix: mysql without schema

* fix: mysql without schema

* chore: preview with field schema

* chore: tableActionInitializers

* style: preview style improve

* chore:  parameter is filter when there is no filterByTk

* fix: preview pagination

* fix: preview pagination

* style: preview table style improve

* fix: sync from database loading

* chore: preview performance optimization

* chore: preview performance optimization

* feat: limit & offset

* chore: preview performance optimization

* test: field with dot column

* fix: datetime interface display

* fix: missing boolean type

* fix: sync

* fix: sync from database

* style: style improve

* style: style improve

* style: style improve

* chore: preview table

* chore: preview table

* chore: preview table

* fix: styling

---------

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

View File

@ -25,13 +25,15 @@ services:
networks:
- 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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -180,6 +180,10 @@ export default {
"Collection template": "Collection template",
"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",

View File

@ -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": "編集",

View File

@ -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": "数据库使用的类型",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,6 +52,7 @@ export const TableActionColumnInitializers = (props: any) => {
const { t } = useTranslation();
const collection = useCollection();
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props'] || {};
const modifyFlag = (collection as any).template !== 'view';
return (
<SchemaInitializer.Button
insertPosition={'beforeEnd'}
@ -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';
},
},
],
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,8 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
tableName?: string;
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -36,6 +36,7 @@ describe('association test', () => {
context: {},
});
await db.getRepository('collections').create({
values: {
name: 'comments',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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