From fc21db8ecbc7af5ee69f18fda94ce1a49ff9fe63 Mon Sep 17 00:00:00 2001 From: ajie Date: Fri, 24 Oct 2025 15:21:22 +0800 Subject: [PATCH] feat(data-source-main): reading tables & change field interfaces (#7118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: test * refactor: remove group for CollectionFields * feat: support change field type * feat: support change interface * fix: type error * fix: build error * fix: handle field source * fix: test * fix: test * Revert "Revert "feat: support reading tables from main data source (#7238)" (…" This reverts commit c881117c9df6381682107a8d1256ae22f688f66c. * fix: disable modify preset fields interface and type when collection is db2cm * refactor: read and load tables * fix: issue of creating external database datas source * fix: bug * fix: bug * fix: read table bug * feat: sync fields * fix: test * chore: remove refresh btn * fix: build * fix: underscored table name * fix: ds reload * fix: bug * fix: update fields * fix: interface * fix: sync fields * fix: bug * fix: bug * fix: test --------- Co-authored-by: xilesun <2013xile@gmail.com> --- .../block-configs/BlockConfigsProvider.tsx | 26 +- .../core/client/src/block-configs/index.ts | 2 +- .../src/collection-manager/action-hooks.ts | 9 +- .../collection-manager/collectionPlugin.ts | 1 + .../data-source/DataSourceManager.ts | 14 + packages/core/client/src/locale/zh-CN.json | 10 +- .../antd/table/Table.Void.tsx | 11 +- .../src/data-source-factory.ts | 16 +- .../src/data-source-manager.ts | 5 +- .../src/database-data-source.ts | 103 ++++ .../database-introspector.ts | 341 +++++++++++++ .../mariadb-introspector.ts | 19 + .../postgres-introspector.ts | 170 +++++++ .../type-interface-map.ts | 212 ++++++++ .../core/data-source-manager/src/index.ts | 2 + .../src/sequelize-data-source.ts | 15 +- .../core/data-source-manager/src/types.ts | 42 +- packages/core/database/src/database.ts | 6 +- packages/core/database/src/index.ts | 3 +- packages/core/database/src/utils.ts | 10 + packages/core/server/src/index.ts | 1 + packages/core/server/src/main-data-source.ts | 150 +++++- .../plugin-auth/src/server/plugin.ts | 4 +- .../src/client/index.ts | 2 +- .../http-api/view-collection.test.ts | 14 +- .../server/__tests__/read-db-tables.test.ts | 107 +++- .../src/server/constants.ts | 10 + .../src/server/models/collection.ts | 3 + .../src/server/resourcers/collections.ts | 2 +- .../src/server/resourcers/main-data-source.ts | 35 ++ .../src/server/server.ts | 46 +- .../src/client/component/BreadcumbTitle.tsx | 7 +- .../CollectionsManager/ConfigurationTable.tsx | 58 +-- .../EditCollectionAction.tsx | 5 +- .../CollectionsManager/SetFilterTargetKey.tsx | 6 +- .../components/TitleField.tsx | 3 +- .../component/CollectionsTableField.tsx | 18 +- .../component/CreateDatabaseConnectAction.tsx | 38 +- .../component/DatabaseConnectionManager.tsx | 19 +- .../EditDatabaseConnectionAction.tsx | 21 +- .../Configuration/CollectionFields.tsx | 476 +++++++++++++++--- .../Configuration/ConfigurationTable.tsx | 15 + .../db-sync/LoadCollectionAction.tsx | 202 ++++++++ .../db-sync/SyncFieldChangesAction.tsx | 43 ++ .../db-sync/SyncFromDatabaseAction.tsx | 55 ++ .../Configuration/schemas/collections.ts | 13 + .../src/client/hooks/index.ts | 33 +- .../src/client/hooks/useDataSourceActions.ts | 79 +++ .../src/client/hooks/useDataSourceRefresh.tsx | 78 +++ .../src/client/hooks/useResourceData.tsx | 89 ++++ .../src/client/schema/index.ts | 1 + .../src/locale/en-US.json | 7 + .../src/locale/zh-CN.json | 9 +- .../src/server/actions/data-sources.ts | 117 +++++ .../src/server/middlewares/load-tables.ts | 50 ++ .../src/server/plugin.ts | 134 +---- .../resourcers/data-sources-collections.ts | 87 ---- .../src/server/utils.ts | 64 +++ yarn.lock | 41 +- 59 files changed, 2664 insertions(+), 495 deletions(-) create mode 100644 packages/core/data-source-manager/src/database-data-source.ts create mode 100644 packages/core/data-source-manager/src/database-introspector/database-introspector.ts create mode 100644 packages/core/data-source-manager/src/database-introspector/mariadb-introspector.ts create mode 100644 packages/core/data-source-manager/src/database-introspector/postgres-introspector.ts create mode 100644 packages/core/data-source-manager/src/database-introspector/type-interface-map.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-main/src/server/constants.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/main-data-source.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/db-sync/LoadCollectionAction.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/db-sync/SyncFieldChangesAction.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/db-sync/SyncFromDatabaseAction.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/hooks/useDataSourceActions.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/hooks/useDataSourceRefresh.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/client/hooks/useResourceData.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/locale/en-US.json create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/server/actions/data-sources.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-manager/src/server/middlewares/load-tables.ts diff --git a/packages/core/client/src/block-configs/BlockConfigsProvider.tsx b/packages/core/client/src/block-configs/BlockConfigsProvider.tsx index bf8887428cb..7d511a9cca5 100644 --- a/packages/core/client/src/block-configs/BlockConfigsProvider.tsx +++ b/packages/core/client/src/block-configs/BlockConfigsProvider.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useContext, ReactNode, useRef, useCallback } from "react"; +import React, { createContext, useContext, ReactNode, useRef, useCallback } from 'react'; export const BlockConfigsContext = createContext<{ getConfigs: () => any; @@ -31,34 +31,34 @@ export const useBlockConfigs = () => { export const BlockConfigsProvider = ({ children, value: initialValue }: { children: ReactNode; value: any }) => { const valueRef = useRef(initialValue); const subscribersRef = useRef void>>([]); - + const getConfigs = useCallback(() => { return valueRef.current; }, []); const setConfigs = useCallback((newValue: any, shouldNotify = false) => { valueRef.current = newValue; - + // 仅在明确要求通知时执行回调 if (shouldNotify) { - subscribersRef.current.forEach(callback => callback()); + subscribersRef.current.forEach((callback) => callback()); } }, []); - + const getFiltersConfigs = useCallback(() => { return valueRef.current?.configData?.filterSteps || {}; }, []); - + const getEventsConfigs = useCallback(() => { return valueRef.current?.configData?.events || {}; }, []); const subscribe = useCallback((callback: () => void) => { subscribersRef.current.push(callback); - + // 返回取消订阅函数 return () => { - subscribersRef.current = subscribersRef.current.filter(cb => cb !== callback); + subscribersRef.current = subscribersRef.current.filter((cb) => cb !== callback); }; }, []); @@ -67,12 +67,8 @@ export const BlockConfigsProvider = ({ children, value: initialValue }: { childr setConfigs, getFiltersConfigs, getEventsConfigs, - subscribe + subscribe, }).current; - return ( - - {children} - - ); -}; \ No newline at end of file + return {children}; +}; diff --git a/packages/core/client/src/block-configs/index.ts b/packages/core/client/src/block-configs/index.ts index 8bd80078ed8..62de053c5ff 100644 --- a/packages/core/client/src/block-configs/index.ts +++ b/packages/core/client/src/block-configs/index.ts @@ -7,4 +7,4 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export * from './BlockConfigsProvider'; \ No newline at end of file +export * from './BlockConfigsProvider'; diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index e8aee91b8d0..422c207fc51 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -398,7 +398,7 @@ export const useFilterAction = () => { }; }; -export const useCreateAction = (actionCallback?: (values: any, collections: any[]) => void) => { +export const useCreateAction = (actionCallback?: (values: any) => void) => { const form = useForm(); const field = useField(); const ctx = useActionContext(); @@ -410,14 +410,9 @@ export const useCreateAction = (actionCallback?: (values: any, collections: any[ await form.submit(); field.data = field.data || {}; field.data.loading = true; - let collections = []; - if (!form.values.addAllCollections) { - collections = form.values.collections; - } - delete form.values.collections; const res = await resource.create({ values: form.values }); ctx.setVisible(false); - await actionCallback?.(res?.data?.data, collections); + actionCallback?.(res?.data?.data); await form.reset(); field.data.loading = false; refresh(); diff --git a/packages/core/client/src/collection-manager/collectionPlugin.ts b/packages/core/client/src/collection-manager/collectionPlugin.ts index 6eb4bec2c37..2fdca8cfdc1 100644 --- a/packages/core/client/src/collection-manager/collectionPlugin.ts +++ b/packages/core/client/src/collection-manager/collectionPlugin.ts @@ -93,6 +93,7 @@ export class CollectionPlugin extends Plugin { this.dataSourceManager.addDataSource(MainDataSource, { key: DEFAULT_DATA_SOURCE_KEY, displayName: DEFAULT_DATA_SOURCE_TITLE, + status: 'loaded', }); } diff --git a/packages/core/client/src/data-source/data-source/DataSourceManager.ts b/packages/core/client/src/data-source/data-source/DataSourceManager.ts index 13f62fc82db..7c7f4fb6f32 100644 --- a/packages/core/client/src/data-source/data-source/DataSourceManager.ts +++ b/packages/core/client/src/data-source/data-source/DataSourceManager.ts @@ -50,6 +50,7 @@ export class DataSourceManager { key: DEFAULT_DATA_SOURCE_KEY, displayName: DEFAULT_DATA_SOURCE_TITLE, collections: options.collections || [], + status: 'loaded', }); (options.dataSources || []).forEach((dataSourceOptions) => { this.addDataSource(LocalDataSource, dataSourceOptions); @@ -70,6 +71,19 @@ export class DataSourceManager { return filterDataSource ? _.filter(allDataSources, filterDataSource) : allDataSources; } + setDataSources(dataSources: DataSourceOptions[]) { + const keyByDataSources = _.keyBy(dataSources, 'key'); + Object.keys(this.dataSourceInstancesMap).forEach((key) => { + const newDataSource = keyByDataSources[key]; + if (newDataSource) { + this.dataSourceInstancesMap[key].setOptions({ + displayName: newDataSource.displayName, + status: newDataSource.status, + }); + } + }); + } + getDataSource(key?: string) { return key ? this.dataSourceInstancesMap[key] : this.dataSourceInstancesMap[DEFAULT_DATA_SOURCE_KEY]; } diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 0415c4bb3e0..9c688a94ae2 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -1227,10 +1227,18 @@ "Calendar Month": "日历月", "Calendar Year": "日历年", "Scan to input": "扫码录入", + "Date variables(Deprecated)":"日期变量(废弃)", "Disable manual input": "禁止手动输入", "Enable Scan": "启用扫码录入", + "Available Collections": "可选数据表", + "Selected Collections": "已选数据表", + "Search collections...": "搜索数据表...", + "items": "项", + "item": "项", + "Load collections": "加载数据表", + "Are you sure you want to load {{count}} collection(s)?": "你确定要加载 {{count}} 个数据表吗?", + "Confirm Load Collections": "确认加载数据表", "Auto focus": "自动聚焦", - "Date variables(Deprecated)": "日期变量(废弃)", "{{#label}} length must be {{#limit}} characters long": "{{#label}} 长度必须是 {{#limit}} 个字符", "Min value": "最小值", "Max value": "最大值", diff --git a/packages/core/client/src/schema-component/antd/table/Table.Void.tsx b/packages/core/client/src/schema-component/antd/table/Table.Void.tsx index cd4fa12c4c7..1ab64ca0937 100644 --- a/packages/core/client/src/schema-component/antd/table/Table.Void.tsx +++ b/packages/core/client/src/schema-component/antd/table/Table.Void.tsx @@ -24,6 +24,7 @@ type TableVoidProps = TableProps & { options?: Options & { uid?: string }, props?: any, ) => Result & { state?: any; setState?: any }; + onSuccess?: (data?: any) => void; }; const pageSizeOptions = [5, 10, 20, 50, 100, 200]; @@ -94,7 +95,12 @@ const useDefSelectedRowKeys = () => { export const TableVoid: React.FC = observer( (props) => { - const { rowKey = 'id', useDataSource = useDef, useSelectedRowKeys = useDefSelectedRowKeys } = props; + const { + rowKey = 'id', + useDataSource = useDef, + useSelectedRowKeys = useDefSelectedRowKeys, + onSuccess: propsOnSuccess, + } = props; const field = useField(); const fieldSchema = useFieldSchema(); const form = useMemo(() => createForm(), []); @@ -115,6 +121,9 @@ export const TableVoid: React.FC = observer( } field.componentProps.pagination.current = data?.meta?.page || 1; field.componentProps.pagination.pageSize = data?.meta?.pageSize || 10; + + // 调用从 props 传入的 onSuccess 回调 + propsOnSuccess?.(data); }, }, props, diff --git a/packages/core/data-source-manager/src/data-source-factory.ts b/packages/core/data-source-manager/src/data-source-factory.ts index b11693bd07a..70327bbd2c8 100644 --- a/packages/core/data-source-manager/src/data-source-factory.ts +++ b/packages/core/data-source-manager/src/data-source-factory.ts @@ -9,22 +9,23 @@ import { DataSource } from './data-source'; import { DataSourceManager } from './data-source-manager'; +import { DataSourceConstructor } from './types'; export class DataSourceFactory { - public collectionTypes: Map = new Map(); + public collectionTypes: Map = new Map(); constructor(protected dataSourceManager: DataSourceManager) {} - register(type: string, dataSourceClass: typeof DataSource) { + register(type: string, dataSourceClass: DataSourceConstructor) { this.collectionTypes.set(type, dataSourceClass); } - getClass(type: string): typeof DataSource { - return this.collectionTypes.get(type); + getClass(type: string): DataSourceConstructor { + return this.collectionTypes.get(type) as DataSourceConstructor; } - create(type: string, options: any = {}): DataSource { - const klass = this.collectionTypes.get(type); + create(type: string, options: any = {}): T { + const klass = this.getClass(type); if (!klass) { throw new Error(`Data source type "${type}" not found`); } @@ -36,8 +37,7 @@ export class DataSourceFactory { if (environment) { Object.assign(opts, environment.renderJsonTemplate(others)); } - // @ts-ignore - const dataSource = new klass(opts) as DataSource; + const dataSource = new klass(opts); dataSource.setDataSourceManager(this.dataSourceManager); return dataSource; } diff --git a/packages/core/data-source-manager/src/data-source-manager.ts b/packages/core/data-source-manager/src/data-source-manager.ts index 8dc1fe34375..219823e9f2f 100644 --- a/packages/core/data-source-manager/src/data-source-manager.ts +++ b/packages/core/data-source-manager/src/data-source-manager.ts @@ -11,6 +11,7 @@ import { createConsoleLogger, createLogger, Logger, LoggerOptions } from '@nocob import { ToposortOptions } from '@nocobase/utils'; import { DataSource } from './data-source'; import { DataSourceFactory } from './data-source-factory'; +import { DataSourceConstructor } from './types'; type DataSourceHook = (dataSource: DataSource) => void; @@ -101,11 +102,11 @@ export class DataSourceManager { }; } - registerDataSourceType(type: string, DataSourceClass: typeof DataSource) { + registerDataSourceType(type: string, DataSourceClass: DataSourceConstructor) { this.factory.register(type, DataSourceClass); } - getDataSourceType(type: string): typeof DataSource | undefined { + getDataSourceType(type: string): DataSourceConstructor | undefined { return this.factory.getClass(type); } diff --git a/packages/core/data-source-manager/src/database-data-source.ts b/packages/core/data-source-manager/src/database-data-source.ts new file mode 100644 index 00000000000..9e705c23c29 --- /dev/null +++ b/packages/core/data-source-manager/src/database-data-source.ts @@ -0,0 +1,103 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Database } from '@nocobase/database'; +import { DatabaseIntrospector } from './database-introspector/database-introspector'; +import { PostgresIntrospector } from './database-introspector/postgres-introspector'; +import { MariaDBIntrospector } from './database-introspector/mariadb-introspector'; +import { DataSource } from './data-source'; +import { Context } from '@nocobase/actions'; +import { CollectionOptions, FieldOptions } from './types'; + +export abstract class DatabaseDataSource extends DataSource { + declare introspector: T; + + createDatabaseIntrospector(db: Database): T { + if (db.isPostgresCompatibleDialect()) { + return new PostgresIntrospector({ + db, + }) as unknown as T; + } + const dialect = db.sequelize.getDialect(); + switch (dialect) { + case 'mariadb': + return new MariaDBIntrospector({ + db, + }) as T; + default: + return new DatabaseIntrospector({ + db, + }) as T; + } + } + + abstract readTables(): Promise; + abstract loadTables(ctx: Context, tables: string[]): Promise; + + mergeWithLoadedCollections( + collections: CollectionOptions[], + loadedCollections: { + [name: string]: { name: string; fields: FieldOptions[] }; + }, + ): CollectionOptions[] { + return collections.map((collection) => { + const loadedCollection = loadedCollections[collection.name]; + if (!loadedCollection) return collection; + + const collectionFields = collection.fields || []; + const loadedFieldsObj = Object.fromEntries( + (loadedCollection.fields || []).map((f) => [f.columnName || f.field || f.name, f]), + ); + + // Merge existing fields + const newFields = collectionFields.map((field) => { + const loadedField = loadedFieldsObj[field.name]; + if (!loadedField) return field; + if (loadedField.possibleTypes) { + loadedField.possibleTypes = field.possibleTypes; + } + return this.mergeFieldOptions(field, loadedField); + }); + + // Add association fields from loaded data + const loadedAssociationFields = (loadedCollection.fields || []).filter((field) => + ['belongsTo', 'belongsToMany', 'hasMany', 'hasOne', 'belongsToArray'].includes(field.type), + ); + + newFields.push(...loadedAssociationFields); + + return { + ...collection, + ...Object.fromEntries(Object.entries(loadedCollection).filter(([key]) => key !== 'fields')), + fields: newFields, + }; + }); + } + + private mergeFieldOptions(fieldOptions: FieldOptions, modelOptions: FieldOptions): FieldOptions { + const newOptions = { + ...fieldOptions, + ...modelOptions, + }; + + if (fieldOptions.rawType && modelOptions.rawType && fieldOptions.rawType !== modelOptions.rawType) { + newOptions.type = fieldOptions.type; + newOptions.interface = fieldOptions.interface; + newOptions.rawType = fieldOptions.rawType; + } + + for (const key of [...new Set([...Object.keys(modelOptions), ...Object.keys(fieldOptions)])]) { + if (modelOptions[key] === null && fieldOptions[key]) { + newOptions[key] = fieldOptions[key]; + } + } + + return newOptions; + } +} diff --git a/packages/core/data-source-manager/src/database-introspector/database-introspector.ts b/packages/core/data-source-manager/src/database-introspector/database-introspector.ts new file mode 100644 index 00000000000..5e3ce5790d0 --- /dev/null +++ b/packages/core/data-source-manager/src/database-introspector/database-introspector.ts @@ -0,0 +1,341 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { Database, Repository, fieldTypeMap } from '@nocobase/database'; +import EventEmitter from 'events'; +import lodash from 'lodash'; +import { ColumnsDescription } from 'sequelize'; +import { isArray } from 'mathjs'; +import { typeInterfaceMap } from './type-interface-map'; +import { + CollectionOptions, + FieldInferResult, + FieldOptions, + PartialCollectionOptions, + UnsupportedFieldOptions, + tableInfo, +} from '../types'; + +type GetCollectionOptions = { + tableInfo: tableInfo; + localOptions?: PartialCollectionOptions; + mergedOptions?: PartialCollectionOptions; +}; + +interface DatabaseIntrospectorOptions { + db: Database; + typeInterfaceMap?: any; +} + +export class DatabaseIntrospector extends EventEmitter { + db: Database; + + constructor(options: DatabaseIntrospectorOptions) { + super(); + this.db = options.db; + } + + protected getFieldTypeMap(): Record> { + return fieldTypeMap[this.db.sequelize.getDialect()]; + } + + protected getTypeInterfaceConfig(type: string): Record { + return typeInterfaceMap[type] || {}; + } + + async getTableList() { + return await this.db.sequelize.getQueryInterface().showAllTables(); + } + + async getTableColumnsInfo(tableInfo: tableInfo) { + return this.db.sequelize.getQueryInterface().describeTable(tableInfo); + } + + async getTableConstraints(tableInfo: tableInfo) { + return this.db.sequelize.getQueryInterface().showIndex(tableInfo.tableName); + } + + async getViewList() { + return (await this.db.queryInterface.listViews()).map((view) => view.name); + } + + excludeViewsOrTables() { + return []; + } + + async getTables(options: any = {}) { + let tableList = await this.getTableList(); + const views = options.views?.length ? options.views : await this.getViewList(); + + tableList = tableList.concat(views); + + tableList = tableList.filter((tableName) => { + return !this.excludeViewsOrTables().includes(tableName); + }); + if (this.db.options.tablePrefix) { + tableList = tableList.filter((tableName) => { + return tableName.startsWith(this.db.options.tablePrefix); + }); + } + return tableList; + } + + async getCollection(options: GetCollectionOptions): Promise { + const { tableInfo, localOptions = {}, mergedOptions = {} } = options; + + const columnsInfo = await this.getTableColumnsInfo(tableInfo); + + const constraints = await this.getTableConstraints(tableInfo); + const collectionOptions = this.tableInfoToCollectionOptions(tableInfo); + + try { + const fields: Array = Object.keys(columnsInfo).map((columnName) => { + return this.columnInfoToFieldOptions(columnsInfo, columnName, constraints); + }); + + const unsupportedFields: Array = fields.filter((field) => { + // @ts-ignore + return field.supported === false; + }) as Array; + + const supportFields: Array = fields.filter((field) => { + // @ts-ignore + return field.supported !== false; + }) as Array; + + this.db.logger.debug('Processing collection fields', { + tableName: tableInfo.tableName, + totalFields: fields.length, + supportedFields: supportFields.length, + unsupportedFields: unsupportedFields.length, + }); + + const remoteCollectionInfo: CollectionOptions = { + ...collectionOptions, + ...this.collectionOptionsByFields(supportFields), + ...localOptions, + ...mergedOptions, + fields: supportFields, + }; + + if (unsupportedFields.length) { + remoteCollectionInfo.unsupportedFields = unsupportedFields; + this.db.logger.debug('Found unsupported fields', { + tableName: tableInfo.tableName, + fields: unsupportedFields.map((f) => ({ name: f.name, type: f.rawType })), + }); + } + + if ( + remoteCollectionInfo.view && + !remoteCollectionInfo.filterTargetKey && + supportFields.find((field) => field.name === 'id') + ) { + remoteCollectionInfo.filterTargetKey = 'id'; + this.db.logger.debug('Set view filterTargetKey to id', { + tableName: tableInfo.tableName, + }); + } + + return remoteCollectionInfo; + } catch (e) { + this.db.logger.error('Collection introspection error', { + tableName: tableInfo.tableName, + error: e.message, + stack: e.stack, + }); + throw new Error(`table ${tableInfo.tableName} introspection error: ${e.message}`, { cause: e }); + } + } + + public tableInfoToCollectionOptions(tableInfo: tableInfo) { + const tableName = tableInfo.tableName; + let name = tableName; + + if (this.db.options.tablePrefix) { + name = tableName.replace(this.db.options.tablePrefix, ''); + } + //replace dot to underscore + name = name.replace(/\./g, '_'); + + return { + name, + title: name, + schema: tableInfo.schema, + tableName, + }; + } + + protected extractTypeFromDefinition(rawType: string) { + const leftParenIndex = rawType.indexOf('('); + + if (leftParenIndex === -1) { + return rawType.toLowerCase(); + } + + return rawType.substring(0, leftParenIndex).toLowerCase().trim(); + } + + protected inferFieldTypeByRawType(rawType: string): any { + const fieldTypeMap = this.getFieldTypeMap(); + const queryType = this.extractTypeFromDefinition(rawType); + + const mappedType = fieldTypeMap[queryType]; + + if (isArray(mappedType)) { + return { + type: mappedType[0], + possibleTypes: mappedType, + }; + } + + return { + type: mappedType, + }; + } + + protected inferFieldOptionsByRawType(type: string, rawType: string) { + const fieldClass = this.db.fieldTypes.get(type); + + if (!fieldClass) { + return {}; + } + + if (typeof fieldClass.optionsFromRawType === 'function') { + return fieldClass.optionsFromRawType(rawType); + } + + return {}; + } + + protected columnInfoToFieldOptions( + columnsInfo: ColumnsDescription, + columnName: string, + indexes: any, + ): FieldInferResult { + const columnInfo = columnsInfo[columnName]; + + const name = columnName; + let fieldOptions: FieldOptions = { + ...this.columnAttribute(columnsInfo, columnName, indexes), + ...this.inferFieldTypeByRawType(columnInfo.type), + rawType: columnInfo.type, + name, + field: columnName, + }; + + Object.assign(fieldOptions, this.inferFieldOptionsByRawType(fieldOptions.type, columnInfo.type)); + + if (!fieldOptions.type) { + return { + rawType: columnInfo.type, + name, + field: columnName, + supported: false, + }; + } + + const interfaceConfig = this.getDefaultInterfaceByType(columnsInfo, columnName, fieldOptions.type); + + if (typeof interfaceConfig === 'string') { + fieldOptions.interface = interfaceConfig; + } else { + fieldOptions = { + ...fieldOptions, + ...interfaceConfig, + }; + } + + lodash.set(fieldOptions, 'uiSchema.title', columnName); + + return fieldOptions; + } + + protected columnAttribute(columnsInfo: ColumnsDescription, columnName: string, indexes: any) { + const columnInfo = columnsInfo[columnName]; + + const attr: any = { + type: columnInfo.type, + allowNull: columnInfo.defaultValue ? true : columnInfo.allowNull, + primaryKey: columnInfo.primaryKey, + unique: false, + autoIncrement: columnInfo.autoIncrement, + description: columnInfo.comment, + elementType: columnInfo['elementType'], + }; + + if (columnInfo.defaultValue && typeof columnInfo.defaultValue === 'string') { + const isSerial = columnInfo.defaultValue.match(/^nextval\(/); + const isUUID = columnInfo.defaultValue.match(/^uuid_generate_v4\(/); + + if (isSerial || isUUID) { + attr.autoIncrement = true; + } + } + + for (const index of indexes) { + if (index.fields.length == 1 && index.fields[0].attribute == columnName && index.unique) { + attr.unique = true; + } + } + + return attr; + } + + private collectionOptionsByFields(fields: FieldOptions[]): PartialCollectionOptions { + const options: PartialCollectionOptions = { + timestamps: false, + autoGenId: false, + }; + + const autoIncrementField = fields.find((field) => field.autoIncrement); + + if (autoIncrementField) { + options.filterTargetKey = autoIncrementField.name; + } + + const primaryKeys = fields.filter((field) => field.primaryKey); + + if (primaryKeys.length > 1) { + options.filterTargetKey = primaryKeys.map((field) => field.name); + } + + if (!options.filterTargetKey && primaryKeys.length == 1) { + options.filterTargetKey = primaryKeys[0].name; + } + + const uniques = fields.filter((field) => field.unique); + + if (!options.filterTargetKey && uniques.length == 1) { + options.filterTargetKey = uniques[0].name; + } + + return options; + } + + private getDefaultInterfaceByType(columnsInfo, columnName, type: string): object | string { + const interfaceConfig = this.getTypeInterfaceConfig(type); + + let interfaceRes = interfaceConfig; + if (typeof interfaceConfig === 'function') { + interfaceRes = interfaceConfig(columnsInfo[columnName]); + } + + return interfaceRes; + } +} diff --git a/packages/core/data-source-manager/src/database-introspector/mariadb-introspector.ts b/packages/core/data-source-manager/src/database-introspector/mariadb-introspector.ts new file mode 100644 index 00000000000..740d2eb8c2a --- /dev/null +++ b/packages/core/data-source-manager/src/database-introspector/mariadb-introspector.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { DatabaseIntrospector } from './database-introspector'; + +export class MariaDBIntrospector extends DatabaseIntrospector { + async getTableList() { + const tables = await this.db.sequelize.getQueryInterface().showAllTables(); + return tables.map((table: any) => { + return table.tableName; + }); + } +} diff --git a/packages/core/data-source-manager/src/database-introspector/postgres-introspector.ts b/packages/core/data-source-manager/src/database-introspector/postgres-introspector.ts new file mode 100644 index 00000000000..77279d54d63 --- /dev/null +++ b/packages/core/data-source-manager/src/database-introspector/postgres-introspector.ts @@ -0,0 +1,170 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { DatabaseIntrospector } from './database-introspector'; +import _ from 'lodash'; +import { ColumnsDescription } from 'sequelize'; +import { tableInfo } from '../types'; + +export class PostgresIntrospector extends DatabaseIntrospector { + async getTableConstraints(tableInfo: tableInfo): Promise { + const schema = tableInfo.schema || 'public'; + + const sql = ` + SELECT + tc.constraint_schema, + tc.constraint_name, + tc.table_name, + kcu.column_name, + CASE WHEN (SELECT COUNT(*) + FROM information_schema.key_column_usage kcu2 + WHERE kcu2.constraint_name = tc.constraint_name + AND kcu2.table_schema = tc.constraint_schema) = 1 THEN TRUE + ELSE FALSE END AS is_single_column +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +WHERE tc.constraint_type = 'UNIQUE' + AND tc.table_name = :tableName + AND tc.table_schema = :schema +ORDER BY tc.constraint_name, kcu.column_name; + `; + + return await this.db.sequelize.query(sql, { + type: 'SELECT', + replacements: { + tableName: tableInfo.tableName, + schema, + }, + }); + } + + async getTableColumnsInfo(tableInfo: tableInfo) { + const columnsInfo = await this.db.sequelize.getQueryInterface().describeTable(tableInfo); + const primaryKeys = await this.getPrimaryKeysOfTable(tableInfo); + + // adjust primary key attributes + Object.keys(columnsInfo).forEach((columnName) => { + const columnInfo = columnsInfo[columnName]; + const primaryKey = primaryKeys.find((key: any) => key.column_name === columnName); + if (primaryKey) { + columnInfo.primaryKey = true; + } + }); + await this.appendArrayColumnElementType(tableInfo, columnsInfo); + + return columnsInfo; + } + + private async getPrimaryKeysOfTable(tableInfo: tableInfo) { + const schema = tableInfo.schema || 'public'; + + const sql = ` + SELECT pg_attribute.attname AS column_name, + pg_constraint.conname AS constraint_name + FROM pg_constraint + JOIN pg_class ON pg_constraint.conrelid = pg_class.oid + JOIN pg_attribute ON pg_attribute.attnum = ANY (pg_constraint.conkey) + AND pg_attribute.attrelid = pg_class.oid + JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_class.relname = :tableName + AND pg_namespace.nspname = :schema + AND pg_constraint.contype = 'p'; + `; + + return await this.db.sequelize.query(sql, { + type: 'SELECT', + replacements: { + tableName: tableInfo.tableName, + schema, + }, + }); + } + + protected columnAttribute(columnsInfo: ColumnsDescription, columnName: string, indexes: any) { + const columnInfo = columnsInfo[columnName]; + + const attr: any = { + type: columnInfo.type, + allowNull: columnInfo.defaultValue ? true : columnInfo.allowNull, + primaryKey: columnInfo.primaryKey, + unique: false, + description: columnInfo.comment, + }; + + if (columnInfo.defaultValue && typeof columnInfo.defaultValue === 'string') { + const isSerial = columnInfo.defaultValue.match(/^nextval\(/); + const isUUID = columnInfo.defaultValue.match(/^uuid_generate_v4\(/); + + if (isSerial || isUUID) { + attr.autoIncrement = true; + } + } + + if (columnInfo.type === 'ARRAY') { + attr.dataType = 'array'; + if (columnInfo['elementType']) { + const elementType = columnInfo['elementType'].replace(/"/g, ''); + const { type } = this.inferFieldTypeByRawType(elementType); + attr.elementType = type; + } + } + + for (const index of indexes) { + if (index['is_single_column'] && index['column_name'] === columnName) { + attr.unique = true; + } + } + + return attr; + } + + private async getArrayColumnElementType(tableInfo: tableInfo, columnNames: string[]) { + const schema = tableInfo.schema || 'public'; + // https://www.postgresql.org/docs/current/infoschema-element-types.html + const sql = ` +SELECT c.column_name, e.data_type AS element_type +FROM information_schema.columns c LEFT JOIN information_schema.element_types e + ON ((c.table_catalog, c.table_schema, c.table_name, 'TABLE', c.dtd_identifier) + = (e.object_catalog, e.object_schema, e.object_name, e.object_type, e.collection_type_identifier)) +WHERE c.table_schema = :schema AND c.table_name = :tableName AND c.column_name in (:columnNames); + `; + + return await this.db.sequelize.query(sql, { + type: 'SELECT', + replacements: { + schema, + tableName: tableInfo.tableName, + columnNames, + }, + }); + } + + private async appendArrayColumnElementType(tableInfo: tableInfo, columnsInfo: ColumnsDescription) { + const arrayColumns = {}; + for (const columnName in columnsInfo) { + const columnInfo = columnsInfo[columnName]; + if (columnInfo.type === 'ARRAY') { + arrayColumns[columnName] = columnInfo; + } + } + const columnNames = Object.keys(arrayColumns); + if (!columnNames.length) { + return; + } + const rows = await this.getArrayColumnElementType(tableInfo, columnNames); + const udtNameMap = _.keyBy(rows, 'column_name') as { + [key: string]: { element_type: string }; + }; + Object.keys(arrayColumns).forEach((columnName) => { + columnsInfo[columnName]['elementType'] = udtNameMap[columnName].element_type; + }); + } +} diff --git a/packages/core/data-source-manager/src/database-introspector/type-interface-map.ts b/packages/core/data-source-manager/src/database-introspector/type-interface-map.ts new file mode 100644 index 00000000000..1be3a2dee09 --- /dev/null +++ b/packages/core/data-source-manager/src/database-introspector/type-interface-map.ts @@ -0,0 +1,212 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const typeInterfaceMap = { + // ---------- JSON / Structured ---------- + array: () => ({ + interface: 'json', + uiSchema: { + 'x-component': 'Input.JSON', + 'x-component-props': { + autoSize: { minRows: 5 }, + }, + default: null, + }, + }), + json: () => ({ + interface: 'json', + uiSchema: { + 'x-component': 'Input.JSON', + 'x-component-props': { + autoSize: { minRows: 5 }, + }, + default: null, + }, + }), + jsonb: () => ({ + interface: 'json', + uiSchema: { + 'x-component': 'Input.JSON', + 'x-component-props': { + autoSize: { minRows: 5 }, + }, + default: null, + }, + }), + + // ---------- Date / Time ---------- + date: () => ({ + interface: 'datetime', + uiSchema: { + 'x-component': 'DatePicker', + 'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false }, + }, + }), + datetime: () => ({ + interface: 'datetime', + uiSchema: { + 'x-component': 'DatePicker', + 'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false }, + }, + }), + datetimeTz: () => ({ + interface: 'datetime', + uiSchema: { + 'x-component': 'DatePicker', + 'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false }, + }, + }), + datetimeNoTz: () => ({ + interface: 'datetimeNoTz', + uiSchema: { + type: 'string', + 'x-component': 'DatePicker', + 'x-component-props': { showTime: false, utc: false }, + }, + }), + dateOnly: () => ({ + interface: 'dateOnly', + uiSchema: { + type: 'string', + 'x-component': 'DatePicker', + 'x-component-props': { dateOnly: true }, + }, + }), + time: () => ({ + interface: 'time', + uiSchema: { + type: 'string', + 'x-component': 'TimePicker', + 'x-component-props': { format: 'HH:mm:ss' }, + }, + }), + + // ---------- Numeric ---------- + integer: () => ({ + interface: 'integer', + uiSchema: { + type: 'number', + 'x-component': 'InputNumber', + 'x-component-props': { stringMode: true, step: '1' }, + 'x-validator': 'integer', + }, + }), + bigInt: () => ({ + interface: 'integer', + uiSchema: { + 'x-component': 'InputNumber', + 'x-component-props': { + style: { width: '100%' }, + }, + }, + }), + float: () => ({ + interface: 'number', + uiSchema: { + type: 'number', + 'x-component': 'InputNumber', + 'x-component-props': { stringMode: true, step: '1' }, + }, + }), + double: () => ({ + interface: 'number', + uiSchema: { + type: 'number', + 'x-component': 'InputNumber', + 'x-component-props': { stringMode: true, step: '1' }, + }, + }), + real: () => ({ + interface: 'number', + uiSchema: { + type: 'number', + 'x-component': 'InputNumber', + 'x-component-props': { stringMode: true, step: '1' }, + }, + }), + decimal: () => ({ + interface: 'number', + uiSchema: { + type: 'number', + 'x-component': 'InputNumber', + 'x-component-props': { stringMode: true, step: '1' }, + }, + }), + + // ---------- Text / String ---------- + string: () => ({ + interface: 'input', + uiSchema: { + 'x-component': 'Input', + 'x-component-props': { + style: { width: '100%' }, + }, + }, + }), + text: () => ({ + interface: 'textarea', + uiSchema: { + type: 'string', + 'x-component': 'Input.TextArea', + }, + }), + password: () => ({ + interface: 'password', + hidden: true, + uiSchema: { + type: 'string', + 'x-component': 'Password', + }, + }), + uid: () => ({ + interface: 'input', + uiSchema: { + 'x-component': 'Input', + 'x-component-props': { + style: { width: '100%' }, + }, + }, + }), + uuid: () => ({ + interface: 'uuid', + uiSchema: { + 'x-component': 'Input', + 'x-component-props': { + style: { width: '100%' }, + }, + }, + }), + + // ---------- Boolean ---------- + boolean: () => ({ + interface: 'checkbox', + uiSchema: { + type: 'boolean', + 'x-component': 'Checkbox', + }, + }), + + // ---------- Placeholders (not yet implemented) ---------- + // Relationships + belongsTo: '', + belongsToMany: '', + hasMany: '', + hasOne: '', + context: '', + virtual: '', + // Other + radio: '', + set: '', + sort: '', + // Geospatial + point: '', + polygon: '', + lineString: '', + circle: '', +}; diff --git a/packages/core/data-source-manager/src/index.ts b/packages/core/data-source-manager/src/index.ts index defaf540f88..48861679846 100644 --- a/packages/core/data-source-manager/src/index.ts +++ b/packages/core/data-source-manager/src/index.ts @@ -11,9 +11,11 @@ export * from './collection-manager'; export * from './collection'; export * from './data-source'; export * from './data-source-manager'; +export * from './database-data-source'; export * from './sequelize-collection-manager'; export * from './sequelize-data-source'; export * from './load-default-actions'; export * from './types'; +export * from './database-introspector/database-introspector'; export * from './utils'; diff --git a/packages/core/data-source-manager/src/sequelize-data-source.ts b/packages/core/data-source-manager/src/sequelize-data-source.ts index 52375ec1a46..21afef0a82d 100644 --- a/packages/core/data-source-manager/src/sequelize-data-source.ts +++ b/packages/core/data-source-manager/src/sequelize-data-source.ts @@ -7,10 +7,21 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { DataSource } from './data-source'; +import { DataSourceOptions } from './data-source'; +import { DatabaseDataSource } from './database-data-source'; +import { DatabaseIntrospector } from './database-introspector/database-introspector'; import { SequelizeCollectionManager } from './sequelize-collection-manager'; -export class SequelizeDataSource extends DataSource { +export abstract class SequelizeDataSource< + T extends DatabaseIntrospector = DatabaseIntrospector, +> extends DatabaseDataSource { + declare collectionManager: SequelizeCollectionManager; + + constructor(options: DataSourceOptions) { + super(options); + this.introspector = this.createDatabaseIntrospector(this.collectionManager.db); + } + createCollectionManager(options?: any) { return new SequelizeCollectionManager(options.collectionManager); } diff --git a/packages/core/data-source-manager/src/types.ts b/packages/core/data-source-manager/src/types.ts index ca5b0e4bb12..dc5611da188 100644 --- a/packages/core/data-source-manager/src/types.ts +++ b/packages/core/data-source-manager/src/types.ts @@ -9,14 +9,44 @@ import { DataSource } from './data-source'; -export type CollectionOptions = { - name: string; - repository?: string; - filterTargetKey?: string | Array; - fields: any[]; - [key: string]: any; +export type DataSourceConstructor = Omit & { + new (...args: any[]): T; }; +export type tableInfo = { + tableName: string; + schema?: string; +}; + +export type PartialCollectionOptions = Partial< + Omit & { + fields?: Partial[]; + } +>; + +export interface CollectionOptions { + name: string; + schema?: string; + tableName: string; + title?: string; + template?: string; + timestamps?: boolean; + filterTargetKey?: string | Array; + fields: FieldOptions[]; + autoGenId?: boolean; + view?: boolean; + unsupportedFields?: UnsupportedFieldOptions[]; + [key: string]: any; +} + +export interface UnsupportedFieldOptions { + rawType: string; + name: string; + supported: false; +} + +export type FieldInferResult = FieldOptions | UnsupportedFieldOptions; + export type FieldOptions = { name: string; field: string; diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 2968ffcdc54..6ba51a9f84f 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -565,11 +565,7 @@ export class Database extends EventEmitter implements AsyncEmitter { ): Collection { options = lodash.cloneDeep(options); - if (typeof options.underscored !== 'boolean') { - if (this.options.underscored) { - options.underscored = true; - } - } + options.underscored = options.underscored ?? this.options.underscored; this.logger.trace(`beforeDefineCollection: ${safeJsonStringify(options)}`, { databaseInstanceId: this.instanceId, diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index 9312ebae51d..ef0530e6e3c 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -54,12 +54,13 @@ export * from './repository'; export * from './relation-repository/relation-repository'; export { default as sqlParser, SQLParserTypes } from './sql-parser'; export * from './update-associations'; -export { snakeCase } from './utils'; +export { snakeCase, extractTypeFromDefinition } from './utils'; export * from './value-parsers'; export * from './view-collection'; export { default as fieldTypeMap } from './view/field-type-map'; export * from './view/view-inference'; export * from './update-guard'; +export { TableInfo } from './query-interface/query-interface'; export { default as operators } from './operators'; export { filterIncludes, mergeIncludes } from './utils/filter-include'; diff --git a/packages/core/database/src/utils.ts b/packages/core/database/src/utils.ts index 055b518676f..cdadc971e6a 100644 --- a/packages/core/database/src/utils.ts +++ b/packages/core/database/src/utils.ts @@ -129,6 +129,16 @@ export function getKeysByPrefix(keys: string[], prefix: string) { return keys.filter((key) => key.startsWith(`${prefix}.`)).map((key) => key.substring(prefix.length + 1)); } +export function extractTypeFromDefinition(rawType: string) { + const leftParenIndex = rawType.indexOf('('); + + if (leftParenIndex === -1) { + return rawType.toLowerCase(); + } + + return rawType.substring(0, leftParenIndex).toLowerCase().trim(); +} + export function processIncludes(includes: any[], model: any, parentAs = '') { includes.forEach((include: { association: string; include?: any[] }, index: number) => { // Process current level diff --git a/packages/core/server/src/index.ts b/packages/core/server/src/index.ts index bb4c88424a2..55a9aff8b09 100644 --- a/packages/core/server/src/index.ts +++ b/packages/core/server/src/index.ts @@ -23,6 +23,7 @@ export * from './event-queue'; export * from './background-job-manager'; export * from './worker-id-allocator'; export * from './redis-connection-manager'; +export * from './main-data-source'; export const OFFICIAL_PLUGIN_PREFIX = '@nocobase/plugin-'; export { diff --git a/packages/core/server/src/main-data-source.ts b/packages/core/server/src/main-data-source.ts index 77f7408a164..41c8a64cdf7 100644 --- a/packages/core/server/src/main-data-source.ts +++ b/packages/core/server/src/main-data-source.ts @@ -7,9 +7,15 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager'; +import { Context } from '@nocobase/actions'; +import { CollectionOptions, DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager'; +import { Collection, Model } from '@nocobase/database'; + +type MainDataSourceStatus = 'loaded' | 'loading'; export class MainDataSource extends SequelizeDataSource { + status: MainDataSourceStatus = 'loaded'; + init(options: DataSourceOptions = {}) { const { acl, resourceManager, database } = options; @@ -29,4 +35,146 @@ export class MainDataSource extends SequelizeDataSource { this.resourceManager.use(this.acl.middleware(), { group: 'acl', after: 'auth' }); } } + + async readTables() { + const allTables = await this.introspector.getTableList(); + const existsCollections = this.collectionManager.db.collections; + const existsTables = Array.from(existsCollections.values()).map( + (collection: Collection) => collection.model.tableName, + ); + const diffTables = allTables.filter((table) => !existsTables.includes(table)); + return diffTables.map((name) => ({ name })); + } + + private async tables2Collections( + tableNames: ( + | string + | { + tableName: string; + schema?: string; + } + )[], + ): Promise { + const db = this.collectionManager.db; + const results = await Promise.all( + tableNames.map(async (tableName) => { + let tableInfo: { tableName: string; schema?: string }; + if (typeof tableName === 'string') { + tableInfo = { tableName }; + if (db.options.schema) { + tableInfo.schema = db.options.schema; + } + } else { + tableInfo = tableName; + } + + try { + return await this.introspector.getCollection({ tableInfo }); + } catch (e) { + if (e.message.includes('No description found for')) { + return null; + } + + throw e; + } + }), + ); + return results.filter(Boolean); + } + + async loadTables(ctx: Context, tables: string[]) { + const repo = this.collectionManager.db.getRepository('collections'); + const existsCollections = this.collectionManager.db.collections; + const existsTables = Array.from(existsCollections.values()).map( + (collection: Collection) => collection.model.tableName, + ); + const toAddTables = tables.filter((table) => !existsTables.includes(table)); + if (toAddTables.length) { + try { + this.status = 'loading'; + const results = await this.tables2Collections(toAddTables); + const values = results.map((result) => ({ + ...result, + underscored: false, + })); + await repo.create({ values, context: ctx }); + } catch (e) { + throw e; + } finally { + this.status = 'loaded'; + } + } + } + + private async getLoadedCollections(filter?: any) { + const db = this.collectionManager.db; + const loadedCollections = await db.getRepository('collections').find({ + appends: ['fields'], + filter: { + hidden: false, + ...filter, + }, + }); + const collections = loadedCollections.filter((collection: Model) => collection.options?.from !== 'db2cm'); + const loadedData = {}; + for (const collection of collections) { + const c = db.getCollection(collection.name); + loadedData[c.tableName()] = { + ...collection.toJSON(), + fields: collection.fields.map((field: Model) => { + const f = c.getField(field.name); + return { + columnName: f?.columnName(), + ...field.toJSON(), + }; + }), + }; + } + return loadedData; + } + + async syncFieldsFromDatabase(ctx: any, collectionNames?: string[]) { + let filter = {}; + if (collectionNames?.length) { + filter = { + name: collectionNames, + }; + } + const db = this.collectionManager.db; + const loadedCollections = await this.getLoadedCollections(filter); + const tableNames = Object.values(loadedCollections).map(({ name }) => { + const collection = db.getCollection(name); + return collection.getTableNameWithSchema(); + }); + let collections = []; + try { + collections = await this.tables2Collections(tableNames); + } catch (err) { + ctx.log.error(err); + } + const toLoadCollections = this.mergeWithLoadedCollections(collections, loadedCollections); + + for (const values of toLoadCollections) { + const existsFields = loadedCollections[values.tableName].fields; + const deletedFields = existsFields.filter((field: any) => !values.fields.find((f) => f.name === field.name)); + + await db.sequelize.transaction(async (transaction) => { + for (const field of deletedFields) { + await db.getRepository('fields').destroy({ + filterByTk: field.key, + context: ctx, + transaction, + }); + } + + await db.getRepository('collections').update({ + filterByTk: values.name, + values, + updateAssociationValues: ['fields'], + context: ctx, + transaction, + }); + }); + } + } } diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts index 7bc00a90a87..13f8d021995 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts @@ -110,8 +110,8 @@ export class PluginAuthServer extends Plugin { }, }); // Register actions - Object.entries(authActions).forEach(([action, handler]) => - this.app.resourceManager.getResource('auth')?.addAction(action, handler), + Object.entries(authActions).forEach( + ([action, handler]) => this.app.resourceManager.getResource('auth')?.addAction(action, handler), ); Object.entries(authenticatorsActions).forEach(([action, handler]) => this.app.resourceManager.registerActionHandler(`authenticators:${action}`, handler), diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/client/index.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/client/index.ts index e89af1a8738..afa6c090a1d 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/client/index.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/client/index.ts @@ -49,7 +49,7 @@ class PluginDataSourceMainClient extends Plugin { 'x-component': 'InputNumber', 'x-read-pretty': true, }, - interface: 'integer', + interface: 'id', }, }); this.addCollectionPresetField({ diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/view-collection.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/view-collection.test.ts index 2a985344db7..7fb81be6da8 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/view-collection.test.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/view-collection.test.ts @@ -316,7 +316,6 @@ SELECT * FROM numbers; await app.db.sync(); const UserCollection = app.db.getCollection('users'); - const viewName = `t_${uid(6)}`; const createViewName = app.db.options.schema ? `${app.db.options.schema}.${viewName}` : viewName; const dropSQL = `DROP VIEW IF EXISTS ${createViewName}`; @@ -494,12 +493,17 @@ SELECT * FROM numbers; await app.db.sync(); const UserCollection = app.db.getCollection('users'); - - // create view const viewName = `t_${uid(6)}`; - const dropSQL = `DROP VIEW IF EXISTS ${viewName}`; + let schemaAndViewName = viewName; + const schema = process.env.COLLECTION_MANAGER_SCHEMA || app.db.options.schema || 'public'; + let usersTableName = UserCollection.quotedTableName(); + if (db.inDialect('postgres')) { + schemaAndViewName = `${schema}.${viewName}`; + usersTableName = `${schema}.users`; + } + const dropSQL = `DROP VIEW IF EXISTS ${schemaAndViewName}`; await app.db.sequelize.query(dropSQL); - const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`; + const viewSQL = `CREATE VIEW ${schemaAndViewName} AS SELECT * FROM ${usersTableName}`; await app.db.sequelize.query(viewSQL); // create view collection diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/read-db-tables.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/read-db-tables.test.ts index a31c0b913f3..8c39176b975 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/read-db-tables.test.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/read-db-tables.test.ts @@ -7,19 +7,19 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import Database from '@nocobase/database'; -import Application, { Plugin } from '@nocobase/server'; +import Database, { createMockDatabase } from '@nocobase/database'; +import { Plugin } from '@nocobase/server'; import { createApp } from '.'; +import { DataTypes, Sequelize } from 'sequelize'; describe('db2cm test', () => { let db: Database; let app: any; - afterEach(async () => { - await app.destroy(); - }); - describe('uiManageable test', async () => { + afterEach(async () => { + await app.destroy(); + }); class Plugin1 extends Plugin { static collection = { name: 'hello', @@ -87,4 +87,99 @@ describe('db2cm test', () => { expect(fields.some((c) => c.name === 'name')).toBeTruthy(); }); }); + + describe.skipIf(process.env.DB_DIALECT === 'sqlite')('read db tables', async () => { + let queryInterface: any; + let schema: string; + + beforeEach(async () => { + app = await createApp({ + plugins: ['data-source-manager'], + }); + db = app.db; + + queryInterface = db.sequelize.getQueryInterface(); + schema = db.options.schema; + }); + + afterEach(async () => { + await db.clean({ drop: true }); + await app.destroy(); + }); + + it('get available tables', async () => { + await queryInterface.createTable( + { + schema, + tableName: 'table', + }, + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + ); + const response = await app + .agent() + .resource('dataSources') + .readTables({ + values: { + dataSourceKey: 'main', + }, + }); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].name).toBe('table'); + }); + + it('load table', async () => { + await queryInterface.createTable( + { + schema, + tableName: 'table', + }, + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + ); + const tables = ['table']; + + let collections = await db.getRepository('collections').find({ + filter: { name: tables }, + }); + expect(collections.length).toBe(0); + + const response = await app + .agent() + .resource('dataSources') + .loadTables({ + values: { + dataSourceKey: 'main', + tables, + }, + }); + expect(response.status).toBe(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + collections = await db.getRepository('collections').find({ + filter: { name: tables }, + }); + + expect(collections.length).toBe(1); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/constants.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/constants.ts new file mode 100644 index 00000000000..835cdff1cf7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/constants.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const PRESET_FIELDS_INTERFACES = ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']; diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts index 1f46535ab7f..c119347d88e 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts @@ -149,6 +149,9 @@ export class CollectionModel extends MagicAttributeModel { }); for (const instance of instances) { + if (!instance.get('collectionName')) { + instance.set('collectionName', this.get('name')); + } await instance.load(options); } } diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/collections.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/collections.ts index 502be450807..81dc09c88f4 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/collections.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/collections.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Database } from '@nocobase/database'; +import { Collection, Database, snakeCase } from '@nocobase/database'; import lodash from 'lodash'; export default { diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/main-data-source.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/main-data-source.ts new file mode 100644 index 00000000000..63b7125175f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/resourcers/main-data-source.ts @@ -0,0 +1,35 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Context, Next } from '@nocobase/actions'; +import PluginDataSourceMainServer from '../server'; +import { MainDataSource } from '@nocobase/server'; + +export default { + name: 'mainDataSource', + actions: { + async refresh(ctx: Context, next: Next) { + const plugin = ctx.app.pm.get('data-source-main') as PluginDataSourceMainServer; + const mainDataSource = ctx.app.dataSourceManager.get('main') as MainDataSource; + if (mainDataSource.status === 'loaded') { + await plugin.loadCollections(); + } + await next(); + }, + + async syncFields(ctx: Context, next: Next) { + const { collections } = ctx.action.params.values || {}; + const mainDataSource = ctx.app.dataSourceManager.get('main') as MainDataSource; + if (mainDataSource.status === 'loaded') { + await mainDataSource.syncFieldsFromDatabase(ctx, collections); + } + await next(); + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts index 5565b945ade..6ddb621caad 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts @@ -7,7 +7,15 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Filter, InheritedCollection, JoiValidationError, UniqueConstraintError } from '@nocobase/database'; +import { + Collection, + extractTypeFromDefinition, + fieldTypeMap, + Filter, + InheritedCollection, + JoiValidationError, + UniqueConstraintError, +} from '@nocobase/database'; import PluginErrorHandler from '@nocobase/plugin-error-handler'; import { Plugin } from '@nocobase/server'; import lodash from 'lodash'; @@ -28,6 +36,9 @@ import { beforeDestoryField } from './hooks/beforeDestoryField'; import { CollectionModel, FieldModel } from './models'; import collectionActions from './resourcers/collections'; import viewResourcer from './resourcers/views'; +import mainDataSourceResource from './resourcers/main-data-source'; +import { ColumnsDescription } from 'sequelize'; +import { PRESET_FIELDS_INTERFACES } from './constants'; import { Schema } from '@formily/json-schema'; import _ from 'lodash'; @@ -428,7 +439,7 @@ export class PluginDataSourceMainServer extends Plugin { this.app.acl.registerSnippet({ name: `pm.data-source-manager.data-source-main`, - actions: ['collections:*', 'collections.fields:*', 'collectionCategories:*'], + actions: ['collections:*', 'collections.fields:*', 'collectionCategories:*', 'mainDataSource:*'], }); this.app.acl.registerSnippet({ @@ -470,11 +481,11 @@ export class PluginDataSourceMainServer extends Plugin { } await next(); }); + this.app.resourceManager.define(viewResourcer); + this.app.resourceManager.registerActionHandlers(collectionActions); + this.app.resourceManager.define(mainDataSourceResource); - this.app.resource(viewResourcer); - this.app.actions(collectionActions); - - const handleFieldSource = (fields) => { + const handleFieldSource = (fields, rawFields?: ColumnsDescription) => { for (const field of lodash.castArray(fields)) { if (field.get('source')) { const [collectionSource, fieldSource] = field.get('source').split('.'); @@ -500,6 +511,15 @@ export class PluginDataSourceMainServer extends Plugin { // set final options field.set('options', newOptions); } + const fieldTypes = fieldTypeMap[this.db.options.dialect]; + if (rawFields && fieldTypes) { + const rawField = rawFields[field.get('name')]; + if (rawField && !PRESET_FIELDS_INTERFACES.includes(field.get('interface'))) { + const mappedType = extractTypeFromDefinition(rawField.type); + const possibleTypes = fieldTypes[mappedType]; + field.set('possibleTypes', possibleTypes); + } + } } }; @@ -522,7 +542,19 @@ export class PluginDataSourceMainServer extends Plugin { //handle collections:fields:list if (ctx.action.resourceName == 'collections.fields' && ctx.action.actionName == 'list') { - handleFieldSource(ctx.action.params?.paginate == 'false' ? ctx.body : ctx.body.rows); + const collectionName = ctx.action.sourceId; + const collection: Collection = ctx.db.getCollection(collectionName); + let rawFields: ColumnsDescription = {}; + if (collection) { + try { + rawFields = await ctx.app.db.queryInterface.sequelizeQueryInterface.describeTable( + collection.getTableNameWithSchema(), + ); + } catch (err) { + // ignore + } + } + handleFieldSource(ctx.action.params?.paginate == 'false' ? ctx.body : ctx.body.rows, rawFields); } if (ctx.action.resourceName == 'collections.fields' && ctx.action.actionName == 'get') { diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/BreadcumbTitle.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/BreadcumbTitle.tsx index 7c32fe3d0b0..454a140a9f0 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/BreadcumbTitle.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/BreadcumbTitle.tsx @@ -21,11 +21,12 @@ export const BreadcumbTitle = () => { const { name } = useParams(); const compile = useCompile(); const dm = useDataSourceManager(); - const { displayName } = dm.getDataSource(name) || {}; + const dataSourceKey = name || 'main'; + const { displayName } = dm.getDataSource(dataSourceKey) || {}; const { dataSource } = useContext(DataSourceContext); const dataSourceValue = useMemo( - () => (dataSource && dataSource?.name === name ? dataSource : dm.getDataSource(name)), - [dataSource, name], + () => (dataSource && dataSource?.name === dataSourceKey ? dataSource : dm.getDataSource(dataSourceKey)), + [dataSource, dataSourceKey, dm.getDataSource(dataSourceKey)?.status], ); const items = useMemo(() => { const status = dataSourceValue?.status; diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/ConfigurationTable.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/ConfigurationTable.tsx index 50a871dee45..f585587dbba 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/ConfigurationTable.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/ConfigurationTable.tsx @@ -28,11 +28,11 @@ import { useRecord, } from '@nocobase/client'; import { getPickerFormat } from '@nocobase/utils/client'; -import { message } from 'antd'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { DataSourceContext } from '../../DatabaseConnectionProvider'; +import { useDataSourceRefresh } from '../../hooks/useDataSourceRefresh'; +import { useDataSourceData } from '../../hooks/useResourceData'; import { CollectionFields } from './CollectionFields'; import { getCollectionSchema } from './schema/collections'; @@ -100,6 +100,8 @@ export const ConfigurationTable = () => { const data = useContext(CollectionCategoriesContext); const api = useAPIClient(); const compile = useCompile(); + const service = useContext(ResourceActionContext); + const loadCategories = async () => { return data.data.map((item: any) => ({ label: compile(item.name), @@ -122,56 +124,18 @@ export const ConfigurationTable = () => { }; const useRefreshActionProps = () => { - const service = useContext(ResourceActionContext); - const api = useAPIClient(); - const field = useField(); - const { name } = useParams(); - field.data = field.data || {}; - const { setDataSource, dataSource } = useContext(DataSourceContext); - return { - async onClick() { - field.data.loading = true; - try { - const { data } = await api.request({ - url: `dataSources:refresh?filterByTk=${name}&clientStatus=${dataSource?.status || 'loaded'}`, - method: 'post', - }); - field.data.loading = false; - setDataSource({ ...data?.data, name }); - if (data?.data?.status === 'reloading') { - message.warning(t('Data source synchronization in progress')); - } else if (data?.data?.status === 'loaded') { - message.success(t('Data source synchronization successful')); - service?.refresh?.(); - } - await ds.getDataSource(name).reload(); - } catch (error) { - field.data.loading = false; - } + return useDataSourceRefresh({ + dataSourceName: name, + onSuccess: () => { + service?.refresh?.(); }, - }; + }); }; const collectionSchema = useMemo(() => { return getCollectionSchema(name); }, [name]); - const resource = api.resource('dataSources', name); - const [dataSourceData, setDataSourceData] = useState({}); - - useEffect(() => { - try { - // eslint-disable-next-line promise/catch-or-return - resource - .get({ - filterByTk: name, - }) - .then((data) => { - setDataSourceData(data?.data); - }); - } catch (error) { - console.log(error); - } - }, [name]); + const { data: dataSourceData } = useDataSourceData(name); const loadFilterTargetKeys = async (field) => { const { fields } = field.form.values; diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx index 41509b81597..3ac16bba654 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx @@ -147,8 +147,11 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => { async run() { await form.submit(); await api.request({ - url: `dataSources/${name}/collections:update?filterByTk=${filterByTk}`, + url: `dataSources/${name}/collections:update`, method: 'post', + params: { + filterByTk, + }, data: { ...omit(form.values, ['fields', 'autoGenId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'sortable']), }, diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/SetFilterTargetKey.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/SetFilterTargetKey.tsx index 8cff8f28316..c18836568f9 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/SetFilterTargetKey.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/SetFilterTargetKey.tsx @@ -91,16 +91,18 @@ export const SetFilterTargetKey = (props) => { } if (dataSourceKey === 'main') { await api.request({ - url: `collections:update?filterByTk=${record.name}`, + url: `collections:update`, method: 'post', + params: { filterByTk: record.name }, data: { filterTargetKey, }, }); } else { await api.request({ - url: `dataSources/${dataSourceKey}/collections:update?filterByTk=${record.name}`, + url: `dataSources/${dataSourceKey}/collections:update`, method: 'post', + params: { filterByTk: record.name }, data: { filterTargetKey, }, diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/components/TitleField.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/components/TitleField.tsx index c47bb4ec556..48f485a1c56 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/components/TitleField.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/components/TitleField.tsx @@ -35,8 +35,9 @@ export const TitleField = withDynamicSchemaProps( const handleChange = async (checked) => { setLoadingRecord(record); await api.request({ - url: `dataSources/${dataSourceKey}/collections:update?filterByTk=${filterByTk}`, + url: `dataSources/${dataSourceKey}/collections:update`, method: 'post', + params: { filterByTk }, data: { titleField: checked ? record.name : 'id' }, }); message.success(t('Saved successfully')); diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsTableField.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsTableField.tsx index 66049d3e6a8..615f0d36665 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsTableField.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsTableField.tsx @@ -153,15 +153,15 @@ const CollectionsTable = observer((tableProps: any) => { setLoading(true); try { - const params: any = { - isFirst: from === 'create', - dbOptions: { ...options, type: formValues.type || 'mysql' }, - }; - - const response = await api.request({ - url: `dataSources/${key}/collections:all`, - method: 'get', - params, + const response = await api.resource('dataSources').readTables({ + values: { + dataSourceKey: key, + ...(from === 'create' + ? { + dbOptions: { ...options, type: formValues.type || 'mysql' }, + } + : {}), + }, }); const { data } = response?.data || {}; const allCollectionsData = data || []; diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CreateDatabaseConnectAction.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CreateDatabaseConnectAction.tsx index c9867f08397..4da991c5f97 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CreateDatabaseConnectAction.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CreateDatabaseConnectAction.tsx @@ -9,6 +9,7 @@ import { DownOutlined, PlusOutlined } from '@ant-design/icons'; import { uid } from '@formily/shared'; +import { useForm, useField } from '@formily/react'; import { ActionContext, SchemaComponent, @@ -17,6 +18,7 @@ import { usePlugin, useResourceContext, useActionContext, + useResourceActionContext, } from '@nocobase/client'; import { Button, Dropdown, Empty } from 'antd'; import React, { useState } from 'react'; @@ -26,6 +28,39 @@ import { useLoadCollections, useTestConnectionAction } from '../hooks'; import { NAMESPACE } from '../locale'; import { CollectionsTableField } from './CollectionsTableField'; +const useCreateAction = (actionCallback?: (values: any) => void) => { + const form = useForm(); + const field = useField(); + const ctx = useActionContext(); + const { refresh } = useResourceActionContext(); + const { resource } = useResourceContext(); + return { + async run() { + try { + await form.submit(); + field.data = field.data || {}; + field.data.loading = true; + const collections: { name: string; selected: boolean }[] = form.values.collections || []; + const res = await resource.create({ + values: { + ...form.values, + collections: collections.filter((c) => c.selected).map((c) => c.name), + }, + }); + ctx.setVisible(false); + actionCallback?.(res?.data?.data); + await form.reset(); + field.data.loading = false; + refresh(); + } catch (error) { + if (field.data) { + field.data.loading = false; + } + } + }, + }; +}; + export const CreateDatabaseConnectAction = () => { const [schema, setSchema] = useState({}); const plugin = usePlugin(PluginDatabaseConnectionsClient); @@ -108,7 +143,7 @@ export const CreateDatabaseConnectAction = () => { 'x-component': 'Action', 'x-component-props': { type: 'primary', - useAction: '{{ cm.useCreateAction }}', + useAction: '{{ useCreateAction }}', actionCallback: '{{ dataSourceCreateCallback }}', }, }, @@ -170,6 +205,7 @@ export const CreateDatabaseConnectAction = () => { useTestConnectionAction, dialect, useDialectDataSource, + useCreateAction, }} schema={schema} /> diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx index db8ee480efd..8e7155665a6 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx @@ -26,14 +26,21 @@ import { ThirdDataSource } from '../ThridDataSource'; import { CreateDatabaseConnectAction } from './CreateDatabaseConnectAction'; import { EditDatabaseConnectionAction } from './EditDatabaseConnectionAction'; import { ViewDatabaseConnectionAction } from './ViewDatabaseConnectionAction'; -import { addDatasourceCollections } from '../hooks'; import _ from 'lodash'; export const DatabaseConnectionManagerPane = () => { const { t } = useTranslation(); const plugin = usePlugin(PluginDatabaseConnectionsClient); const dm = useDataSourceManager(); - const api = useAPIClient(); // 移到组件顶层 + const api = useAPIClient(); + + const dataSourceListCallback = useCallback( + (data: any) => { + dm.setDataSources(data?.data || []); + dm.reload(); + }, + [dm], + ); const types = [...plugin.types.keys()] .map((key) => { @@ -65,14 +72,11 @@ export const DatabaseConnectionManagerPane = () => { ); const dataSourceCreateCallback = useCallback( - async (data: any, collections) => { - if (!data.options?.addAllCollections) { - await addDatasourceCollections(api, data.key, { collections, dbOptions: data.options }); - } + async (data: any) => { dm.addDataSource(ThirdDataSource, data); reloadKeys.current = [...reloadKeys.current, data.key]; }, - [api, dm], + [dm], ); const useRefreshActionProps = () => { @@ -122,6 +126,7 @@ export const DatabaseConnectionManagerPane = () => { useDestroyAction, dataSourceDeleteCallback, dataSourceCreateCallback, + dataSourceListCallback, useIsAbleDelete, }} schema={databaseConnectionSchema} diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/EditDatabaseConnectionAction.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/EditDatabaseConnectionAction.tsx index e56d1ce9948..44f1bc5b916 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/EditDatabaseConnectionAction.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/EditDatabaseConnectionAction.tsx @@ -26,7 +26,7 @@ import { useTranslation } from 'react-i18next'; import { useForm, useField } from '@formily/react'; import PluginDatabaseConnectionsClient from '../'; import { NAMESPACE } from '../locale'; -import { addDatasourceCollections, useLoadCollections } from '../hooks'; +import { useLoadCollections } from '../hooks'; import { CollectionsTableField } from './CollectionsTableField'; export const EditDatabaseConnectionAction = () => { @@ -52,18 +52,17 @@ export const EditDatabaseConnectionAction = () => { await form.submit(); field.data = field.data || {}; field.data.loading = true; + const collections: { name: string; selected: boolean }[] = form.values.collections || []; try { - await resource.update({ filterByTk, values: _.omit(form.values, 'collections') }); - const toBeAddedCollections = form.values.collections || []; - if (!form.values.addAllCollections) { - await addDatasourceCollections(api, filterByTk, { - collections: toBeAddedCollections, - dbOptions: form.values.options, - }); - } - delete form.values.collections; + await resource.update({ + filterByTk, + values: { + ...form.values, + collections: collections.filter((c) => c.selected).map((c) => c.name), + }, + }); ctx.setVisible(false); - dm.getDataSource(filterByTk).setOptions(form.values); + dm.getDataSource(filterByTk).setOptions(_.omit(form.values, 'collections')); dm.getDataSource(filterByTk).reload(); await form.reset(); refresh(); diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/CollectionFields.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/CollectionFields.tsx index aa7a414e261..f26f312f065 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/CollectionFields.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/CollectionFields.tsx @@ -34,13 +34,17 @@ import { useResourceActionContext, useResourceContext, ViewCollectionField, + useFieldInterfaceOptions, + useApp, } from '@nocobase/client'; -import { message, Space, Switch, Table, TableColumnProps, Tag, Tooltip } from 'antd'; -import React, { createContext, useContext, useMemo } from 'react'; +import { message, Select, Space, Switch, Table, TableColumnProps, Tag, Tooltip } from 'antd'; +import React, { createContext, useContext, useMemo, useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { omit } from 'lodash'; import { FilterTargetKeyAlert } from '../../CollectionsManager/FilterTargetKeyAlert'; import { collection } from './schemas/collectionFields'; +import { SyncFieldChangesAction } from './db-sync/SyncFieldChangesAction'; const resourceActionProps = { association: { sourceKey: 'name', @@ -70,24 +74,56 @@ const CollectionFieldsProvider = (props) => { ); }; -const indentStyle = css` - .ant-table { - margin-left: -16px !important; - } -`; const tableContainer = css` tr { display: flex; } td, th { - flex: 2.3; + flex: 2; width: 0; - &:nth-child(5) { - flex: 1.2; + min-width: 120px; + + /* Field display name */ + &:nth-child(2) { + flex: 2.5; + min-width: 150px; } + + /* Field name */ + &:nth-child(3) { + flex: 2; + min-width: 120px; + } + + /* Field interface - 需要更多空间 */ + &:nth-child(4) { + flex: 2.5; + min-width: 180px; + } + + /* Field type */ + &:nth-child(5) { + flex: 1.8; + min-width: 120px; + } + + /* Title field */ + &:nth-child(6) { + flex: 1.2; + min-width: 100px; + } + + /* Description */ + &:nth-child(7) { + flex: 2; + min-width: 120px; + } + + /* Actions */ &:last-child { flex: 1.5; + min-width: 120px; } } .ant-table-selection-column, @@ -98,13 +134,262 @@ const tableContainer = css` } `; +const groupTableContainer = css` + tr { + display: flex; + } + td, + th { + flex: 2; + width: 0; + min-width: 120px; + + /* Title column for inherited fields - 需要更多空间显示完整标题 */ + &:nth-child(2) { + flex: 4; + min-width: 300px; + white-space: nowrap; + } + + /* Field display name */ + &:nth-child(3) { + flex: 2.5; + min-width: 150px; + } + + /* Field name */ + &:nth-child(4) { + flex: 2; + min-width: 120px; + } + + /* Field interface */ + &:nth-child(5) { + flex: 2.5; + min-width: 180px; + } + + /* Field type */ + &:nth-child(6) { + flex: 1.8; + min-width: 120px; + } + + /* Title field */ + &:nth-child(7) { + flex: 1.2; + min-width: 100px; + } + + /* Actions */ + &:last-child { + flex: 1.5; + min-width: 120px; + } + } + .ant-table-selection-column, + .ant-table-row-expand-icon-cell { + flex-basis: 50px !important; + min-width: 50px; + flex: 0; + } +`; + +const inheritFieldsContainer = css` + tr { + display: flex; + } + td, + th { + flex: 2; + width: 0; + min-width: 100px; + + /* Field display name */ + &:nth-child(1) { + flex: 2.5; + min-width: 150px; + } + + /* Field name - 减少宽度 */ + &:nth-child(2) { + flex: 1.5; + min-width: 100px; + } + + /* Field interface */ + &:nth-child(3) { + flex: 2; + min-width: 150px; + } + + /* Field type */ + &:nth-child(4) { + flex: 1.5; + min-width: 100px; + } + + /* Title field */ + &:nth-child(5) { + flex: 1.2; + min-width: 80px; + } + + /* Description */ + &:nth-child(6) { + flex: 2; + min-width: 120px; + } + + /* Actions */ + &:last-child { + flex: 1.8; + min-width: 140px; + } + } +`; + const titlePrompt = 'Default title for each record'; + +const getInterfaceOptions = (data, type) => { + const interfaceOptions = []; + data.forEach((item) => { + const options = item.children.filter((h) => h?.availableTypes?.includes(type)); + interfaceOptions.push({ + label: item.label, + key: item.key, + children: options, + }); + }); + return interfaceOptions.filter((v) => { + if (type === 'sort') { + return v.key === 'advanced'; + } + return v.children.length > 0; + }); +}; + +const isValueInOptions = (value, options) => { + return options?.some((option) => option.children?.some?.((child) => child.name === value)); +}; + +const FieldInterfaceRenderer = ({ value, record, updateFieldHandler, isPresetField = false }) => { + const compile = useCompile(); + const { getInterface } = useCollectionManager_deprecated(); + const initOptions = useFieldInterfaceOptions(); + const [selectValue, setSelectValue] = useState(value); + const [options, setOptions] = useState([]); + const targetType = record.type; + + const applyInterfaceChange = useCallback( + async (newValue) => { + const interfaceConfig = getInterface(newValue); + setSelectValue(newValue); + await updateFieldHandler(record, { + interface: newValue, + uiSchema: { + title: record?.uiSchema?.title, + ...interfaceConfig?.default?.uiSchema, + }, + }); + }, + [record], + ); + + useEffect(() => { + if (isPresetField) return; + if (record?.possibleTypes || targetType) { + const newOptions = getInterfaceOptions(initOptions, targetType); + setOptions(newOptions); + } + }, [record, isPresetField, targetType, initOptions]); + + useEffect(() => { + if (isPresetField) return; + if (!options.length) return; + + const firstOption = options[0]?.children?.[0]?.name; + if (!firstOption) return; + + if ( + (options.length === 1 && options[0]?.children?.length === 1 && selectValue !== firstOption) || + (selectValue && !isValueInOptions(selectValue, options)) + ) { + applyInterfaceChange(firstOption); + } + }, [selectValue, isPresetField, options, applyInterfaceChange]); + + if (['oho', 'obo', 'o2m', 'm2o', 'm2m'].includes(record.interface)) { + return ( + + {compile(initOptions.find((h) => h.key === 'relation')?.children?.find((v) => v.name === value)?.label)} + + ); + } + + if (!options.length || options.every((group) => !group.children.length)) { + return {compile(getInterface(value)?.title)}; + } + + return ( + + ); +}; + +const FieldTypeRenderer = ({ value, record, updateFieldHandler, isPresetField = false }) => { + const item = omit(record, ['__parent', '__collectionName']); + const possibleTypes = item?.possibleTypes || []; + return !Array.isArray(item?.possibleTypes) || isPresetField || !possibleTypes.includes(value) ? ( + {value} + ) : ( +