From 89635982b4b776f86c9adb8f6379d7dfd4d27ed0 Mon Sep 17 00:00:00 2001 From: YANG QIA <2013xile@gmail.com> Date: Mon, 25 Sep 2023 15:03:23 +0800 Subject: [PATCH] feat(db): add sql collection (#2419) * feat(db): add sql collection * feat: frontend * perf: issue of select * fix: sql model * fix: sql collection schema * fix: implement sql collection * fix: dependency * fix: remove type declaration in actions * fix: backend test * chore: remove some ops of block using sql collection * chore: remove sql collections from Form and Kanban * feat: add execute button to sql input * feat(backend): support infer fields by parsing sql * feat(frontend): support infer interface by parsing sql * fix: fix update issues and improve * fix: update issue * chore: update yarn.lock * fix: fix T-1548 * fix: fix T-1544 * fix: fix T-1545 * fix: fix T-1549 * fix: test * fix: fix T-1556 * fix: remove map action diviver * chore: debug * chore: remove schema of sql collection * fix: sql collection schema * chore: remove debug log & fix T-1555 * fix: fix T-1679 * fix: sql update issue * fix: sql attribute issue * fix: bug of star attribute * fix: test * fix: test * fix: reset fields when updating sql collection * fix(collection-manager): redundant fields after set collection fields * fix: test * fix: destory with individuals hook * chore: save * chore: test * fix: fields sync issue * fix: remove underscored option of sql collection * chore: mutex in fields.afterDestroy * fix: test * chore: yarn.lock * chore: update collections.setFields * feat: improve sql input * fix: fix T-1742 & improve * chore: fix conflicts * fix: workspace * fix: build * fix: test * chore: add translations * fix: reviewed issues * chore: update yarn.lock --------- Co-authored-by: ChengLei Shao --- .../CollectionManagerShortcut.tsx | 2 + .../Configuration/AddCollectionAction.tsx | 2 +- .../Configuration/AddFieldAction.tsx | 62 +- .../Configuration/CollectionFields.tsx | 2 + .../Configuration/EditCollectionAction.tsx | 11 +- .../Configuration/SyncSQLFieldsAction.tsx | 160 +++ .../Configuration/index.tsx | 1 + .../Configuration/schemas/collectionFields.ts | 8 + .../sql-collection/FieldsConfigure.tsx | 291 ++++++ .../sql-collection/PreviewTable.tsx | 58 ++ .../components/sql-collection/SQLInput.tsx | 68 ++ .../sql-collection/SQLRequestProvider.tsx | 47 + .../components/sql-collection/index.ts | 4 + .../collection-manager/templates/index.tsx | 1 + .../src/collection-manager/templates/sql.tsx | 80 ++ packages/core/client/src/locale/en_US.ts | 970 +++++++++--------- packages/core/client/src/locale/zh_CN.ts | 95 +- .../buttons/CalendarActionInitializers.tsx | 2 +- .../CalendarFormActionInitializers.tsx | 12 +- .../buttons/GridCardActionInitializers.tsx | 24 +- .../buttons/ListActionInitializers.tsx | 20 +- .../ReadPrettyFormActionInitializers.tsx | 2 +- .../buttons/RecordBlockInitializers.tsx | 28 +- .../buttons/TableActionColumnInitializers.tsx | 10 +- .../buttons/TableActionInitializers.tsx | 8 +- .../items/DetailsBlockInitializer.tsx | 4 +- .../client/src/schema-initializer/utils.ts | 5 +- .../sql-collection/infer-fields.test.ts | 63 ++ .../sql-collection/select-query.test.ts | 71 ++ .../database/src/__tests__/sql-parser.test.ts | 12 + packages/core/database/src/database.ts | 8 + packages/core/database/src/index.ts | 1 + .../core/database/src/sql-collection/index.ts | 2 + .../src/sql-collection/query-generator.ts | 69 ++ .../src/sql-collection/sql-collection.ts | 46 + .../database/src/sql-collection/sql-model.ts | 157 +++ .../src/server/models/collection.ts | 8 +- .../src/server/resourcers/sql.ts | 96 ++ .../src/server/server.ts | 4 +- .../src/server/__tests__/query.test.ts | 1 + .../src/server/actions/query.ts | 2 +- .../src/client/ImportInitializerProvider.tsx | 6 +- .../client/block/MapActionInitializers.tsx | 14 + yarn.lock | 2 +- 44 files changed, 1934 insertions(+), 605 deletions(-) create mode 100644 packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx create mode 100644 packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx create mode 100644 packages/core/client/src/collection-manager/templates/components/sql-collection/PreviewTable.tsx create mode 100644 packages/core/client/src/collection-manager/templates/components/sql-collection/SQLInput.tsx create mode 100644 packages/core/client/src/collection-manager/templates/components/sql-collection/SQLRequestProvider.tsx create mode 100644 packages/core/client/src/collection-manager/templates/components/sql-collection/index.ts create mode 100644 packages/core/client/src/collection-manager/templates/sql.tsx create mode 100644 packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts create mode 100644 packages/core/database/src/__tests__/sql-collection/select-query.test.ts create mode 100644 packages/core/database/src/sql-collection/index.ts create mode 100644 packages/core/database/src/sql-collection/query-generator.ts create mode 100644 packages/core/database/src/sql-collection/sql-collection.ts create mode 100644 packages/core/database/src/sql-collection/sql-model.ts create mode 100644 packages/plugins/@nocobase/plugin-collection-manager/src/server/resourcers/sql.ts diff --git a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx index 325c51130..fa4691d33 100644 --- a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx +++ b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx @@ -23,6 +23,7 @@ import { SyncFieldsActionCom, ViewCollectionField, ViewFieldAction, + SyncSQLFieldsAction, } from './Configuration'; import { CollectionCategroriesProvider } from './CollectionManagerProvider'; @@ -80,6 +81,7 @@ export const CollectionManagerPane = () => { EditCategoryAction, SyncFieldsAction, SyncFieldsActionCom, + SyncSQLFieldsAction, }} /> // diff --git a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx index 1ab063e7c..07b76a84e 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx @@ -98,7 +98,7 @@ const getSchema = (schema, category, compile): ISchema => { }; const getDefaultCollectionFields = (values) => { - if (values?.template === 'view') { + if (values?.template === 'view' || values?.template === 'sql') { return values.fields; } const defaults = values.fields ? [...values.fields] : []; diff --git a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx index 453365b0d..203bcbb99 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx @@ -302,35 +302,37 @@ export const AddFieldAction = (props) => { }; }, [getInterface, items, record]); return ( - - - - {children || ( - - )} - - - - + record.template !== 'sql' && ( + + + + {children || ( + + )} + + + + + ) ); }; diff --git a/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx b/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx index 3264c0a72..9b4792e85 100644 --- a/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx +++ b/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx @@ -25,6 +25,7 @@ import { OverridingCollectionField } from './OverridingCollectionField'; import { collection } from './schemas/collectionFields'; import { SyncFieldsAction } from './SyncFieldsAction'; import { ViewCollectionField } from './ViewInheritedField'; +import { SyncSQLFieldsAction } from './SyncSQLFieldsAction'; import { Input } from '../../schema-component/antd/input'; const indentStyle = css` @@ -419,6 +420,7 @@ export const CollectionFields = () => { > + { const ctx = useActionContext(); const { refresh } = useResourceActionContext(); const { resource, targetKey } = useResourceContext(); - const { [targetKey]: filterByTk } = useRecord(); + const { [targetKey]: filterByTk, template } = useRecord(); + const api = useAPIClient(); + const collectionResource = template === 'sql' ? api.resource('sqlCollection') : resource; return { async run() { await form.submit(); - await resource.update({ filterByTk, values: omit(form.values, ['fields']) }); + await collectionResource.update({ + filterByTk, + values: template === 'sql' ? form.values : omit(form.values, ['fields']), + }); ctx.setVisible(false); await form.reset(); refresh(); diff --git a/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx b/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx new file mode 100644 index 000000000..4d6b03f2f --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx @@ -0,0 +1,160 @@ +import { RecordProvider, useRecord } from '../../record-provider'; +import React, { useEffect, useMemo, useState } from 'react'; +import { ActionContextProvider, FormProvider, SchemaComponent, useActionContext } from '../../schema-component'; +import { SyncOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'antd'; +import { uid } from '@formily/shared'; +import { useCancelAction } from '../action-hooks'; +import { FieldsConfigure, PreviewTable, SQLRequestProvider } from '../templates/components/sql-collection'; +import { createForm } from '@formily/core'; +import { FormLayout } from '@formily/antd-v5'; +import { useCollectionManager } from '../hooks'; +import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider'; +import { useAPIClient } from '../../api-client'; +import { useField, useForm } from '@formily/react'; + +const schema = { + type: 'object', + properties: { + [uid()]: { + type: 'void', + title: '{{ t("Sync from database") }}', + 'x-component': 'Action.Drawer', + 'x-decorator': 'FormLayout', + 'x-decorator-props': { + layout: 'vertical', + }, + properties: { + config: { + type: 'void', + 'x-decorator': SQLRequestProvider, + 'x-decorator-props': { + manual: false, + }, + properties: { + sql: { + type: 'string', + }, + sources: { + type: 'array', + title: '{{t("Source collections")}}', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: true, + }, + 'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'], + }, + fields: { + type: 'array', + title: '{{t("Fields")}}', + 'x-decorator': 'FormItem', + 'x-component': FieldsConfigure, + required: true, + }, + table: { + type: 'void', + title: '{{t("Preview")}}', + 'x-decorator': 'FormItem', + 'x-component': PreviewTable, + }, + }, + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ useCancelAction }}', + }, + }, + submit: { + title: '{{ t("Submit") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ useSyncFromDB }}', + }, + }, + }, + }, + }, + }, + }, +}; + +const useSyncFromDB = (refreshCMList?: any) => { + const form = useForm(); + const ctx = useActionContext(); + const { refreshCM } = useCollectionManager(); + const { refresh } = useResourceActionContext(); + const { targetKey } = useResourceContext(); + const { [targetKey]: filterByTk } = useRecord(); + const api = useAPIClient(); + const field = useField(); + return { + async run() { + await form.submit(); + field.data = field.data || {}; + field.data.loading = true; + try { + await api.resource('sqlCollection').setFields({ + filterByTk, + values: { + fields: form.values.fields, + sources: form.values.sources, + }, + }); + ctx.setVisible(false); + await form.reset(); + field.data.loading = false; + refresh(); + await refreshCM(); + await refreshCMList?.(); + } catch (err) { + field.data.loading = false; + } + }, + }; +}; + +export const SyncSQLFieldsAction: React.FC<{ + refreshCMList: any; +}> = ({ refreshCMList }) => { + const record = useRecord(); + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const form = useMemo( + () => + createForm({ + initialValues: record, + }), + [record], + ); + + return ( + record.template === 'sql' && ( + + + + + useSyncFromDB(refreshCMList), + }} + /> + + + + ) + ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx index faec9c4f1..ad184f595 100644 --- a/packages/core/client/src/collection-manager/Configuration/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/index.tsx @@ -15,6 +15,7 @@ export * from './ConfigurationTabs'; export * from './AddCategoryAction'; export * from './EditCategoryAction'; export * from './SyncFieldsAction'; +export * from './SyncSQLFieldsAction'; registerValidateFormats({ uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/, diff --git a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts index cbea11e59..6a135986d 100644 --- a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts +++ b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts @@ -124,6 +124,14 @@ export const collectionFieldSchema: ISchema = { type: 'primary', }, }, + syncSQL: { + type: 'void', + title: '{{ t("Sync from database") }}', + 'x-component': 'SyncSQLFieldsAction', + 'x-component-props': { + type: 'primary', + }, + }, create: { type: 'void', title: '{{ t("Add new") }}', diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx b/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx new file mode 100644 index 000000000..3fa937d6a --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx @@ -0,0 +1,291 @@ +import { useTranslation } from 'react-i18next'; +import { useAsyncData } from '../../../../async-data-provider'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Cascader, Input, Select, Spin, Table, Tag } from 'antd'; +import { observer, useField, useForm } from '@formily/react'; +import { ArrayField } from '@formily/core'; +import { getOptions } from '../../../Configuration/interfaces'; +import { useCompile } from '../../../../schema-component'; +import { useCollectionManager } from '../../../hooks'; +import dayjs from 'dayjs'; +import { FieldOptions } from '@nocobase/database'; +import { ResourceActionContext, useResourceContext } from '../../../ResourceActionProvider'; +import { useRecord } from '../../../../record-provider'; +import { last } from 'lodash'; + +const inferInterface = (field: string, value: any) => { + if (field.toLowerCase().includes('id')) { + return 'id'; + } + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return 'integer'; + } + return 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (dayjs(value).isValid()) { + return 'datetime'; + } + return 'input'; +}; + +const useSourceFieldsOptions = () => { + const form = useForm(); + const { sources = [] } = form.values; + const { t } = useTranslation(); + const { getCollection, getInheritCollections, getParentCollectionFields } = useCollectionManager(); + const data = []; + sources.forEach((item: string) => { + const collection = getCollection(item); + const inherits = getInheritCollections(item); + const result = inherits.map((v) => { + const fields: FieldOptions[] = getParentCollectionFields(v, item); + return { + type: 'group', + key: v, + label: t(`Parent collection fields`) + t(`(${getCollection(v).title})`), + children: fields + .filter((v) => !['hasOne', 'hasMany', 'belongsToMany'].includes(v?.type)) + .map((k) => { + return { + value: k.name, + label: t(k.uiSchema?.title), + }; + }), + }; + }); + const children = (collection.fields as FieldOptions[]) + .filter((v) => !['hasOne', 'hasMany', 'belongsToMany'].includes(v?.type)) + ?.map((v) => { + return { value: v.name, label: t(v.uiSchema?.title) }; + }); + + data.push({ + value: item, + label: t(collection.title), + children: [...children, ...result], + }); + }); + return data; +}; + +export const FieldsConfigure = observer(() => { + const { t } = useTranslation(); + const [dataSource, setDataSource] = useState([]); + const { data: res, error, loading } = useAsyncData(); + const { data, fields: sourceFields } = res || {}; + const field: ArrayField = useField(); + const { data: curFields } = useContext(ResourceActionContext); + const compile = useCompile(); + const { getInterface, getCollectionField } = useCollectionManager(); + const interfaceOptions = useMemo( + () => + getOptions() + .filter((v) => !['relation'].includes(v.key)) + .map((options, index) => ({ + ...options, + key: index, + label: compile(options.label), + options: options.children.map((option) => ({ + ...option, + label: compile(option.label), + })), + })), + [compile], + ); + const sourceFieldsOptions = useSourceFieldsOptions(); + + const refGetInterface = useRef(getInterface); + useEffect(() => { + const fieldsMp = new Map(); + if (!loading) { + if (data && data.length) { + Object.entries(data?.[0] || {}).forEach(([col, val]) => { + const sourceField = sourceFields[col]; + const fieldInterface = inferInterface(col, val); + const defaultConfig = refGetInterface.current(fieldInterface)?.default; + const uiSchema = sourceField?.uiSchema || defaultConfig?.uiSchema || {}; + fieldsMp.set(col, { + name: col, + interface: sourceField?.interface || fieldInterface, + type: sourceField?.type || defaultConfig?.type, + source: sourceField?.source, + uiSchema: { + title: col, + ...uiSchema, + }, + }); + }); + } else { + Object.entries(sourceFields || {}).forEach(([col, val]: [string, any]) => + fieldsMp.set(col, { + name: col, + ...val, + uiSchema: { + title: col, + ...(val?.uiSchema || {}), + }, + }), + ); + } + } + + if (field.value?.length) { + field.value.forEach((item) => { + if (fieldsMp.has(item.name)) { + fieldsMp.set(item.name, item); + } + }); + } + + // if (curFields?.data.length) { + // curFields.data.forEach((field: any) => { + // if (fieldsMp.has(field.name)) { + // fieldsMp.set(field.name, field); + // } + // }); + // } + + const fields = Array.from(fieldsMp.values()); + if (!fields.length) { + return; + } + setDataSource(fields); + field.setValue(fields); + }, [loading, data, field, sourceFields, curFields]); + + if (loading) { + return ; + } + if (!data && !error) { + return ; + } + const err = error as any; + if (err) { + const errMsg = + err?.response?.data?.errors?.map?.((item: { message: string }) => item.message).join('\n') || err.message; + return ; + } + + const handleFieldChange = (record: any, index: number) => { + const fields = [...dataSource]; + fields.splice(index, 1, record); + setDataSource(fields); + field.setValue( + fields.map((f) => ({ + ...f, + source: typeof f.source === 'string' ? f.source : f.source?.filter?.(Boolean)?.join('.') || null, + })), + ); + }; + + const columns = [ + { + title: t('Field name'), + dataIndex: 'name', + key: 'name', + width: 130, + }, + { + title: t('Field source'), + dataIndex: 'source', + key: 'source', + width: 200, + render: (text: string, record: any, index: number) => { + const field = dataSource[index]; + return ( + { + let sourceField = sourceFields[value?.[1]]; + if (!sourceField) { + sourceField = getCollectionField(value?.join('.') || ''); + } + handleFieldChange( + { + ...field, + source: value, + interface: sourceField?.interface, + type: sourceField?.type, + uiSchema: sourceField?.uiSchema, + }, + index, + ); + }} + /> + ); + }, + }, + { + title: t('Field interface'), + dataIndex: 'interface', + key: 'interface', + width: 150, + render: (text: string, record: any, index: number) => { + const field = dataSource[index]; + return field.source ? ( + {compile(getInterface(text)?.title) || text} + ) : ( + + handleFieldChange({ ...field, uiSchema: { ...field?.uiSchema, title: e.target.value } }, index) + } + /> + ); + }, + }, + ]; + return ( +
+ ); +}); diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/PreviewTable.tsx b/packages/core/client/src/collection-manager/templates/components/sql-collection/PreviewTable.tsx new file mode 100644 index 000000000..6e3e17af4 --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/PreviewTable.tsx @@ -0,0 +1,58 @@ +import { useAsyncData } from '../../../../async-data-provider'; +import React, { useEffect } from 'react'; +import { Table } from 'antd'; +import { Schema, observer, useForm } from '@formily/react'; +import { useTranslation } from 'react-i18next'; + +export const PreviewTable = observer(() => { + const { data: res, loading, error } = useAsyncData(); + const { data } = res || {}; + const { t } = useTranslation(); + const form = useForm(); + + const fields = form.values.fields || []; + const titleMp = fields.reduce((mp: { [name: string]: string }, field: any) => { + mp[field.name] = field?.uiSchema?.title; + return mp; + }, {}); + + const columns = error + ? [] + : Object.keys(data?.[0] || {}).map((col) => { + const title = titleMp[col]; + return { + title: Schema.compile(title || col, { t }), + dataIndex: col, + key: col, + }; + }); + + const dataSource = error + ? [] + : data?.map((record: any, index: number) => { + const compiledRecord = Object.entries(record).reduce( + (mp: { [key: string]: any }, [key, val]: [string, any]) => { + if (typeof val !== 'string') { + mp[key] = val; + return mp; + } + const compiled = Schema.compile(val, { t }); + mp[key] = t(compiled); + return mp; + }, + {}, + ); + return { ...compiledRecord, key: index }; + }); + + return ( +
+ ); +}); diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLInput.tsx b/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLInput.tsx new file mode 100644 index 000000000..1a98c85fb --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLInput.tsx @@ -0,0 +1,68 @@ +import { useField, useForm } from '@formily/react'; +import { useAsyncData } from '../../../../async-data-provider'; +import React, { useEffect } from 'react'; +import { Input, SchemaComponent } from '../../../../schema-component'; +import { css } from '@emotion/css'; +import { Button } from 'antd'; +import { EditOutlined, RightSquareOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { Field } from '@formily/core'; + +export const SQLInput = ({ disabled }) => { + const { t } = useTranslation(); + const { run, loading, error } = useAsyncData(); + const field = useField(); + const execute = () => { + if (!field.value) { + return; + } + run(field.value); + }; + const toggleEditing = () => { + if (!disabled && !field.value) { + return; + } + if (!disabled) { + run(field.value); + } + field.setComponentProps({ + disabled: !disabled, + }); + }; + + useEffect(() => { + if (error) { + field.setComponentProps({ + disabled: false, + }); + } + }, [field, error]); + + return ( +
+ (field.value = e.target.value)} /> + + + + +
+ ); +}; diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLRequestProvider.tsx b/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLRequestProvider.tsx new file mode 100644 index 000000000..bdd4fb49b --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/SQLRequestProvider.tsx @@ -0,0 +1,47 @@ +import { useAPIClient, useRequest } from '../../../../api-client'; +import { AsyncDataProvider } from '../../../../async-data-provider'; +import React, { useEffect, useRef } from 'react'; +import { useForm } from '@formily/react'; +import { useRecord } from '../../../../record-provider'; + +export const SQLRequestProvider: React.FC<{ + manual?: boolean; +}> = (props) => { + const api = useAPIClient(); + const form = useForm(); + const record = useRecord(); + let { manual } = props; + manual = manual === undefined ? true : manual; + + const result = useRequest( + (sql: string) => + api + .resource('sqlCollection') + .execute({ + values: { + sql, + }, + }) + .then((res) => res?.data?.data || { data: [], fields: [], sources: [] }), + { + manual: true, + onSuccess: (data) => { + const { sources } = data; + const formSources = form.values.sources || []; + form.setValuesIn('sources', Array.from(new Set([...formSources, ...sources]))); + }, + }, + ); + + const { run } = result; + const sql = form.values.sql || record.sql; + const first = useRef(true); + useEffect(() => { + if (sql && first.current) { + run(sql); + } + first.current = false; + }, [manual, run, sql]); + + return {props.children}; +}; diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/index.ts b/packages/core/client/src/collection-manager/templates/components/sql-collection/index.ts new file mode 100644 index 000000000..cede88f81 --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/index.ts @@ -0,0 +1,4 @@ +export * from './FieldsConfigure'; +export * from './PreviewTable'; +export * from './SQLRequestProvider'; +export * from './SQLInput'; diff --git a/packages/core/client/src/collection-manager/templates/index.tsx b/packages/core/client/src/collection-manager/templates/index.tsx index 2b4b8524c..94154160a 100644 --- a/packages/core/client/src/collection-manager/templates/index.tsx +++ b/packages/core/client/src/collection-manager/templates/index.tsx @@ -3,3 +3,4 @@ export * from './general'; export * from './tree'; export * from './expression'; export * from './view'; +export * from './sql'; diff --git a/packages/core/client/src/collection-manager/templates/sql.tsx b/packages/core/client/src/collection-manager/templates/sql.tsx new file mode 100644 index 000000000..c1e3d21ca --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/sql.tsx @@ -0,0 +1,80 @@ +import { Field } from '@formily/core'; +import { useAsyncData } from '../../async-data-provider'; +import { SQLInput, PreviewTable, FieldsConfigure, SQLRequestProvider } from './components/sql-collection'; +import { getConfigurableProperties } from './properties'; +import { ICollectionTemplate } from './types'; +import { i18n } from '../../i18n'; + +export const sql: ICollectionTemplate = { + name: 'sql', + title: '{{t("SQL collection")}}', + order: 4, + color: 'yellow', + default: { + fields: [], + }, + configurableProperties: { + title: { + type: 'string', + title: '{{ t("Collection display name") }}', + required: true, + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + name: { + type: 'string', + title: '{{t("Collection name")}}', + required: true, + 'x-disabled': '{{ !createOnly }}', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-validator': 'uid', + description: + "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}", + }, + config: { + type: 'void', + 'x-decorator': SQLRequestProvider, + properties: { + sql: { + type: 'string', + title: '{{t("SQL")}}', + 'x-decorator': 'FormItem', + 'x-component': SQLInput, + required: true, + 'x-validator': (value: string, rules, { form }) => { + const field = form.query('sql').take() as Field; + if (!field.componentProps.disabled) { + return i18n.t('Please confirm the SQL statement first'); + } + return ''; + }, + }, + sources: { + type: 'array', + title: '{{t("Source collections")}}', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: true, + }, + 'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'], + }, + fields: { + type: 'array', + title: '{{t("Fields")}}', + 'x-decorator': 'FormItem', + 'x-component': FieldsConfigure, + required: true, + }, + table: { + type: 'void', + title: '{{t("Preview")}}', + 'x-decorator': 'FormItem', + 'x-component': PreviewTable, + }, + }, + }, + ...getConfigurableProperties('category'), + }, +}; diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 13b75e2b2..de2a47327 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -117,485 +117,498 @@ export default { 'Tablet device': 'Tablet device', 'Desktop device': 'Desktop device', 'Large screen device': 'Large screen device', - "Collapse": "Collapse", - "Select data source": "Select data source", - "Calendar": "Calendar", - "Delete events": "Delete events", - "This event": "This event", - "This and following events": "This and following events", - "All events": "All events", - "Delete this event?": "Delete this event?", - "Delete Event": "Delete Event", - "Kanban": "Kanban", - "Gantt":"Gantt", - "Create gantt block":"Create gantt block", - "Progress field":"Progress field", - "Time scale":"Time scale", - "Hour":"Hour", - "Quarter of day":"Quarter of day", - "Half of day":"Half of day", - "Year":"Year", - "QuarterYear":"QuarterYear", - "Select grouping field": "Select grouping field", - "Media": "Media", - "Markdown": "Markdown", - "Wysiwyg": "Wysiwyg", - "Chart blocks": "Chart blocks", - "Column chart": "Column chart", - "Bar chart": "Bar chart", - "Line chart": "Line chart", - "Pie chart": "Pie chart", - "Area chart": "Area chart", - "Other chart": "Other chart", - "Other blocks": "Other blocks", - "In configuration": "In configuration", - "Chart title": "Chart title", - "Chart type": "Chart type", - "Chart config": "Chart config", - "Templates": "Templates", - "Select template": "Select template", - "Action logs": "Action logs", - "Create template": "Create template", - "Edit markdown": "Edit markdown", - "Add block": "Add block", - "Add new": "Add new", - "Add record": "Add record", - 'Add child':'Add child', - 'Collapse all':'Collapse all', - 'Expand all':'Expand all', - 'Expand/Collapse':'Expand/Collapse', - 'Default collapse':'Default collapse', - "Tree table":"Tree table", - "Custom field display name": "Custom field display name", - "Display fields": "Display collection fields", - "Edit record": "Edit record", - "Delete menu item": "Delete menu item", - "Add page": "Add page", - "Add group": "Add group", - "Add link": "Add link", - "Insert above": "Insert above", - "Insert below": "Insert below", - "Save": "Save", - "Delete block": "Delete block", - "Are you sure you want to delete it?": "Are you sure you want to delete it?", - "This is a demo text, **supports Markdown syntax**.": "This is a demo text, **supports Markdown syntax**.", - "Filter": "Filter", - "Connect data blocks": "Connect data blocks", - "Action type": "Action type", - "Actions": "Actions", - "Insert": "Insert", - "Insert if not exists": "Insert if not exists", - "Insert if not exists, or update": "Insert if not exists, or update", - "Determine whether a record exists by the following fields": "Determine whether a record exists by the following fields", - "Update": "Update", - "View": "View", - "View record": "View record", - "Refresh": "Refresh", - "Data changes": "Data changes", - "Field name": "Field name", - "Before change": "Before change", - "After change": "After change", - "Delete record": "Delete record", - "Create collection": "Create collection", - "Collection display name": "Collection display name", - "Collection name": "Collection name", - "Inherits": "Inherits", - "Generate ID field automatically": "Generate ID field automatically", - "Store the creation user of each record": "Store the creation user of each record", - "Store the last update user of each record": "Store the last update user of each record", - "Store the creation time of each record": "Store the creation time of each record", - "Store the last update time of each record": "Store the last update time of each record", - "More options": "More options", - "Records can be sorted": "Records can be sorted", - "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.", - "Edit": "Edit", - "Edit collection": "Edit collection", - "Configure fields": "Configure fields", - "Configure columns": "Configure columns", - "Edit field": "Edit field", - "Override": "Override", - "Override field": "Override field", - "Configure fields of {{title}}": "Configure fields of {{title}}", - "Association fields filter": "Association fields filter", - "PK & FK fields": "PK & FK fields", - "Association fields": "Association fields", - "Choices fields": "Choices fields", - "System fields": "System fields", - "General fields": "General fields", - "Inherited fields": "Inherited fields", - "Parent collection fields": "Parent collection fields", - "Basic": "Basic", - "Single line text": "Single line text", - "Long text": "Long text", - "Phone": "Phone", - "Email": "Email", - "Number": "Number", - "Integer": "Integer", - "Percent": "Percent", - "Password": "Password", - "Advanced type": "Advanced", - "Formula": "Formula", - "Formula description": "Compute a value in each record based on other fields in the same record.", - "Choices": "Choices", - "Checkbox": "Checkbox", - "Single select": "Single select", - "Multiple select": "Multiple select", - "Radio group": "Radio group", - "Checkbox group": "Checkbox group", - "China region": "China region", - "Date & Time": "Date & Time", - "Datetime": "Datetime", - "Relation": "Relation", - "Link to": "Link to", - "Link to description": "Used to create collection relationships quickly and compatible with most common scenarios. Suitable for non-developer use. When present as a field, it is a drop-down selection used to select records from the target collection. Once created, it will simultaneously generate the associated fields of the current collection in the target collection.", - "Sub-table": "Sub-table", - "Sub-details":"Sub-details", - "Sub-form(Popover)":"Sub-form(Popover)", - "System info": "System info", - "Created at": "Created at", - "Last updated at": "Last updated at", - "Created by": "Created by", - "Last updated by": "Last updated by", - "Add field": "Add field", - "Field display name": "Field display name", - "Field type": "Field type", - "Field interface": "Field interface", - "Date format": "Date format", - "Year/Month/Day": "Year/Month/Day", - "Year-Month-Day": "Year-Month-Day", - "Day/Month/Year": "Day/Month/Year", - "Show time": "Show time", - "Time format": "Time format", - "12 hour": "12 hour", - "24 hour": "24 hour", - "Relationship type": "Relationship type", - "Inverse relationship type": "Inverse relationship type", - "Source collection": "Source collection", - "Source key": "Source key", - "Target collection": "Target collection", - "Through collection": "Through collection", - "Target key": "Target key", - "Foreign key": "Foreign key", - "One to one": "One to one", - "One to many": "One to many", - "Many to one": "Many to one", - "Many to many": "Many to many", - "Foreign key 1": "Foreign key 1", - "Foreign key 2": "Foreign key 2", - "One to one description": "Used to create one-to-one relationships. For example, a user has a profile.", - "One to many description": "Used to create a one-to-many relationship. For example, a country will have many cities and a city can only be in one country. When present as a field, it is a sub-table that displays the records of the associated collection. When created, a Many-to-one field is automatically generated in the associated collection.", - "Many to one description": "Used to create many-to-one relationships. For example, a city can belong to only one country and a country can have many cities. When present as a field, it is a drop-down selection used to select record from the associated collection. Once created, a One-to-many field is automatically generated in the associated collection.", - "Many to many description": "Used to create many-to-many relationships. For example, a student will have many teachers and a teacher will have many students. When present as a field, it is a drop-down selection used to select records from the associated collection.", - "Generated automatically if left blank": "Generated automatically if left blank", - "Display association fields": "Display association fields", - "Display field title": "Display field title", - "Field component": "Field component", - "Allow multiple": "Allow multiple", - "Quick upload": "Quick upload", - "Select file": "Select file", - "Subtable": "Sub-table", - "Sub-form": "Sub-form", - "Field mode":"Field mode", - "Allow add new data":"Allow add new data", - "Record picker": "Record picker", - "Toggles the subfield mode": "Toggles the subfield mode", - "Selector mode": "Selector mode", - "Subtable mode": "Sub-table mode", - "Subform mode": "Sub-form mode", - "Edit block title": "Edit block title", - "Block title": "Block title", - "Pattern": "Pattern", - "Operator": "Operator", - "Editable": "Editable", - "Readonly": "Readonly", - "Easy-reading": "Easy-reading", - "Add filter": "Add filter", - "Add filter group": "Add filter group", - "Comparision": "Comparision", - "is": "is", - "is not": "is not", - "contains": "contains", - "does not contain": "does not contain", - "starts with": "starts with", - "not starts with": "not starts with", - "ends with": "ends with", - "not ends with": "not ends with", - "is empty": "is empty", - "is not empty": "is not empty", - "Edit chart": "Edit chart", - "Add text": "Add text", - "Filterable fields": "Filterable fields", - "Edit button": "Edit button", - "Hide": "Hide", - "Enable actions": "Enable actions", - "Import": "Import", - "Export": "Export", - "Customize": "Customize", - "Custom": "Custom", - "Function": "Function", - "Popup form": "Popup form", - "Flexible popup": "Flexible popup", - "Configure actions": "Configure actions", - "Display order number": "Display order number", - "Enable drag and drop sorting": "Enable drag and drop sorting", - "Triggered when the row is clicked": "Triggered when the row is clicked", - "Add tab": "Add tab", - "Disable tabs": "Disable tabs", - "Details": "Details", - "Edit tab": "Edit tab", - "Relationship blocks": "Relationship blocks", - "Select record": "Select record", - "Display name": "Display name", - "Select icon": "Select icon", - "Custom column name": "Custom column name", - "Edit description": "Edit description", - "Required": "Required", - "Unique": "Unique", - "Label field": "Label field", - "Default is the ID field": "Default is the ID field", - "Set default sorting rules": "Set default sorting rules", - "Set validation rules": "Set validation rules", - "Max length": "Max length", - "Min length": "Min length", - "Maximum": "Maximum", - "Minimum": "Minimum", - "Max length must greater than min length": "Max length must greater than min length", - "Min length must less than max length": "Min length must less than max length", - "Maximum must greater than minimum": "Maximum must greater than minimum", - "Minimum must less than maximum": "Minimum must less than maximum", - "Validation rule": "Validation rule", - "Add validation rule": "Add validation rule", - "Format": "Format", - "Regular expression": "Pattern", - "Error message": "Error message", - "Length": "Length", - "The field value cannot be greater than ": "The field value cannot be greater than ", - "The field value cannot be less than ": "The field value cannot be less than ", - "The field value is not an integer number": "The field value is not an integer number", - "Set default value": "Set default value", - "Default value": "Default value", - "is before": "is before", - "is after": "is after", - "is on or after": "is on or after", - "is on or before": "is on or before", - "is between": "is between", - "Upload": "Upload", - "Select level": "Select level", - "Province": "Province", - "City": "City", - "Area": "Area", - "Street": "Street", - "Village": "Village", - "Must select to the last level": "Must select to the last level", - "Move {{title}} to": "Move {{title}} to", - "Target position": "Target position", - "After": "After", - "Before": "Before", - "Add {{type}} before \"{{title}}\"": "Add {{type}} before \"{{title}}\"", - "Add {{type}} after \"{{title}}\"": "Add {{type}} after \"{{title}}\"", - "Add {{type}} in \"{{title}}\"": "Add {{type}} in \"{{title}}\"", - "Original name": "Original name", - "Custom name": "Custom name", - "Custom Title": "Custom Title", - "Options": "Options", - "Option value": "Option value", - "Option label": "Option label", - "Color": "Color", - "Add option": "Add option", - "Related collection": "Related collection", - "Allow linking to multiple records": "Allow linking to multiple records", - "Allow uploading multiple files": "Allow uploading multiple files", - "Configure calendar": "Configure calendar", - "Title field": "Title field", - "Custom title": "Custom title", - "Daily": "Daily", - "Weekly": "Weekly", - "Monthly": "Monthly", - "Yearly": "Yearly", - "Repeats": "Repeats", - "Show lunar": "Show lunar", - "Start date field": "Start date field", - "End date field": "End date field", - "Navigate": "Navigate", - "Title": "Title", - "Description": "Description", - "Select view": "Select view", - "Reset": "Reset", - "Importable fields": "Importable fields", - "Exportable fields": "Exportable fields", - "Saved successfully": "Saved successfully", - "Nickname": "Nickname", - "Sign in": "Sign in", - "Sign in via account": "Sign in via account", - "Sign in via phone": "Sign in via phone", - "Create an account": "Create an account", - "Sign up": "Sign up", - "Confirm password": "Confirm password", - "Log in with an existing account": "Log in with an existing account", - "Signed up successfully. It will jump to the login page.": "Signed up successfully. It will jump to the login page.", - "Password mismatch": "Password mismatch", - "Users": "Users", - "Verification code": "Verification code", - "Send code": "Send code", - "Retry after {{count}} seconds": "Retry after {{count}} seconds", - "Roles": "Roles", - "Add role": "Add role", - "Role name": "Role name", - "Configure": "Configure", - "Configure permissions": "Configure permissions", - "Edit role": "Edit role", - "Action permissions": "Action permissions", - "Menu permissions": "Menu permissions", - "Menu item name": "Menu item name", - "Allow access": "Allow access", - "Action name": "Action name", - "Allow action": "Allow action", - "Action scope": "Action scope", - "Operate on new data": "Operate on new data", - "Operate on existing data": "Operate on existing data", - "Yes": "Yes", - "No": "No", - "Red": "Red", - "Magenta": "Magenta", - "Volcano": "Volcano", - "Orange": "Orange", - "Gold": "Gold", - "Lime": "Lime", - "Green": "Green", - "Cyan": "Cyan", - "Blue": "Blue", - "Geek blue": "Geek blue", - "Purple": "Purple", - "Default": "Default", - "Add card": "Add card", - "edit title": "edit title", - "Turn pages": "Turn pages", - "Others": "Others", - "Save as template": "Save as template", - "Save as block template": "Save as block template", - "Block templates": "Block templates", - "Convert reference to duplicate": "Convert reference to duplicate", - "Template name": "Template name", - "Block type": "Block type", - "No blocks to connect": "No blocks to connect", - "Action column": "Action column", - "Records per page": "Records per page", - "(Fields only)": "(Fields only)", - "Button title": "Button title", - "Button icon": "Button icon", - "Submitted successfully": "Submitted successfully", - "Operation succeeded": "Operation succeeded", - "Operation failed": "Operation failed", - "Open mode": "Open mode", - "Popup size": "Popup size", - "Small": "Small", - "Middle": "Middle", - "Large": "Large", - "Menu item title": "Menu item title", - "Menu item icon": "Menu item icon", - "Target": "Target", - "Position": "Position", - "Insert before": "Insert before", - "Insert after": "Insert after", - "UI Editor": "UI Editor", - "ASC": "ASC", - "DESC": "DESC", - "Add sort field": "Add sort field", - "ID": "ID", - "Identifier for program usage. Support letters, numbers and underscores, must start with an letter.": "Identifier for program usage. Support letters, numbers and underscores, must start with an letter.", - "Drawer": "Drawer", - "Dialog": "Dialog", - "Delete action": "Delete action", - "Custom column title": "Custom column title", + Collapse: 'Collapse', + 'Select data source': 'Select data source', + Calendar: 'Calendar', + 'Delete events': 'Delete events', + 'This event': 'This event', + 'This and following events': 'This and following events', + 'All events': 'All events', + 'Delete this event?': 'Delete this event?', + 'Delete Event': 'Delete Event', + Kanban: 'Kanban', + Gantt: 'Gantt', + 'Create gantt block': 'Create gantt block', + 'Progress field': 'Progress field', + 'Time scale': 'Time scale', + Hour: 'Hour', + 'Quarter of day': 'Quarter of day', + 'Half of day': 'Half of day', + Year: 'Year', + QuarterYear: 'QuarterYear', + 'Select grouping field': 'Select grouping field', + Media: 'Media', + Markdown: 'Markdown', + Wysiwyg: 'Wysiwyg', + 'Chart blocks': 'Chart blocks', + 'Column chart': 'Column chart', + 'Bar chart': 'Bar chart', + 'Line chart': 'Line chart', + 'Pie chart': 'Pie chart', + 'Area chart': 'Area chart', + 'Other chart': 'Other chart', + 'Other blocks': 'Other blocks', + 'In configuration': 'In configuration', + 'Chart title': 'Chart title', + 'Chart type': 'Chart type', + 'Chart config': 'Chart config', + Templates: 'Templates', + 'Select template': 'Select template', + 'Action logs': 'Action logs', + 'Create template': 'Create template', + 'Edit markdown': 'Edit markdown', + 'Add block': 'Add block', + 'Add new': 'Add new', + 'Add record': 'Add record', + 'Add child': 'Add child', + 'Collapse all': 'Collapse all', + 'Expand all': 'Expand all', + 'Expand/Collapse': 'Expand/Collapse', + 'Default collapse': 'Default collapse', + 'Tree table': 'Tree table', + 'Custom field display name': 'Custom field display name', + 'Display fields': 'Display collection fields', + 'Edit record': 'Edit record', + 'Delete menu item': 'Delete menu item', + 'Add page': 'Add page', + 'Add group': 'Add group', + 'Add link': 'Add link', + 'Insert above': 'Insert above', + 'Insert below': 'Insert below', + Save: 'Save', + 'Delete block': 'Delete block', + 'Are you sure you want to delete it?': 'Are you sure you want to delete it?', + 'This is a demo text, **supports Markdown syntax**.': 'This is a demo text, **supports Markdown syntax**.', + Filter: 'Filter', + 'Connect data blocks': 'Connect data blocks', + 'Action type': 'Action type', + Actions: 'Actions', + Insert: 'Insert', + 'Insert if not exists': 'Insert if not exists', + 'Insert if not exists, or update': 'Insert if not exists, or update', + 'Determine whether a record exists by the following fields': + 'Determine whether a record exists by the following fields', + Update: 'Update', + View: 'View', + 'View record': 'View record', + Refresh: 'Refresh', + 'Data changes': 'Data changes', + 'Field name': 'Field name', + 'Before change': 'Before change', + 'After change': 'After change', + 'Delete record': 'Delete record', + 'Create collection': 'Create collection', + 'Collection display name': 'Collection display name', + 'Collection name': 'Collection name', + Inherits: 'Inherits', + 'Generate ID field automatically': 'Generate ID field automatically', + 'Store the creation user of each record': 'Store the creation user of each record', + 'Store the last update user of each record': 'Store the last update user of each record', + 'Store the creation time of each record': 'Store the creation time of each record', + 'Store the last update time of each record': 'Store the last update time of each record', + 'More options': 'More options', + 'Records can be sorted': 'Records can be sorted', + '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.', + Edit: 'Edit', + 'Edit collection': 'Edit collection', + 'Configure fields': 'Configure fields', + 'Configure columns': 'Configure columns', + 'Edit field': 'Edit field', + Override: 'Override', + 'Override field': 'Override field', + 'Configure fields of {{title}}': 'Configure fields of {{title}}', + 'Association fields filter': 'Association fields filter', + 'PK & FK fields': 'PK & FK fields', + 'Association fields': 'Association fields', + 'Choices fields': 'Choices fields', + 'System fields': 'System fields', + 'General fields': 'General fields', + 'Inherited fields': 'Inherited fields', + 'Parent collection fields': 'Parent collection fields', + Basic: 'Basic', + 'Single line text': 'Single line text', + 'Long text': 'Long text', + Phone: 'Phone', + Email: 'Email', + Number: 'Number', + Integer: 'Integer', + Percent: 'Percent', + Password: 'Password', + 'Advanced type': 'Advanced', + Formula: 'Formula', + 'Formula description': 'Compute a value in each record based on other fields in the same record.', + Choices: 'Choices', + Checkbox: 'Checkbox', + 'Single select': 'Single select', + 'Multiple select': 'Multiple select', + 'Radio group': 'Radio group', + 'Checkbox group': 'Checkbox group', + 'China region': 'China region', + 'Date & Time': 'Date & Time', + Datetime: 'Datetime', + Relation: 'Relation', + 'Link to': 'Link to', + 'Link to description': + 'Used to create collection relationships quickly and compatible with most common scenarios. Suitable for non-developer use. When present as a field, it is a drop-down selection used to select records from the target collection. Once created, it will simultaneously generate the associated fields of the current collection in the target collection.', + 'Sub-table': 'Sub-table', + 'Sub-details': 'Sub-details', + 'Sub-form(Popover)': 'Sub-form(Popover)', + 'System info': 'System info', + 'Created at': 'Created at', + 'Last updated at': 'Last updated at', + 'Created by': 'Created by', + 'Last updated by': 'Last updated by', + 'Add field': 'Add field', + 'Field display name': 'Field display name', + 'Field type': 'Field type', + 'Field interface': 'Field interface', + 'Date format': 'Date format', + 'Year/Month/Day': 'Year/Month/Day', + 'Year-Month-Day': 'Year-Month-Day', + 'Day/Month/Year': 'Day/Month/Year', + 'Show time': 'Show time', + 'Time format': 'Time format', + '12 hour': '12 hour', + '24 hour': '24 hour', + 'Relationship type': 'Relationship type', + 'Inverse relationship type': 'Inverse relationship type', + 'Source collection': 'Source collection', + 'Source key': 'Source key', + 'Target collection': 'Target collection', + 'Through collection': 'Through collection', + 'Target key': 'Target key', + 'Foreign key': 'Foreign key', + 'One to one': 'One to one', + 'One to many': 'One to many', + 'Many to one': 'Many to one', + 'Many to many': 'Many to many', + 'Foreign key 1': 'Foreign key 1', + 'Foreign key 2': 'Foreign key 2', + 'One to one description': 'Used to create one-to-one relationships. For example, a user has a profile.', + 'One to many description': + 'Used to create a one-to-many relationship. For example, a country will have many cities and a city can only be in one country. When present as a field, it is a sub-table that displays the records of the associated collection. When created, a Many-to-one field is automatically generated in the associated collection.', + 'Many to one description': + 'Used to create many-to-one relationships. For example, a city can belong to only one country and a country can have many cities. When present as a field, it is a drop-down selection used to select record from the associated collection. Once created, a One-to-many field is automatically generated in the associated collection.', + 'Many to many description': + 'Used to create many-to-many relationships. For example, a student will have many teachers and a teacher will have many students. When present as a field, it is a drop-down selection used to select records from the associated collection.', + 'Generated automatically if left blank': 'Generated automatically if left blank', + 'Display association fields': 'Display association fields', + 'Display field title': 'Display field title', + 'Field component': 'Field component', + 'Allow multiple': 'Allow multiple', + 'Quick upload': 'Quick upload', + 'Select file': 'Select file', + Subtable: 'Sub-table', + 'Sub-form': 'Sub-form', + 'Field mode': 'Field mode', + 'Allow add new data': 'Allow add new data', + 'Record picker': 'Record picker', + 'Toggles the subfield mode': 'Toggles the subfield mode', + 'Selector mode': 'Selector mode', + 'Subtable mode': 'Sub-table mode', + 'Subform mode': 'Sub-form mode', + 'Edit block title': 'Edit block title', + 'Block title': 'Block title', + Pattern: 'Pattern', + Operator: 'Operator', + Editable: 'Editable', + Readonly: 'Readonly', + 'Easy-reading': 'Easy-reading', + 'Add filter': 'Add filter', + 'Add filter group': 'Add filter group', + Comparision: 'Comparision', + is: 'is', + 'is not': 'is not', + contains: 'contains', + 'does not contain': 'does not contain', + 'starts with': 'starts with', + 'not starts with': 'not starts with', + 'ends with': 'ends with', + 'not ends with': 'not ends with', + 'is empty': 'is empty', + 'is not empty': 'is not empty', + 'Edit chart': 'Edit chart', + 'Add text': 'Add text', + 'Filterable fields': 'Filterable fields', + 'Edit button': 'Edit button', + Hide: 'Hide', + 'Enable actions': 'Enable actions', + Import: 'Import', + Export: 'Export', + Customize: 'Customize', + Custom: 'Custom', + Function: 'Function', + 'Popup form': 'Popup form', + 'Flexible popup': 'Flexible popup', + 'Configure actions': 'Configure actions', + 'Display order number': 'Display order number', + 'Enable drag and drop sorting': 'Enable drag and drop sorting', + 'Triggered when the row is clicked': 'Triggered when the row is clicked', + 'Add tab': 'Add tab', + 'Disable tabs': 'Disable tabs', + Details: 'Details', + 'Edit tab': 'Edit tab', + 'Relationship blocks': 'Relationship blocks', + 'Select record': 'Select record', + 'Display name': 'Display name', + 'Select icon': 'Select icon', + 'Custom column name': 'Custom column name', + 'Edit description': 'Edit description', + Required: 'Required', + Unique: 'Unique', + 'Label field': 'Label field', + 'Default is the ID field': 'Default is the ID field', + 'Set default sorting rules': 'Set default sorting rules', + 'Set validation rules': 'Set validation rules', + 'Max length': 'Max length', + 'Min length': 'Min length', + Maximum: 'Maximum', + Minimum: 'Minimum', + 'Max length must greater than min length': 'Max length must greater than min length', + 'Min length must less than max length': 'Min length must less than max length', + 'Maximum must greater than minimum': 'Maximum must greater than minimum', + 'Minimum must less than maximum': 'Minimum must less than maximum', + 'Validation rule': 'Validation rule', + 'Add validation rule': 'Add validation rule', + Format: 'Format', + 'Regular expression': 'Pattern', + 'Error message': 'Error message', + Length: 'Length', + 'The field value cannot be greater than ': 'The field value cannot be greater than ', + 'The field value cannot be less than ': 'The field value cannot be less than ', + 'The field value is not an integer number': 'The field value is not an integer number', + 'Set default value': 'Set default value', + 'Default value': 'Default value', + 'is before': 'is before', + 'is after': 'is after', + 'is on or after': 'is on or after', + 'is on or before': 'is on or before', + 'is between': 'is between', + Upload: 'Upload', + 'Select level': 'Select level', + Province: 'Province', + City: 'City', + Area: 'Area', + Street: 'Street', + Village: 'Village', + 'Must select to the last level': 'Must select to the last level', + 'Move {{title}} to': 'Move {{title}} to', + 'Target position': 'Target position', + After: 'After', + Before: 'Before', + 'Add {{type}} before "{{title}}"': 'Add {{type}} before "{{title}}"', + 'Add {{type}} after "{{title}}"': 'Add {{type}} after "{{title}}"', + 'Add {{type}} in "{{title}}"': 'Add {{type}} in "{{title}}"', + 'Original name': 'Original name', + 'Custom name': 'Custom name', + 'Custom Title': 'Custom Title', + Options: 'Options', + 'Option value': 'Option value', + 'Option label': 'Option label', + Color: 'Color', + 'Add option': 'Add option', + 'Related collection': 'Related collection', + 'Allow linking to multiple records': 'Allow linking to multiple records', + 'Allow uploading multiple files': 'Allow uploading multiple files', + 'Configure calendar': 'Configure calendar', + 'Title field': 'Title field', + 'Custom title': 'Custom title', + Daily: 'Daily', + Weekly: 'Weekly', + Monthly: 'Monthly', + Yearly: 'Yearly', + Repeats: 'Repeats', + 'Show lunar': 'Show lunar', + 'Start date field': 'Start date field', + 'End date field': 'End date field', + Navigate: 'Navigate', + Title: 'Title', + Description: 'Description', + 'Select view': 'Select view', + Reset: 'Reset', + 'Importable fields': 'Importable fields', + 'Exportable fields': 'Exportable fields', + 'Saved successfully': 'Saved successfully', + Nickname: 'Nickname', + 'Sign in': 'Sign in', + 'Sign in via account': 'Sign in via account', + 'Sign in via phone': 'Sign in via phone', + 'Create an account': 'Create an account', + 'Sign up': 'Sign up', + 'Confirm password': 'Confirm password', + 'Log in with an existing account': 'Log in with an existing account', + 'Signed up successfully. It will jump to the login page.': 'Signed up successfully. It will jump to the login page.', + 'Password mismatch': 'Password mismatch', + Users: 'Users', + 'Verification code': 'Verification code', + 'Send code': 'Send code', + 'Retry after {{count}} seconds': 'Retry after {{count}} seconds', + Roles: 'Roles', + 'Add role': 'Add role', + 'Role name': 'Role name', + Configure: 'Configure', + 'Configure permissions': 'Configure permissions', + 'Edit role': 'Edit role', + 'Action permissions': 'Action permissions', + 'Menu permissions': 'Menu permissions', + 'Menu item name': 'Menu item name', + 'Allow access': 'Allow access', + 'Action name': 'Action name', + 'Allow action': 'Allow action', + 'Action scope': 'Action scope', + 'Operate on new data': 'Operate on new data', + 'Operate on existing data': 'Operate on existing data', + Yes: 'Yes', + No: 'No', + Red: 'Red', + Magenta: 'Magenta', + Volcano: 'Volcano', + Orange: 'Orange', + Gold: 'Gold', + Lime: 'Lime', + Green: 'Green', + Cyan: 'Cyan', + Blue: 'Blue', + 'Geek blue': 'Geek blue', + Purple: 'Purple', + Default: 'Default', + 'Add card': 'Add card', + 'edit title': 'edit title', + 'Turn pages': 'Turn pages', + Others: 'Others', + 'Save as template': 'Save as template', + 'Save as block template': 'Save as block template', + 'Block templates': 'Block templates', + 'Convert reference to duplicate': 'Convert reference to duplicate', + 'Template name': 'Template name', + 'Block type': 'Block type', + 'No blocks to connect': 'No blocks to connect', + 'Action column': 'Action column', + 'Records per page': 'Records per page', + '(Fields only)': '(Fields only)', + 'Button title': 'Button title', + 'Button icon': 'Button icon', + 'Submitted successfully': 'Submitted successfully', + 'Operation succeeded': 'Operation succeeded', + 'Operation failed': 'Operation failed', + 'Open mode': 'Open mode', + 'Popup size': 'Popup size', + Small: 'Small', + Middle: 'Middle', + Large: 'Large', + 'Menu item title': 'Menu item title', + 'Menu item icon': 'Menu item icon', + Target: 'Target', + Position: 'Position', + 'Insert before': 'Insert before', + 'Insert after': 'Insert after', + 'UI Editor': 'UI Editor', + ASC: 'ASC', + DESC: 'DESC', + 'Add sort field': 'Add sort field', + ID: 'ID', + 'Identifier for program usage. Support letters, numbers and underscores, must start with an letter.': + 'Identifier for program usage. Support letters, numbers and underscores, must start with an letter.', + Drawer: 'Drawer', + Dialog: 'Dialog', + 'Delete action': 'Delete action', + 'Custom column title': 'Custom column title', 'Column title': 'column title', - "Original title: ": "Original title: ", - "Delete table column": "Delete table column", - "Skip required validation": "Skip required validation", - "Form values": "Form values", - "Fields values": "Fields values", + 'Original title: ': 'Original title: ', + 'Delete table column': 'Delete table column', + 'Skip required validation': 'Skip required validation', + 'Form values': 'Form values', + 'Fields values': 'Fields values', 'The field has been deleted': 'The field has been deleted', - "When submitting the following fields, the saved values are": "When submitting the following fields, the saved values are", - "After successful submission": "After successful submission", - "Then": "Then", - "Stay on current page": "Stay on current page", - "Redirect to": "Redirect to", - "Save action": "Save action", - "Exists": "Exists", - "Add condition": "Add condition", - "Add condition group": "Add condition group", - "exists": "exists", - "not exists": "not exists", - "=": "=", - "≠": "≠", - ">": ">", - "≥": "≥", - "<": "<", - "≤": "≤", - "Role UID": "Role UID", - "Precision": "Precision", - "Formula mode": "Formula mode", - "Expression": "Expression", - "Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.", - "Formula error.": "Formula error.", - "Rich Text": "Rich Text", - "Junction collection": "Junction collection", - "Leave it blank, unless you need a custom intermediate table": "Leave it blank, unless you need a custom intermediate table", - "Fields": "Fields", - "Edit field title": "Edit field title", - "Field title": "Field title", - "Original field title: ": "Original field title: ", - "Edit tooltip": "Edit tooltip", - "Delete field": "Delete field", - "Select collection": "Select collection", - "Blank block": "Blank block", - "Duplicate template": "Duplicate template", - "Reference template": "Reference template", - "Create calendar block": "Create calendar block", - "Create kanban block": "Create kanban block", - "Grouping field": "Grouping field", - "Single select and radio fields can be used as the grouping field": "Single select and radio fields can be used as the grouping field", - "Tab name": "Tab name", - "Current record blocks": "Current record blocks", - "Popup message": "Popup message", - "Delete role": "Delete role", - "Role display name": "Role display name", - "Default role": "Default role", - "All collections use general action permissions by default; permission configured individually will override the default one.": "All collections use general action permissions by default; permission configured individually will override the default one.", - "Allows configuration of the whole system, including UI, collections, permissions, etc.": "Allows configuration of the whole system, including UI, collections, permissions, etc.", - "New menu items are allowed to be accessed by default.": "New menu items are allowed to be accessed by default.", - "Global permissions": "Global permissions", - "General permissions": "General permissions", - "Global action permissions": "Global action permissions", - "General action permissions": "General action permissions", - "Plugin settings permissions": "Plugin settings permissions", - "Allow to desgin pages": "Allow to desgin pages", - "Allow to manage plugins": "Allow to manage plugins", - "Allow to configure plugins": "Allow to configure plugins", - "Allows to configure interface": "Allows to configure interface", - "Allows to install, activate, disable plugins": "Allows to install, activate, disable plugins", - "Allows to configure plugins": "Allows to configure plugins", - "Action display name": "Action display name", - "Allow": "Allow", - "Data scope": "Data scope", - "Action on new records": "Action on new records", - "Action on existing records": "Action on existing records", - "All records": "All records", - "Own records": "Own records", - "Permission policy": "Permission policy", - "Individual": "Individual", - "General": "General", - "Accessible": "Accessible", - "Configure permission": "Configure permission", - "Action permission": "Action permission", - "Field permission": "Field permission", - "Scope name": "Scope name", - "Unsaved changes": "Unsaved changes", + 'When submitting the following fields, the saved values are': + 'When submitting the following fields, the saved values are', + 'After successful submission': 'After successful submission', + Then: 'Then', + 'Stay on current page': 'Stay on current page', + 'Redirect to': 'Redirect to', + 'Save action': 'Save action', + Exists: 'Exists', + 'Add condition': 'Add condition', + 'Add condition group': 'Add condition group', + exists: 'exists', + 'not exists': 'not exists', + '=': '=', + '≠': '≠', + '>': '>', + '≥': '≥', + '<': '<', + '≤': '≤', + 'Role UID': 'Role UID', + Precision: 'Precision', + 'Formula mode': 'Formula mode', + Expression: 'Expression', + 'Input +, -, *, /, ( ) to calculate, input @ to open field variables.': + 'Input +, -, *, /, ( ) to calculate, input @ to open field variables.', + 'Formula error.': 'Formula error.', + 'Rich Text': 'Rich Text', + 'Junction collection': 'Junction collection', + 'Leave it blank, unless you need a custom intermediate table': + 'Leave it blank, unless you need a custom intermediate table', + Fields: 'Fields', + 'Edit field title': 'Edit field title', + 'Field title': 'Field title', + 'Original field title: ': 'Original field title: ', + 'Edit tooltip': 'Edit tooltip', + 'Delete field': 'Delete field', + 'Select collection': 'Select collection', + 'Blank block': 'Blank block', + 'Duplicate template': 'Duplicate template', + 'Reference template': 'Reference template', + 'Create calendar block': 'Create calendar block', + 'Create kanban block': 'Create kanban block', + 'Grouping field': 'Grouping field', + 'Single select and radio fields can be used as the grouping field': + 'Single select and radio fields can be used as the grouping field', + 'Tab name': 'Tab name', + 'Current record blocks': 'Current record blocks', + 'Popup message': 'Popup message', + 'Delete role': 'Delete role', + 'Role display name': 'Role display name', + 'Default role': 'Default role', + 'All collections use general action permissions by default; permission configured individually will override the default one.': + 'All collections use general action permissions by default; permission configured individually will override the default one.', + 'Allows configuration of the whole system, including UI, collections, permissions, etc.': + 'Allows configuration of the whole system, including UI, collections, permissions, etc.', + 'New menu items are allowed to be accessed by default.': 'New menu items are allowed to be accessed by default.', + 'Global permissions': 'Global permissions', + 'General permissions': 'General permissions', + 'Global action permissions': 'Global action permissions', + 'General action permissions': 'General action permissions', + 'Plugin settings permissions': 'Plugin settings permissions', + 'Allow to desgin pages': 'Allow to desgin pages', + 'Allow to manage plugins': 'Allow to manage plugins', + 'Allow to configure plugins': 'Allow to configure plugins', + 'Allows to configure interface': 'Allows to configure interface', + 'Allows to install, activate, disable plugins': 'Allows to install, activate, disable plugins', + 'Allows to configure plugins': 'Allows to configure plugins', + 'Action display name': 'Action display name', + Allow: 'Allow', + 'Data scope': 'Data scope', + 'Action on new records': 'Action on new records', + 'Action on existing records': 'Action on existing records', + 'All records': 'All records', + 'Own records': 'Own records', + 'Permission policy': 'Permission policy', + Individual: 'Individual', + General: 'General', + Accessible: 'Accessible', + 'Configure permission': 'Configure permission', + 'Action permission': 'Action permission', + 'Field permission': 'Field permission', + 'Scope name': 'Scope name', + 'Unsaved changes': 'Unsaved changes', "Are you sure you don't want to save?": "Are you sure you don't want to save?", "Dragging": "Dragging", "Popup": "Popup", @@ -765,4 +778,7 @@ export default { "Select all":"Select all", "Restart": "Restart", "Restart application": "Restart application", + Execute: 'Execute', + 'Please use a valid SELECT or WITH AS statement': 'Please use a valid SELECT or WITH AS statement', + 'Please confirm the SQL statement first': 'Please confirm the SQL statement first', }; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 80178ddc9..be748a96b 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -222,6 +222,7 @@ export default { 'Records can be sorted': '可以对行记录进行排序', 'Calendar collection': '日历数据表', 'General collection': '普通数据表', + 'SQL collection': 'SQL数据表', 'Connect to database view': '连接数据库视图', 'Source collections': '来源数据表', 'Field source': '来源字段', @@ -709,50 +710,52 @@ export default { 'Plugin manager': '插件管理器', Local: '本地', 'Built-in': '内置', - 'Marketplace': '插件市场', - "Add plugin": "新增插件", - "Upgrade": "可供更新", - "Plugin dependencies check failed": "插件依赖检查失败", - "Remove": "移除", - "Docs": "文档", - "More details": "更多详情", - "Upload new version": "上传新版", - "Official plugin": "官方插件", - "Version": "版本", - "Npm package": "Npm 包", - "Upload plugin": "上传插件", - "Npm package name": "Npm 包名", - "Add type": "新增方式", - "Plugin source": "插件来源", - "Changelog": "更新日志", - "Dependencies check": "依赖检查", - "Update plugin": "更新插件", - "Installing": "安装中", - "The deletion was successful.": "删除成功", - "Plugin Zip File": "插件压缩包", - "Compressed file url": "压缩包地址", - "Last updated": "最后更新", - "PackageName": "包名", - "DisplayName": "显示名称", - "Readme": "说明文档", - "Dependencies compatibility check": "依赖兼容性检查", - "Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "插件兼容性检查失败,你应该修改依赖版本以满足版本要求。", - "Version range": "版本范围", - "Plugin's version": "插件的版本", - "Result": "结果", - "No CHANGELOG.md file": "没有 CHANGELOG.md 日志", - "No README.md file": "没有 README.md 文件", - "Homepage": "主页", - 'Drag and drop the file here or click to upload, file size should not exceed 30M': '将文件拖放到此处或单击上传,文件大小不应超过 30M', - "Dependencies check failed, can't enable.": "依赖检查失败,无法启用。", - "Plugin starting...": "插件启动中...", - "Plugin stopping...": "插件停止中...", - "Are you sure to delete this plugin?": "确定要删除此插件吗?", - "re-download file": "重新下载文件", - "Not enabled": "未启用", - "Search plugin": "搜索插件", - "Author": "作者", - "Plugin loading failed. Please check the server logs.": "插件加载失败,请检查服务器日志。", + Marketplace: '插件市场', + 'Add plugin': '新增插件', + Upgrade: '可供更新', + 'Plugin dependencies check failed': '插件依赖检查失败', + Remove: '移除', + Docs: '文档', + 'More details': '更多详情', + 'Upload new version': '上传新版', + 'Official plugin': '官方插件', + Version: '版本', + 'Npm package': 'Npm 包', + 'Upload plugin': '上传插件', + 'Npm package name': 'Npm 包名', + 'Add type': '新增方式', + 'Plugin source': '插件来源', + Changelog: '更新日志', + 'Dependencies check': '依赖检查', + 'Update plugin': '更新插件', + Installing: '安装中', + 'The deletion was successful.': '删除成功', + 'Plugin Zip File': '插件压缩包', + 'Compressed file url': '压缩包地址', + 'Last updated': '最后更新', + PackageName: '包名', + DisplayName: '显示名称', + Readme: '说明文档', + 'Dependencies compatibility check': '依赖兼容性检查', + 'Plugin dependencies check failed, you should change the dependent version to meet the version requirements.': + '插件兼容性检查失败,你应该修改依赖版本以满足版本要求。', + 'Version range': '版本范围', + "Plugin's version": '插件的版本', + Result: '结果', + 'No CHANGELOG.md file': '没有 CHANGELOG.md 日志', + 'No README.md file': '没有 README.md 文件', + Homepage: '主页', + 'Drag and drop the file here or click to upload, file size should not exceed 30M': + '将文件拖放到此处或单击上传,文件大小不应超过 30M', + "Dependencies check failed, can't enable.": '依赖检查失败,无法启用。', + 'Plugin starting...': '插件启动中...', + 'Plugin stopping...': '插件停止中...', + 'Are you sure to delete this plugin?': '确定要删除此插件吗?', + 're-download file': '重新下载文件', + 'Not enabled': '未启用', + 'Search plugin': '搜索插件', + Author: '作者', + 'Plugin loading failed. Please check the server logs.': '插件加载失败,请检查服务器日志。', 'Coming soon...': '敬请期待...', 'All plugin settings': '所有插件配置', Bookmark: '书签', @@ -863,11 +866,13 @@ export default { 'Date display format': '日期显示格式', 'Assign data scope for the template': '为模板指定数据范围', 'Table selected records': '表格中选中的记录', - Tag: '标签', 'Tag color field': '标签颜色字段', 'Sync successfully': '同步成功', 'Sync from form fields': '同步表单字段', 'Select all': '全选', 'Determine whether a record exists by the following fields': '通过以下字段判断记录是否存在', + Execute: '执行', + 'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句', + 'Please confirm the SQL statement first': '请先确认 SQL 语句', }; diff --git a/packages/core/client/src/schema-initializer/buttons/CalendarActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/CalendarActionInitializers.tsx index a1019cd58..f5cc74d41 100644 --- a/packages/core/client/src/schema-initializer/buttons/CalendarActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/CalendarActionInitializers.tsx @@ -76,7 +76,7 @@ export const CalendarActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], diff --git a/packages/core/client/src/schema-initializer/buttons/CalendarFormActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/CalendarFormActionInitializers.tsx index d469d2436..aaf654c37 100644 --- a/packages/core/client/src/schema-initializer/buttons/CalendarFormActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/CalendarFormActionInitializers.tsx @@ -25,7 +25,7 @@ export const CalendarFormActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -38,7 +38,7 @@ export const CalendarFormActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -51,7 +51,7 @@ export const CalendarFormActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -146,7 +146,7 @@ export const CalendarFormActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -171,9 +171,9 @@ export const CalendarFormActionInitializers = { useProps: '{{ useCustomizeRequestActionProps }}', }, }, - visible: () => { + visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], diff --git a/packages/core/client/src/schema-initializer/buttons/GridCardActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/GridCardActionInitializers.tsx index f1bc42874..854c8acc6 100644 --- a/packages/core/client/src/schema-initializer/buttons/GridCardActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/GridCardActionInitializers.tsx @@ -31,9 +31,9 @@ export const GridCardActionInitializers = { skipScopeCheck: true, }, }, - visible: () => { + visible: function useVisible() { const collection = useCollection(); - return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView; + return !['view', 'file', 'sql'].includes(collection.template) || collection?.writableView; }, }, { @@ -55,6 +55,10 @@ export const GridCardActionInitializers = { skipScopeCheck: true, }, }, + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, { type: 'item', @@ -163,9 +167,9 @@ export const GridCardItemActionInitializers = { 'x-decorator': 'ACLActionProvider', 'x-align': 'left', }, - visible: () => { + visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -178,6 +182,10 @@ export const GridCardItemActionInitializers = { 'x-decorator': 'ACLActionProvider', 'x-align': 'left', }, + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, ], }, @@ -261,9 +269,9 @@ export const GridCardItemActionInitializers = { useProps: '{{ useCustomizeUpdateActionProps }}', }, }, - visible: () => { + visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -287,9 +295,9 @@ export const GridCardItemActionInitializers = { useProps: '{{ useCustomizeRequestActionProps }}', }, }, - visible: () => { + visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], diff --git a/packages/core/client/src/schema-initializer/buttons/ListActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/ListActionInitializers.tsx index 0916302e3..f0b860796 100644 --- a/packages/core/client/src/schema-initializer/buttons/ListActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/ListActionInitializers.tsx @@ -33,7 +33,11 @@ export const ListActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file'; + return ( + (collection.template !== 'view' || collection?.writableView) && + collection.template !== 'file' && + collection.template !== 'sql' + ); }, }, { @@ -55,6 +59,10 @@ export const ListActionInitializers = { skipScopeCheck: true, }, }, + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, { type: 'item', @@ -165,7 +173,7 @@ export const ListItemActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -178,6 +186,10 @@ export const ListItemActionInitializers = { 'x-decorator': 'ACLActionProvider', 'x-align': 'left', }, + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, ], }, @@ -263,7 +275,7 @@ export const ListItemActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -289,7 +301,7 @@ export const ListItemActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return (collection as any).template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], diff --git a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx index 8b75d8d01..cdf619d57 100644 --- a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx @@ -2,7 +2,7 @@ import { useCollection } from '../..'; const useVisibleCollection = () => { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }; // 表单的操作配置 export const ReadPrettyFormActionInitializers = { diff --git a/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx index b3c5647dd..943a149cb 100644 --- a/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx @@ -17,10 +17,11 @@ const recursiveParent = (schema: Schema) => { const useRelationFields = () => { const fieldSchema = useFieldSchema(); const { getCollectionFields } = useCollectionManager(); + const collection = useCollection(); let fields = []; if (fieldSchema['x-initializer']) { - fields = useCollection().fields; + fields = collection.fields; } else { const collection = recursiveParent(fieldSchema.parent); if (collection) { @@ -184,7 +185,18 @@ export const RecordBlockInitializers = (props: any) => { const hasFormChildCollection = formChildrenCollections?.length > 0; const detailChildrenCollections = getChildrenCollections(collection.name, true); const hasDetailChildCollection = detailChildrenCollections?.length > 0; - const modifyFlag = (collection as any).template !== 'view' || collection?.writableView; + const modifyFlag = (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; + const detailChildren = useDetailCollections({ + ...props, + childrenCollections: detailChildrenCollections, + collection, + }); + const formChildren = useFormCollections({ + ...props, + childrenCollections: formChildrenCollections, + collection, + }); + return ( { key: 'details', type: 'subMenu', title: '{{t("Details")}}', - children: useDetailCollections({ - ...props, - childrenCollections: detailChildrenCollections, - collection, - }), + children: detailChildren, } : { key: 'details', @@ -220,11 +228,7 @@ export const RecordBlockInitializers = (props: any) => { key: 'form', type: 'subMenu', title: '{{t("Form")}}', - children: useFormCollections({ - ...props, - childrenCollections: formChildrenCollections, - collection, - }), + children: formChildren, } : modifyFlag && { key: 'form', diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx index 9fbcbbb79..ec040fe53 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx @@ -101,7 +101,7 @@ export const TableActionColumnInitializers = (props: any) => { 'x-decorator': 'ACLActionProvider', }, visible: () => { - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, @@ -115,7 +115,7 @@ export const TableActionColumnInitializers = (props: any) => { 'x-decorator': 'ACLActionProvider', }, visible: () => { - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, collection.tree && @@ -139,7 +139,7 @@ export const TableActionColumnInitializers = (props: any) => { 'x-decorator': 'ACLActionProvider', }, visible: () => { - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], @@ -225,7 +225,7 @@ export const TableActionColumnInitializers = (props: any) => { }, }, visible: () => { - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, { @@ -250,7 +250,7 @@ export const TableActionColumnInitializers = (props: any) => { }, }, visible: () => { - return collection.template !== 'view' || collection?.writableView; + return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql'; }, }, ], diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx index 4ba1e4e85..a0daa0c5e 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx @@ -34,7 +34,7 @@ export const TableActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView; + return !['view', 'file', 'sql'].includes(collection.template) || collection?.writableView; }, }, { @@ -47,7 +47,7 @@ export const TableActionInitializers = { }, visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return !['view', 'sql'].includes(collection.template) || collection?.writableView; }, }, { @@ -78,7 +78,7 @@ export const TableActionInitializers = { type: 'divider', visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return !['view', 'sql'].includes(collection.template) || collection?.writableView; }, }, // { @@ -172,7 +172,7 @@ export const TableActionInitializers = { ], visible: function useVisible() { const collection = useCollection(); - return collection.template !== 'view' || collection?.writableView; + return !['view', 'sql'].includes(collection.template) || collection?.writableView; }, }, ], diff --git a/packages/core/client/src/schema-initializer/items/DetailsBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/DetailsBlockInitializer.tsx index f660ec80c..7c5a3ce70 100644 --- a/packages/core/client/src/schema-initializer/items/DetailsBlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/DetailsBlockInitializer.tsx @@ -18,7 +18,9 @@ export const DetailsBlockInitializer = (props) => { collection: item.name, rowKey: collection.filterTargetKey || 'id', actionInitializers: - (collection.template !== 'view' || collection?.writableView) && 'DetailsActionInitializers', + (collection.template !== 'view' || collection?.writableView) && + collection.template !== 'sql' && + 'DetailsActionInitializers', }); insert(schema); }} diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index df3d4d428..1053dc525 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -862,7 +862,10 @@ export const useCollectionDataSourceItems = (componentName) => { const fields = getCollectionFields(item.name); if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) { return false; - } else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view' && !item.writableView) { + } else if ( + ['Kanban', 'FormItem'].includes(componentName) && + ((item.template === 'view' && !item.writableView) || item.template === 'sql') + ) { return false; } else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) { return false; diff --git a/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts b/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts new file mode 100644 index 000000000..6221c76fe --- /dev/null +++ b/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts @@ -0,0 +1,63 @@ +import Database from '../../database'; +import { mockDatabase } from '../../mock-database'; +import { SQLModel } from '../../sql-collection/sql-model'; + +describe('infer fields', () => { + let db: Database; + + beforeAll(async () => { + db = mockDatabase({ tablePrefix: '' }); + await db.clean({ drop: true }); + + db.collection({ + name: 'users', + schema: 'public', + fields: [ + { name: 'id', type: 'bigInt', interface: 'id' }, + { name: 'nickname', type: 'string', interface: 'input' }, + ], + }); + db.collection({ + name: 'roles', + schema: 'public', + fields: [ + { name: 'id', type: 'bigInt', interface: 'id' }, + { name: 'title', type: 'string', interface: 'input' }, + { name: 'name', type: 'string', interface: 'uid' }, + ], + }); + db.collection({ + name: 'roles_users', + schema: 'public', + fields: [ + { name: 'id', type: 'bigInt', interface: 'id' }, + { name: 'userId', type: 'bigInt', interface: 'id' }, + { name: 'roleName', type: 'bigInt', interface: 'id' }, + ], + }); + await db.sync(); + }); + + afterAll(async () => { + await db.close(); + }); + + it('should infer fields', async () => { + const model = class extends SQLModel {}; + model.init(null, { + modelName: 'roles_users', + tableName: 'roles_users', + sequelize: db.sequelize, + }); + model.database = db; + model.sql = `select u.id as uid, u.nickname, r.title, r.name +from users u left join roles_users ru on ru.user_id = u.id +left join roles r on ru.role_name=r.name`; + expect(model.inferFields()).toMatchObject({ + uid: { type: 'bigInt', source: 'users.id' }, + nickname: { type: 'string', source: 'users.nickname' }, + title: { type: 'string', source: 'roles.title' }, + name: { type: 'string', source: 'roles.name' }, + }); + }); +}); diff --git a/packages/core/database/src/__tests__/sql-collection/select-query.test.ts b/packages/core/database/src/__tests__/sql-collection/select-query.test.ts new file mode 100644 index 000000000..4bac0122f --- /dev/null +++ b/packages/core/database/src/__tests__/sql-collection/select-query.test.ts @@ -0,0 +1,71 @@ +import { SQLModel } from '../../sql-collection/sql-model'; +import { Sequelize } from 'sequelize'; + +describe('select query', () => { + const model = class extends SQLModel {}; + model.init(null, { + modelName: 'users', + tableName: 'users', + sequelize: new Sequelize({ + dialect: 'postgres', + }), + }); + model.sql = 'SELECT * FROM "users"'; + model.collection = { + fields: new Map( + Object.entries({ + id: {}, + name: {}, + }), + ), + } as any; + const queryGenerator = model.queryInterface.queryGenerator as any; + + test('plain sql', () => { + const query = queryGenerator.selectQuery('users', {}, model); + expect(query).toBe('SELECT * FROM "users";'); + }); + + test('attributes', () => { + const query = queryGenerator.selectQuery('users', { attributes: ['id', 'name'] }, model); + expect(query).toBe('SELECT "id", "name" FROM (SELECT * FROM "users") AS "users";'); + }); + + test('where', () => { + const query = queryGenerator.selectQuery('users', { where: { id: 1 } }, model); + expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" WHERE "users"."id" = 1;'); + }); + + test('group', () => { + const query = queryGenerator.selectQuery('users', { group: 'id' }, model); + expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" GROUP BY "id";'); + }); + + test('order', () => { + const query = queryGenerator.selectQuery('users', { order: ['id'] }, model); + expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" ORDER BY "users"."id";'); + }); + + test('limit, offset', () => { + const query = queryGenerator.selectQuery('users', { limit: 1, offset: 0 }, model); + expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" LIMIT 1 OFFSET 0;'); + }); + + test('complex sql', () => { + const query = queryGenerator.selectQuery( + 'users', + { + attributes: ['id', 'name'], + where: { id: 1 }, + group: 'id', + order: ['id'], + limit: 1, + offset: 0, + }, + model, + ); + expect(query).toBe( + 'SELECT "id", "name" FROM (SELECT * FROM "users") AS "users" WHERE "users"."id" = 1 GROUP BY "id" ORDER BY "users"."id" LIMIT 1 OFFSET 0;', + ); + }); +}); diff --git a/packages/core/database/src/__tests__/sql-parser.test.ts b/packages/core/database/src/__tests__/sql-parser.test.ts index d0bbb2745..2de090eaf 100644 --- a/packages/core/database/src/__tests__/sql-parser.test.ts +++ b/packages/core/database/src/__tests__/sql-parser.test.ts @@ -10,4 +10,16 @@ describe('sql parser', () => { expect(firstColumn['expr']['table']).toEqual('users'); expect(firstColumn['expr']['column']).toEqual('id'); }); + + // it('should parse complex sql', function () { + // const sql = `select u.id, u.nickname, r.title, r.name from users u left join roles_users ru on ru.user_id = u.id left join roles r on ru.role_name=r.name`; + // const { ast } = sqlParser.parse(sql); + // console.log(JSON.stringify(ast, null, 2)); + // }); + // + // it('should parse with subquery', function () { + // const sql = `with t as (select * from users), v as (select * from roles) select * from t, v where t.id = v.id;`; + // const { ast } = sqlParser.parse(sql); + // console.log(JSON.stringify(ast, null, 2)); + // }); }); diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index e9d3e13ec..76169c7d5 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -70,6 +70,7 @@ import { import { patchSequelizeQueryInterface, snakeCase } from './utils'; import { BaseValueParser, registerFieldValueParsers } from './value-parsers'; import { ViewCollection } from './view-collection'; +import { SqlCollection } from './sql-collection/sql-collection'; export type MergeOptions = merge.Options; @@ -358,6 +359,9 @@ export class Database extends EventEmitter implements AsyncEmitter { if (collection.options.schema) { collection.model._schema = collection.options.schema; } + if (collection.options.sql) { + collection.modelInit(); + } }); this.on('beforeDefineCollection', (options) => { @@ -451,6 +455,10 @@ export class Database extends EventEmitter implements AsyncEmitter { return ViewCollection; } + if (options.sql) { + return SqlCollection; + } + return Collection; })(); diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index ea1e39604..0d90bf81a 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -41,4 +41,5 @@ export { snakeCase } from './utils'; export * from './value-parsers'; export * from './view-collection'; export * from './view/view-inference'; +export * from './sql-collection'; export * from './helpers'; diff --git a/packages/core/database/src/sql-collection/index.ts b/packages/core/database/src/sql-collection/index.ts new file mode 100644 index 000000000..8a524dd0f --- /dev/null +++ b/packages/core/database/src/sql-collection/index.ts @@ -0,0 +1,2 @@ +export * from './sql-model'; +export * from './sql-collection'; diff --git a/packages/core/database/src/sql-collection/query-generator.ts b/packages/core/database/src/sql-collection/query-generator.ts new file mode 100644 index 000000000..c6639a7b8 --- /dev/null +++ b/packages/core/database/src/sql-collection/query-generator.ts @@ -0,0 +1,69 @@ +import { GroupOption, Order, ProjectionAlias, WhereOptions } from 'sequelize'; +import { SQLModel } from './sql-model'; +import { lodash } from '@nocobase/utils'; +import { Collection } from '../collection'; + +export function selectQuery( + tableName: string, + options: { + attributes?: (string | ProjectionAlias)[]; + where?: WhereOptions; + order?: Order; + group?: GroupOption; + limit?: number; + offset?: number; + }, + model: SQLModel, +) { + options = options || {}; + if (lodash.isEmpty(options)) { + return `${model.sql};`; + } + const queryItems = []; + let attributes = options.attributes && options.attributes.slice(); + if (attributes) { + const fields = Array.from((model.collection as Collection)?.fields.keys() || []); + attributes = attributes.filter((attr: any) => attr === '*' || typeof attr !== 'string' || fields.includes(attr)); + } + attributes = this.escapeAttributes(attributes, { model }); + attributes = attributes || ['*']; + + // Add WHERE to sub or main query + if (Object.prototype.hasOwnProperty.call(options, 'where')) { + options.where = this.getWhereConditions(options.where, tableName, model, options); + if (options.where) { + queryItems.push(` WHERE ${options.where}`); + } + } + + // Add GROUP BY to sub or main query + if (options.group) { + options.group = Array.isArray(options.group) + ? options.group.map((t) => this.aliasGrouping(t, model, tableName, options)).join(', ') + : this.aliasGrouping(options.group, model, tableName, options); + + if (options.group) { + queryItems.push(` GROUP BY ${options.group}`); + } + } + + // Add ORDER to sub or main query + if (options.order) { + const orders = this.getQueryOrders(options, model, false); + if (orders.mainQueryOrder.length) { + queryItems.push(` ORDER BY ${orders.mainQueryOrder.join(', ')}`); + } + } + + // Add LIMIT, OFFSET to sub or main query + const limitOrder = this.addLimitAndOffset(options, model); + if (limitOrder) { + queryItems.push(limitOrder); + } + + const query = `SELECT ${attributes.join(', ')} FROM (${model.sql}) ${this.getAliasToken()} ${this.quoteIdentifier( + model.name, + )}${queryItems.join('')}`; + + return `${query};`; +} diff --git a/packages/core/database/src/sql-collection/sql-collection.ts b/packages/core/database/src/sql-collection/sql-collection.ts new file mode 100644 index 000000000..198e68fc6 --- /dev/null +++ b/packages/core/database/src/sql-collection/sql-collection.ts @@ -0,0 +1,46 @@ +import { Collection, CollectionContext, CollectionOptions } from '../collection'; +import { SQLModel } from './sql-model'; + +export class SqlCollection extends Collection { + constructor(options: CollectionOptions, context: CollectionContext) { + options.autoGenId = false; + options.timestamps = false; + options.underscored = false; + + super(options, context); + } + + isSql() { + return true; + } + + public collectionSchema() { + return undefined; + } + + modelInit() { + const { autoGenId, sql } = this.options; + const model = class extends SQLModel {}; + model.init(null, { + ...this.sequelizeModelOptions(), + schema: undefined, + }); + + if (!autoGenId) { + model.removeAttribute('id'); + } + + model.sql = sql; + model.database = this.context.database; + model.collection = this; + + this.model = new Proxy(model, { + get(target, prop) { + if (prop === '_schema') { + return undefined; + } + return Reflect.get(target, prop); + }, + }); + } +} diff --git a/packages/core/database/src/sql-collection/sql-model.ts b/packages/core/database/src/sql-collection/sql-model.ts new file mode 100644 index 000000000..c99da68d2 --- /dev/null +++ b/packages/core/database/src/sql-collection/sql-model.ts @@ -0,0 +1,157 @@ +import { Model } from '../model'; +import sqlParser from '../sql-parser'; +import { selectQuery } from './query-generator'; + +export class SQLModel extends Model { + static sql: string; + + static get queryInterface() { + const queryInterface = this.sequelize.getQueryInterface(); + const queryGenerator = queryInterface.queryGenerator as any; + const sqlGenerator = new Proxy(queryGenerator, { + get(target, prop) { + if (prop === 'selectQuery') { + return selectQuery.bind(target); + } + return Reflect.get(target, prop); + }, + }); + return new Proxy(queryInterface, { + get(target, prop) { + if (prop === 'queryGenerator') { + return sqlGenerator; + } + return Reflect.get(target, prop); + }, + }); + } + + static async sync(): Promise {} + + private static parseTablesAndColumns(): { + table: string; + columns: string | { name: string; as: string }[]; + }[] { + let { ast } = sqlParser.parse(this.sql); + if (Array.isArray(ast)) { + ast = ast[0]; + } + if (ast.with) { + const tables = new Set(); + // parse sql includes with clause is not accurate + // the parsed columns seems to be always '*' + // it is supposed to be improved in the future + ast.with.forEach((withItem: { tableList: string[] }) => { + const tableList = withItem.tableList; + tableList.forEach((table) => { + const name = table.split('::')[2]; // "select::null::users" + tables.add(name); + }); + }); + return Array.from(tables).map((table) => ({ table, columns: '*' })); + } + if (ast.columns === '*') { + return ast.from.map((fromItem: { table: string; as: string }) => ({ + table: fromItem.table, + columns: '*', + })); + } + const columns: string[] = ast.columns.reduce( + ( + tableMp: { [table: string]: { name: string; as: string }[] }, + column: { + as: string; + expr: { + table: string; + column: string; + type: string; + }; + }, + ) => { + if (column.expr.type !== 'column_ref') { + return tableMp; + } + const table = column.expr.table; + const columnAttr = { name: column.expr.column, as: column.as }; + if (!tableMp[table]) { + tableMp[table] = [columnAttr]; + } else { + tableMp[table].push(columnAttr); + } + return tableMp; + }, + {}, + ); + ast.from.forEach((fromItem: { table: string; as: string }) => { + if (columns[fromItem.as]) { + columns[fromItem.table] = columns[fromItem.as]; + columns[fromItem.as] = undefined; + } + }); + return Object.entries(columns) + .filter(([_, columns]) => columns) + .map(([table, columns]) => ({ table, columns })); + } + + private static getTableNameWithSchema(table: string) { + if (this.database.inDialect('postgres') && !table.includes('.')) { + return `public.${table}`; + } + return table; + } + + static inferFields(): { + [field: string]: { + type: string; + source: string; + collection: string; + interface: string; + }; + } { + const tables = this.parseTablesAndColumns(); + const fields = tables.reduce((fields, { table, columns }) => { + const tableName = this.getTableNameWithSchema(table); + const collection = this.database.tableNameCollectionMap.get(tableName); + if (!collection) { + return fields; + } + const attributes = collection.model.getAttributes(); + const sourceFields = {}; + if (columns === '*') { + Object.values(attributes).forEach((attribute) => { + const field = collection.getField((attribute as any).fieldName); + if (!field?.options.interface) { + return; + } + sourceFields[field.name] = { + collection: field.collection.name, + type: field.type, + source: `${field.collection.name}.${field.name}`, + interface: field.options.interface, + uiSchema: field.options.uiSchema, + }; + }); + } else { + (columns as { name: string; as: string }[]).forEach((column) => { + const modelField = Object.values(attributes).find((attribute) => attribute.field === column.name); + if (!modelField) { + return; + } + const field = collection.getField((modelField as any).fieldName); + if (!field?.options.interface) { + return; + } + sourceFields[column.as || column.name] = { + collection: field.collection.name, + type: field.type, + source: `${field.collection.name}.${field.name}`, + interface: field.options.interface, + uiSchema: field.options.uiSchema, + }; + }); + } + return { ...fields, ...sourceFields }; + }, {}); + return fields; + } +} diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts index 6c24639f6..522999b17 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts @@ -6,6 +6,7 @@ interface LoadOptions extends Transactionable { // TODO skipField?: boolean | Array; skipExist?: boolean; + resetFields?: boolean; } export class CollectionModel extends MagicAttributeModel { @@ -14,7 +15,7 @@ export class CollectionModel extends MagicAttributeModel { } async load(loadOptions: LoadOptions = {}) { - const { skipExist, skipField, transaction } = loadOptions; + const { skipExist, skipField, resetFields, transaction } = loadOptions; const name = this.get('name'); let collection: Collection; @@ -23,7 +24,6 @@ export class CollectionModel extends MagicAttributeModel { ...this.get(), fields: [], }; - if (this.db.hasCollection(name)) { collection = this.db.getCollection(name); @@ -31,6 +31,10 @@ export class CollectionModel extends MagicAttributeModel { return collection; } + if (resetFields) { + collection.resetFields(); + } + collection.updateOptions(collectionOptions); } else { collection = this.db.collection(collectionOptions); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/resourcers/sql.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/resourcers/sql.ts new file mode 100644 index 000000000..e838079a5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/resourcers/sql.ts @@ -0,0 +1,96 @@ +import { Context, Next } from '@nocobase/actions'; +import { SQLModel, SqlCollection } from '@nocobase/database'; +import { CollectionModel } from '../models'; + +const updateCollection = async (ctx: Context, transaction: any) => { + const { filterByTk, values } = ctx.action.params; + const repo = ctx.db.getRepository('collections'); + const collection: CollectionModel = await repo.findOne({ + filter: { + name: filterByTk, + }, + transaction, + }); + const existFields = await collection.getFields({ transaction }); + const deletedFields = existFields.filter((field: any) => !values.fields?.find((f: any) => f.name === field.name)); + for (const field of deletedFields) { + await field.destroy({ transaction }); + } + const upRes = await repo.update({ + filterByTk, + values, + updateAssociationValues: ['fields'], + transaction, + }); + + return { collection, upRes }; +}; + +export default { + name: 'sqlCollection', + actions: { + execute: async (ctx: Context, next: Next) => { + let { + values: { sql }, + } = ctx.action.params; + sql = sql.trim().split(';').shift(); + if (!sql) { + ctx.throw(400, ctx.t('SQL is empty')); + } + if (!/^select/i.test(sql) && !/^with([\s\S]+)select([\s\S]+)/i.test(sql)) { + ctx.throw(400, ctx.t('Only select query allowed')); + } + const tmpCollection = new SqlCollection({ name: 'tmp', sql }, { database: ctx.db }); + const model = tmpCollection.model as typeof SQLModel; + // The result is for preview only, add limit clause to avoid too many results + const data = await model.findAll({ attributes: ['*'], limit: 5, raw: true }); + let fields: { + [field: string]: { + type: string; + source: string; + collection: string; + interface: string; + }; + } = {}; + try { + fields = model.inferFields(); + } catch (err) { + ctx.logger.warn('resource: sql-collection, action: execute, error: ', err); + fields = {}; + } + const sources = Array.from(new Set(Object.values(fields).map((field) => field.collection))); + ctx.body = { data, fields, sources }; + await next(); + }, + setFields: async (ctx: Context, next: Next) => { + const transaction = await ctx.app.db.sequelize.transaction(); + try { + const { + upRes: [collection], + } = await updateCollection(ctx, transaction); + await collection.loadFields({ + transaction, + }); + await transaction.commit(); + } catch (e) { + await transaction.rollback(); + throw e; + } + await next(); + }, + update: async (ctx: Context, next: Next) => { + const transaction = await ctx.app.db.sequelize.transaction(); + try { + const { upRes } = await updateCollection(ctx, transaction); + const [collection] = upRes; + await (collection as CollectionModel).load({ transaction, resetFields: true }); + await transaction.commit(); + ctx.body = upRes; + } catch (e) { + await transaction.rollback(); + throw e; + } + await next(); + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts index 12d7392d4..45251459a 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts @@ -17,6 +17,7 @@ import { beforeCreateForViewCollection } from './hooks/beforeCreateForViewCollec import { CollectionModel, FieldModel } from './models'; import collectionActions from './resourcers/collections'; import viewResourcer from './resourcers/views'; +import sqlResourcer from './resourcers/sql'; export class CollectionManagerPlugin extends Plugin { public schema: string; @@ -51,7 +52,7 @@ export class CollectionManagerPlugin extends Plugin { this.app.acl.registerSnippet({ name: `pm.${this.name}.collections`, - actions: ['collections:*', 'collections.fields:*', 'dbViews:*', 'collectionCategories:*'], + actions: ['collections:*', 'collections.fields:*', 'dbViews:*', 'collectionCategories:*', 'sqlCollection:*'], }); this.app.db.on('collections.beforeCreate', async (model) => { @@ -277,6 +278,7 @@ export class CollectionManagerPlugin extends Plugin { }); this.app.resource(viewResourcer); + this.app.resource(sqlResourcer); this.app.actions(collectionActions); const handleFieldSource = (fields) => { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts index a7f397cff..ef3962311 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts @@ -14,6 +14,7 @@ describe('query', () => { beforeAll(() => { app = mockServer(); + app.db.options.underscored = true; app.db.collection({ name: 'orders', fields: [ diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index aad3c15bf..0dd7038ae 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -167,7 +167,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { const { collection: collectionName, measures, dimensions, orders, filter } = ctx.action.params.values as QueryParams; const collection = ctx.db.getCollection(collectionName); const fields = collection.fields; - const underscored = ctx.db.options.underscored; + const underscored = collection.options.underscored; const models: { [target: string]: { type: string; diff --git a/packages/plugins/@nocobase/plugin-import/src/client/ImportInitializerProvider.tsx b/packages/plugins/@nocobase/plugin-import/src/client/ImportInitializerProvider.tsx index 0fdc6904b..a2433711e 100644 --- a/packages/plugins/@nocobase/plugin-import/src/client/ImportInitializerProvider.tsx +++ b/packages/plugins/@nocobase/plugin-import/src/client/ImportInitializerProvider.tsx @@ -20,7 +20,11 @@ export const ImportInitializerProvider = (props: any) => { }, visible: function useVisible() { const collection = useCollection(); - return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file'; + return ( + (collection.template !== 'view' || collection?.writableView) && + collection.template !== 'file' && + collection.template !== 'sql' + ); }, }); return props.children; diff --git a/packages/plugins/@nocobase/plugin-map/src/client/block/MapActionInitializers.tsx b/packages/plugins/@nocobase/plugin-map/src/client/block/MapActionInitializers.tsx index 78292db2f..cd940d436 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/block/MapActionInitializers.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/block/MapActionInitializers.tsx @@ -1,3 +1,5 @@ +import { useCollection } from '@nocobase/client'; + // 表格操作配置 export const MapActionInitializers = { title: "{{t('Configure actions')}}", @@ -29,6 +31,10 @@ export const MapActionInitializers = { skipScopeCheck: true, }, }, + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, { type: 'item', @@ -42,6 +48,10 @@ export const MapActionInitializers = { }, { type: 'divider', + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, { type: 'subMenu', @@ -92,6 +102,10 @@ export const MapActionInitializers = { }, }, ], + visible: function useVisible() { + const collection = useCollection(); + return collection.template !== 'sql'; + }, }, ], }; diff --git a/yarn.lock b/yarn.lock index 05f80e6e0..734d0d426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23324,7 +23324,7 @@ vite@4.3.1: vite@^4.4.9: version "4.4.9" - resolved "https://registry.npmmirror.com/vite/-/vite-4.4.9.tgz#1402423f1a2f8d66fd8d15e351127c7236d29d3d" + resolved "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz#1402423f1a2f8d66fd8d15e351127c7236d29d3d" integrity sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA== dependencies: esbuild "^0.18.10"