feat(data-source-main): reading tables & change field interfaces (#7118)

* 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 c881117c9d.

* 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>
This commit is contained in:
ajie
2025-10-24 15:21:22 +08:00
committed by GitHub
co-authored by xilesun
parent 32ec688a8d
commit fc21db8ecb
59 changed files with 2664 additions and 495 deletions
@@ -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<Array<() => 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 (
<BlockConfigsContext.Provider value={contextValue}>
{children}
</BlockConfigsContext.Provider>
);
};
return <BlockConfigsContext.Provider value={contextValue}>{children}</BlockConfigsContext.Provider>;
};
@@ -7,4 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './BlockConfigsProvider';
export * from './BlockConfigsProvider';
@@ -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();
@@ -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',
});
}
@@ -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];
}
+9 -1
View File
@@ -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": "最大值",
@@ -24,6 +24,7 @@ type TableVoidProps = TableProps<any> & {
options?: Options<any, any> & { uid?: string },
props?: any,
) => Result<any, any> & { 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<TableVoidProps> = observer(
(props) => {
const { rowKey = 'id', useDataSource = useDef, useSelectedRowKeys = useDefSelectedRowKeys } = props;
const {
rowKey = 'id',
useDataSource = useDef,
useSelectedRowKeys = useDefSelectedRowKeys,
onSuccess: propsOnSuccess,
} = props;
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const form = useMemo(() => createForm(), []);
@@ -115,6 +121,9 @@ export const TableVoid: React.FC<TableVoidProps> = observer(
}
field.componentProps.pagination.current = data?.meta?.page || 1;
field.componentProps.pagination.pageSize = data?.meta?.pageSize || 10;
// 调用从 props 传入的 onSuccess 回调
propsOnSuccess?.(data);
},
},
props,
@@ -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<string, typeof DataSource> = new Map();
public collectionTypes: Map<string, DataSourceConstructor> = 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<T extends DataSource = DataSource>(type: string): DataSourceConstructor<T> {
return this.collectionTypes.get(type) as DataSourceConstructor<T>;
}
create(type: string, options: any = {}): DataSource {
const klass = this.collectionTypes.get(type);
create<T extends DataSource = DataSource>(type: string, options: any = {}): T {
const klass = this.getClass<T>(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;
}
@@ -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);
}
@@ -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<T extends DatabaseIntrospector = DatabaseIntrospector> 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<any>;
abstract loadTables(ctx: Context, tables: string[]): Promise<any>;
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;
}
}
@@ -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 <https://www.nocobase.com/agreement>
*/
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<string, string | Array<string>> {
return fieldTypeMap[this.db.sequelize.getDialect()];
}
protected getTypeInterfaceConfig(type: string): Record<string, any> {
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<CollectionOptions> {
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<FieldInferResult> = Object.keys(columnsInfo).map((columnName) => {
return this.columnInfoToFieldOptions(columnsInfo, columnName, constraints);
});
const unsupportedFields: Array<UnsupportedFieldOptions> = fields.filter((field) => {
// @ts-ignore
return field.supported === false;
}) as Array<UnsupportedFieldOptions>;
const supportFields: Array<FieldOptions> = fields.filter((field) => {
// @ts-ignore
return field.supported !== false;
}) as Array<FieldOptions>;
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;
}
}
@@ -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;
});
}
}
@@ -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<object> {
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;
});
}
}
@@ -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: '',
};
@@ -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';
@@ -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<T> {
declare collectionManager: SequelizeCollectionManager;
constructor(options: DataSourceOptions) {
super(options);
this.introspector = this.createDatabaseIntrospector(this.collectionManager.db);
}
createCollectionManager(options?: any) {
return new SequelizeCollectionManager(options.collectionManager);
}
+36 -6
View File
@@ -9,14 +9,44 @@
import { DataSource } from './data-source';
export type CollectionOptions = {
name: string;
repository?: string;
filterTargetKey?: string | Array<string>;
fields: any[];
[key: string]: any;
export type DataSourceConstructor<T extends DataSource = DataSource> = Omit<typeof DataSource, 'prototype'> & {
new (...args: any[]): T;
};
export type tableInfo = {
tableName: string;
schema?: string;
};
export type PartialCollectionOptions = Partial<
Omit<CollectionOptions, 'fields'> & {
fields?: Partial<FieldOptions>[];
}
>;
export interface CollectionOptions {
name: string;
schema?: string;
tableName: string;
title?: string;
template?: string;
timestamps?: boolean;
filterTargetKey?: string | Array<string>;
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;
+1 -5
View File
@@ -565,11 +565,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
): Collection<Attributes, CreateAttributes> {
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,
+2 -1
View File
@@ -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';
+10
View File
@@ -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
+1
View File
@@ -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 {
+149 -1
View File
@@ -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<CollectionOptions[]> {
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,
});
});
}
}
}
@@ -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),
@@ -49,7 +49,7 @@ class PluginDataSourceMainClient extends Plugin {
'x-component': 'InputNumber',
'x-read-pretty': true,
},
interface: 'integer',
interface: 'id',
},
});
this.addCollectionPresetField({
@@ -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
@@ -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);
});
});
});
@@ -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'];
@@ -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);
}
}
@@ -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 {
@@ -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();
},
},
};
@@ -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') {
@@ -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;
@@ -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;
@@ -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']),
},
@@ -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,
},
@@ -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'));
@@ -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 || [];
@@ -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}
/>
@@ -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}
@@ -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();
@@ -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 (
<Tag>
{compile(initOptions.find((h) => h.key === 'relation')?.children?.find((v) => v.name === value)?.label)}
</Tag>
);
}
if (!options.length || options.every((group) => !group.children.length)) {
return <Tag>{compile(getInterface(value)?.title)}</Tag>;
}
return (
<Select
aria-label={`field-interface-${record?.interface}`}
value={selectValue}
style={{ width: '100%' }}
popupMatchSelectWidth={false}
disabled={isPresetField}
onChange={async (newValue) => {
if (isPresetField) return;
const interfaceConfig = getInterface(newValue);
setSelectValue(newValue);
await updateFieldHandler(record, {
interface: newValue,
uiSchema: { title: record?.uiSchema?.title, ...interfaceConfig?.default?.uiSchema },
});
}}
>
{options.map((group) => (
<Select.OptGroup key={group.key} label={compile(group.label)}>
{group.children.map((item) => (
<Select.Option key={item.name} value={item.name}>
{compile(item.label)}
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
);
};
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) ? (
<Tag>{value}</Tag>
) : (
<Select
aria-label={`field-type-${value}`}
defaultValue={value}
popupMatchSelectWidth={false}
style={{ width: '100%' }}
options={
item?.possibleTypes.map((v) => {
return { label: v, value: v };
}) || []
}
onChange={async (newValue) => {
await updateFieldHandler(record, { type: newValue });
}}
/>
);
};
const CurrentFields = (props) => {
const compile = useCompile();
const { getInterface } = useCollectionManager_deprecated();
const { t } = useTranslation();
const { setState } = useResourceActionContext();
const { targetKey } = props.collectionResource || {};
const { targetKey, resource } = props.collectionResource || {};
const parentRecordData = useRecord();
const [loadingRecord, setLoadingRecord] = React.useState<any>(null);
const { refreshCM, isTitleField, getTemplate } = useCollectionManager_deprecated();
@@ -112,6 +397,30 @@ const CurrentFields = (props) => {
const targetTemplate = getTemplate(template);
const api = useAPIClient();
const ctx = useContext(CollectionListContext);
const { collectionInfo } = props;
const app = useApp();
const mainDataSourcePlugin: any = app.pm.get('data-source-main');
const collectionPresetFields = mainDataSourcePlugin.getCollectionPresetFields();
const collectionPresetFieldsInterfaces = collectionPresetFields.map((v) => v.value?.interface).filter((v) => v);
const updateFieldHandler = async (record, values) => {
try {
await resource.update({
filterByTk: record.name,
values: {
...values,
collectionName: record.collectionName,
},
});
// ctx?.refresh?.();
await props.refreshAsync();
message.success(t('Saved successfully'));
refreshCM();
} catch (error) {
console.error('Failed to update field type:', error);
message.error(t('Save failed'));
}
};
const columns: TableColumnProps<any>[] = [
{
dataIndex: ['uiSchema', 'title'],
@@ -125,7 +434,26 @@ const CurrentFields = (props) => {
{
dataIndex: 'interface',
title: t('Field interface'),
render: (value) => <Tag>{compile(getInterface(value)?.title)}</Tag>,
render: (value, record) => (
<FieldInterfaceRenderer
value={value}
record={record}
updateFieldHandler={updateFieldHandler}
isPresetField={collectionInfo.from === 'db2cm' || collectionPresetFieldsInterfaces.includes(record.interface)}
/>
),
},
{
dataIndex: 'type',
title: t('Field type'),
render: (value, record) => (
<FieldTypeRenderer
value={value}
record={record}
updateFieldHandler={updateFieldHandler}
isPresetField={collectionInfo.from === 'db2cm' || collectionPresetFieldsInterfaces.includes(record.interface)}
/>
),
},
{
dataIndex: 'titleField',
@@ -134,8 +462,9 @@ const CurrentFields = (props) => {
const handleChange = async (checked) => {
setLoadingRecord(record);
await api.request({
url: `collections:update?filterByTk=${filterByTk}`,
url: `collections:update`,
method: 'post',
params: { filterByTk },
data: { titleField: checked ? record.name : 'id' },
});
ctx?.refresh?.();
@@ -160,7 +489,7 @@ const CurrentFields = (props) => {
},
{
dataIndex: 'description',
title: t('Descriptio '),
title: t('Description'),
render: (value) => <Input.ReadPretty value={value} ellipsis={true} />,
},
{
@@ -192,9 +521,10 @@ const CurrentFields = (props) => {
<Table
rowKey={'name'}
columns={columns}
showHeader={false}
showHeader={true}
pagination={false}
dataSource={props.fields}
className={tableContainer}
rowSelection={{
type: 'checkbox',
// @ts-ignore
@@ -212,7 +542,6 @@ const CurrentFields = (props) => {
});
},
}}
className={indentStyle}
/>
);
};
@@ -244,6 +573,11 @@ const InheritFields = (props) => {
title: t('Field interface'),
render: (value) => <Tag>{compile(getInterface(value)?.title)}</Tag>,
},
{
dataIndex: 'type',
title: t('Field type'),
render: (value, record) => <Tag>{value}</Tag>,
},
{
dataIndex: 'titleField',
title: t('Title field'),
@@ -252,8 +586,9 @@ const InheritFields = (props) => {
setLoadingRecord(record);
await api.request({
url: `collections:update?filterByTk=${filterByTk}`,
url: `collections:update`,
method: 'post',
params: { filterByTk },
data: { titleField: checked ? record.name : 'id' },
});
ctx?.refresh?.();
@@ -275,6 +610,11 @@ const InheritFields = (props) => {
) : null;
},
},
{
dataIndex: 'description',
title: t('Description'),
render: (value) => <Input.ReadPretty value={value} ellipsis={true} />,
},
{
dataIndex: 'actions',
title: t('Actions'),
@@ -305,6 +645,7 @@ const InheritFields = (props) => {
showHeader={false}
pagination={false}
dataSource={props.fields.filter((field) => field.interface)}
className={inheritFieldsContainer}
/>
);
};
@@ -335,6 +676,8 @@ const CollectionFieldsInternal = () => {
<div
className={css`
font-weight: 500;
white-space: nowrap;
min-width: 300px;
`}
>
{value}
@@ -350,12 +693,12 @@ const CollectionFieldsInternal = () => {
title: t('Field interface'),
},
{
dataIndex: 'titleField',
title: t('Title field'),
dataIndex: 'type',
title: t('Field type'),
},
{
dataIndex: 'description',
title: t('Description'),
dataIndex: 'titleField',
title: t('Title field'),
},
{
dataIndex: 'actions',
@@ -363,50 +706,10 @@ const CollectionFieldsInternal = () => {
},
];
const fields = data?.data || [];
const groups = {
pf: [],
association: [],
general: [],
system: [],
};
const allCurrentFields = fields.filter((field) => field.interface || field.primaryKey || field.isForeignKey);
fields.forEach((field) => {
if (field.primaryKey || field.isForeignKey) {
groups.pf.push(field);
} else if (field.interface) {
const conf = getInterface(field.interface);
if (conf?.group === 'systemInfo') {
groups.system.push(field);
} else if (conf?.group === 'relation') {
groups.association.push(field);
} else {
groups.general.push(field);
}
}
});
const dataSource = [];
const dataSource = [
{
key: 'pf',
title: t('PK & FK fields'),
fields: groups.pf,
},
{
key: 'association',
title: t('Association fields'),
fields: groups.association,
},
{
key: 'general',
title: t('General fields'),
fields: groups.general,
},
{
key: 'system',
title: t('System fields'),
fields: groups.system,
},
];
dataSource.push(
...inherits
.map((key) => {
@@ -454,6 +757,7 @@ const CollectionFieldsInternal = () => {
<Action {...deleteProps} />
<SyncFieldsAction {...syncProps} />
<SyncSQLFieldsAction refreshCMList={refreshAsync} />
<SyncFieldChangesAction />
<SchemaComponent
schema={{
type: 'object',
@@ -464,32 +768,38 @@ const CollectionFieldsInternal = () => {
/>
<AddCollectionField {...addProps} />
</Space>
<Table
rowKey={'key'}
columns={columns}
dataSource={dataSource.filter((d) => d.fields.length)}
pagination={false}
className={tableContainer}
expandable={{
defaultExpandAllRows: true,
defaultExpandedRowKeys: dataSource.map((d) => d.key),
expandedRowRender: (record) =>
record.inherit ? (
{allCurrentFields.length > 0 && (
<div>
<CurrentFields
fields={allCurrentFields}
collectionInfo={field.record}
collectionResource={collectionResource}
refreshAsync={refreshAsync}
type="all"
/>
</div>
)}
{dataSource.filter((d) => d.fields.length).length > 0 && (
<Table
rowKey={'key'}
columns={columns}
dataSource={dataSource.filter((d) => d.fields.length)}
pagination={false}
showHeader={false}
className={groupTableContainer}
expandable={{
defaultExpandAllRows: true,
defaultExpandedRowKeys: dataSource.map((d) => d.key),
expandedRowRender: (record) => (
<InheritFields
fields={record.fields}
collectionResource={collectionResource}
refreshAsync={refreshAsync}
/>
) : (
<CurrentFields
fields={record.fields}
collectionResource={collectionResource}
refreshAsync={refreshAsync}
type={record.key}
/>
),
}}
/>
}}
/>
)}
</FieldContext.Provider>
</FormContext.Provider>
);
@@ -16,6 +16,7 @@ import {
DataSourceContext_deprecated,
EditSubFieldAction,
FieldSummary,
ResourceActionContext,
SchemaComponent,
SchemaComponentContext,
TemplateSummary,
@@ -32,6 +33,8 @@ import React, { useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CollectionFields } from './CollectionFields';
import { collectionSchema } from './schemas/collections';
import { useDataSourceRefresh } from '../../../hooks';
import { SyncFromDatabaseAction } from './db-sync/SyncFromDatabaseAction';
/**
* @param service
@@ -114,6 +117,16 @@ export const ConfigurationTable = () => {
const compile = useCompile();
const form = useForm();
const app = useApp();
const service = useContext(ResourceActionContext);
const useRefreshActionProps = () => {
return useDataSourceRefresh({
dataSourceName: 'main',
onSuccess: () => {
service?.refresh?.();
},
});
};
/**
*
@@ -224,6 +237,7 @@ export const ConfigurationTable = () => {
FieldSummary,
TemplateSummay: TemplateSummary,
CollectionFields,
SyncFromDatabaseAction,
}}
scope={{
loadFilterTargetKeys,
@@ -242,6 +256,7 @@ export const ConfigurationTable = () => {
enableInherits: database?.dialect === 'postgres',
isPG: database?.dialect === 'postgres',
getPickerFormat,
useRefreshActionProps,
}}
/>
</SchemaComponentContext.Provider>
@@ -0,0 +1,202 @@
/**
* 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 React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Button, Drawer, Transfer, Input, message, App, Spin } from 'antd';
import { useAPIClient, useDataSource } from '@nocobase/client';
import { useResourceActionContext, useResourceContext } from '@nocobase/client';
import { useDSMTranslation } from '../../../../locale';
interface CollectionItem {
key: string;
title: string;
name: string;
description?: string;
}
interface LoadCollectionProps {
type?: 'default' | 'primary' | 'dashed' | 'link' | 'text';
[key: string]: any;
}
export const LoadCollection: React.FC<LoadCollectionProps> = (props) => {
const { ...restProps } = props;
const { t } = useDSMTranslation();
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [targetKeys, setTargetKeys] = useState<string[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [availableCollections, setAvailableCollections] = useState<CollectionItem[]>([]);
const api = useAPIClient();
const { resource } = useResourceContext();
const { refresh } = useResourceActionContext();
const { modal } = App.useApp();
const ds = useDataSource();
const getSelectableCollections = useCallback(async () => {
try {
const response = await api.resource('dataSources').readTables({
values: { dataSourceKey: 'main' },
});
const collections = response.data?.data || [];
return collections.map((item) => ({
key: item.name,
name: item.name,
title: item.title || item.name,
}));
} catch (error) {
console.error('Failed to fetch selectable tables:', error);
return [];
}
}, [api]);
const initializeData = useCallback(async () => {
setLoading(true);
try {
const selectableCollections = await getSelectableCollections();
setAvailableCollections(selectableCollections);
setTargetKeys([]);
} catch (error) {
message.error(t('Failed to initialize data'));
} finally {
setLoading(false);
}
}, [getSelectableCollections, t]);
const handleSearch = useCallback((direction: 'left' | 'right', value: string) => {
setSearchValue(value);
}, []);
const handleChange = useCallback((newTargetKeys: string[]) => {
setTargetKeys(newTargetKeys);
}, []);
const handleSelectChange = useCallback((sourceSelectedKeys: string[], targetSelectedKeys: string[]) => {
setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys]);
}, []);
const handleSubmit = useCallback(async () => {
if (targetKeys.length === 0) {
message.warning(t('Please select at least one table'));
return;
}
try {
setLoading(true);
modal.confirm({
title: t('Confirm load tables'),
content: t('Are you sure you want to load {{count}} table(s)?', { count: targetKeys.length }),
onOk: async () => {
try {
await api.resource('dataSources').loadTables({
values: {
dataSourceKey: 'main',
tables: targetKeys,
},
});
message.success(t('Tables loaded successfully'));
setOpen(false);
setTargetKeys([]);
setSelectedKeys([]);
setSearchValue('');
refresh();
ds.reload();
} catch (error) {
console.error('Failed to load tables:', error);
message.error(t('Failed to load tables'));
}
},
});
} catch (error) {
message.error(t('Failed to load tables'));
} finally {
setLoading(false);
}
}, [targetKeys, api, t, modal, refresh]);
const handleCancel = useCallback(() => {
setOpen(false);
setTargetKeys([]);
setSelectedKeys([]);
setSearchValue('');
}, []);
const showDrawer = useCallback(() => {
setOpen(true);
initializeData();
}, [initializeData]);
const filteredDataSource = useMemo(() => {
if (!searchValue) return availableCollections;
return availableCollections.filter(
(item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase()) ||
item.name.toLowerCase().includes(searchValue.toLowerCase()) ||
(item.description && item.description.toLowerCase().includes(searchValue.toLowerCase())),
);
}, [availableCollections, searchValue]);
return (
<>
<div onClick={showDrawer} {...restProps}>
{t('Load tables from database')}
</div>
<Drawer
title={t('Load tables from database')}
placement="right"
onClose={handleCancel}
open={open}
width={800}
footer={
<div style={{ textAlign: 'right' }}>
<Button onClick={handleCancel} style={{ marginRight: 8 }}>
{t('Cancel')}
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading} disabled={targetKeys.length === 0}>
{t('Submit')}
</Button>
</div>
}
>
<div style={{ height: 'calc(100vh - 160px)', display: 'flex', flexDirection: 'column' }}>
<Spin spinning={loading} style={{ height: '100%' }}>
<Transfer
dataSource={filteredDataSource}
titles={[t('Available tables'), t('Selected tables')]}
targetKeys={targetKeys}
selectedKeys={selectedKeys}
onChange={handleChange}
onSelectChange={handleSelectChange}
onSearch={handleSearch}
render={(item) => item.title}
showSearch
style={{ height: '100%' }}
listStyle={{
width: 350,
height: 'calc(100vh - 100px)',
}}
locale={{
itemUnit: t('item'),
itemsUnit: t('items'),
searchPlaceholder: t('Search collections...'),
}}
/>
</Spin>
</div>
</Drawer>
</>
);
};
export default LoadCollection;
@@ -0,0 +1,43 @@
/**
* 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 { useAPIClient, useCollectionRecordData, useDataSource, useResourceActionContext } from '@nocobase/client';
import React from 'react';
import { SyncOutlined } from '@ant-design/icons';
import { Button, message } from 'antd';
import { useDSMTranslation } from '../../../../locale';
export const SyncFieldChangesAction: React.FC = () => {
const { t } = useDSMTranslation();
const api = useAPIClient();
const record = useCollectionRecordData();
const ds = useDataSource();
const { refresh } = useResourceActionContext();
if (record.from === 'db2cm' || record.template === 'view' || record.template === 'sql') {
return null;
}
return (
<Button
icon={<SyncOutlined />}
onClick={async () => {
await api.resource('mainDataSource').syncFields({
values: {
collections: [record.name],
},
});
refresh();
ds.reload();
message.success(t('Sync successfully'));
}}
>
{t('Sync from database')}
</Button>
);
};
@@ -0,0 +1,55 @@
/**
* 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 React from 'react';
import { Dropdown, Button, App, message } from 'antd';
import { ImportOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons';
import LoadCollection from './LoadCollectionAction';
import { useAPIClient, useDataSource } from '@nocobase/client';
import { useDSMTranslation } from '../../../../locale';
export const SyncFromDatabaseAction: React.FC = () => {
const { t } = useDSMTranslation();
const api = useAPIClient();
const { modal } = App.useApp();
const ds = useDataSource();
return (
<Dropdown
menu={{
items: [
{
key: 'load-tables',
label: <LoadCollection />,
icon: <ImportOutlined />,
},
{
key: 'sync-fields',
label: t('Sync field changes from database'),
icon: <ReloadOutlined />,
onClick: async () => {
modal.confirm({
title: t('Sync field changes from database'),
content: t('Field synchronization confirmation prompt'),
onOk: async () => {
await api.resource('mainDataSource').syncFields();
ds.reload();
message.success(t('Sync successfully'));
},
});
},
},
],
}}
placement="bottomLeft"
>
<Button icon={<SyncOutlined />}>{t('Sync from database')}</Button>
</Dropdown>
);
};
@@ -159,6 +159,15 @@ export const collectionTableSchema: ISchema = {
},
'x-align': 'left',
},
// refresh: {
// type: 'void',
// title: '{{ t("Refresh") }}',
// 'x-component': 'Action',
// 'x-use-component-props': 'useRefreshActionProps',
// 'x-component-props': {
// icon: 'ReloadOutlined',
// },
// },
delete: {
type: 'void',
title: '{{ t("Delete") }}',
@@ -168,6 +177,10 @@ export const collectionTableSchema: ISchema = {
isBulk: true,
},
},
syncFromDatabase: {
type: 'void',
'x-component': 'SyncFromDatabaseAction',
},
create: {
type: 'void',
title: '{{ t("Create collection") }}',
@@ -7,6 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './useDataSourceRefresh';
export * from './useResourceData';
export * from './useDataSourceActions';
import { useForm, useField } from '@formily/react';
import { message } from 'antd';
import { useTranslation } from 'react-i18next';
@@ -72,31 +76,12 @@ export const useTestConnectionAction = () => {
export const useLoadCollections = () => {
const api = useAPIClient();
return async (key) => {
const { data } = await api.request({
url: `dataSources/${key}/collections:all`,
method: 'get',
const { data } = await api.resource('dataSources').readTables({
values: {
dataSourceKey: key,
},
});
return data;
};
};
export const addDatasourceCollections = async (api, filterByTk, options: { collections; dbOptions }) => {
const url = `dataSources/${filterByTk}/collections:add`;
const { collections: toBeAddedCollections, dbOptions } = options;
if (toBeAddedCollections.length) {
const collections = [];
for (const { name, selected } of toBeAddedCollections) {
if (selected) {
collections.push(name);
}
}
await api.request({
url,
method: 'post',
data: {
dbOptions,
collections,
},
});
}
};
@@ -0,0 +1,79 @@
/**
* 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 { useAPIClient, useDataSourceManager, useResourceActionContext } from '@nocobase/client';
import { useCallback, useEffect } from 'react';
export const useDataSourceActions = () => {
const dm = useDataSourceManager();
const api = useAPIClient();
const { data } = useResourceActionContext();
const handleDataSourcesUpdate = useCallback(
(dataSources: any[]) => {
dataSources?.forEach((dataSourceConfig) => {
const dataSource = dm.getDataSource(dataSourceConfig.key);
if (dataSource) {
// TODO: Implement data source update logic
}
});
},
[dm],
);
useEffect(() => {
if (data?.data) {
handleDataSourcesUpdate(data.data);
}
}, [data, handleDataSourcesUpdate]);
const actions = {
reloadAllDataSources: useCallback(async () => {
const dataSources = dm.getDataSources();
await Promise.all(dataSources.map((ds) => ds.reload()));
}, [dm]),
reloadDataSource: useCallback(
async (key: string) => {
const dataSource = dm.getDataSource(key);
if (dataSource) {
await dataSource.reload();
}
},
[dm],
),
getDataSourceCollections: useCallback(
(key: string) => {
const dataSource = dm.getDataSource(key);
return dataSource?.collectionManager.getCollections() || [];
},
[dm],
),
testConnection: useCallback(
async (key: string) => {
try {
await api.request({
resource: 'dataSources',
action: 'testConnection',
params: { values: { key } },
});
return true;
} catch (error) {
console.error(`Failed to test connection for ${key}:`, error);
return false;
}
},
[api],
),
};
return actions;
};
@@ -0,0 +1,78 @@
/**
* 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 { useField } from '@formily/react';
import { ResourceActionContext, useAPIClient, useDataSourceManager } from '@nocobase/client';
import { message } from 'antd';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { DataSourceContext } from '../DatabaseConnectionProvider';
export interface UseDataSourceRefreshOptions {
dataSourceName: string;
onSuccess?: () => void;
onError?: (error: any) => void;
showMessage?: boolean;
}
export const useDataSourceRefresh = (options: UseDataSourceRefreshOptions) => {
const { dataSourceName, onSuccess, onError, showMessage = true } = options;
const { t } = useTranslation();
const service = useContext(ResourceActionContext);
const api = useAPIClient();
const field = useField();
const ds = useDataSourceManager();
const { setDataSource, dataSource } = useContext(DataSourceContext);
field.data = field.data || {};
return {
async onClick() {
field.data.loading = true;
try {
const { data } = await api.request({
url: `${
dataSourceName === 'main' ? 'mainDataSource' : 'dataSources'
}:refresh?filterByTk=${dataSourceName}&clientStatus=${dataSource?.status || 'loaded'}`,
method: 'post',
});
field.data.loading = false;
setDataSource({ ...data?.data, name: dataSourceName });
if (data?.data?.status === 'reloading') {
if (showMessage) {
message.warning(t('Data source synchronization in progress'));
}
} else if (data?.data?.status === 'loaded') {
if (showMessage) {
message.success(t('Data source synchronization successful'));
}
service?.refresh?.();
}
await ds.getDataSource(dataSourceName).reload();
onSuccess?.();
} catch (error) {
field.data.loading = false;
if (showMessage) {
message.error(t('Data source synchronization failed'));
}
onError?.(error);
}
},
get loading() {
return field.data.loading;
},
};
};
@@ -0,0 +1,89 @@
/**
* 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 { useAPIClient } from '@nocobase/client';
import { useEffect, useState } from 'react';
export interface UseResourceDataOptions {
resourceName: string;
resourceParams?: string | string[];
getParams?: Record<string, any>;
deps?: any[];
enabled?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export const useResourceData = (options: UseResourceDataOptions) => {
const { resourceName, resourceParams, getParams = {}, deps = [], enabled = true, onSuccess, onError } = options;
const api = useAPIClient();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<any>(null);
const resource = resourceParams ? api.resource(resourceName, resourceParams) : api.resource(resourceName);
const fetchData = async () => {
if (!enabled) return;
setLoading(true);
setError(null);
try {
const response = await resource.get(getParams);
const responseData = response?.data;
setData(responseData);
onSuccess?.(responseData);
} catch (err) {
setError(err);
onError?.(err);
console.error(`Failed to fetch ${resourceName} data:`, err);
} finally {
setLoading(false);
}
};
const refresh = () => {
fetchData();
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceName, enabled, ...deps]);
return {
data,
loading,
error,
refresh,
};
};
export const useDataSourceData = (
dataSourceName: string,
options: Omit<UseResourceDataOptions, 'resourceName' | 'resourceParams' | 'getParams'> & {
getParams?: Record<string, any>;
} = {},
) => {
const { getParams = {}, ...restOptions } = options;
return useResourceData({
resourceName: 'dataSources',
resourceParams: dataSourceName,
getParams: {
filterByTk: dataSourceName,
...getParams,
},
deps: [dataSourceName],
...restOptions,
});
};
@@ -150,6 +150,7 @@ export const databaseConnectionSchema: ISchema = {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
onSuccess: '{{ dataSourceListCallback }}',
},
properties: {
key: {
@@ -0,0 +1,7 @@
{
"Field synchronization confirmation prompt": "Are you sure you want to synchronize fields from database? Depends on the number of tables and fields, This operation may take anywhere from a few seconds to several dozen seconds. If you only need to sync specific tables, please use the \"Configure Fields\" - \"Sync from database\" for those tables.",
"Load tables from database": "Load tables from database",
"Sync field changes from database": "Sync field changes from database",
"Available tables": "Available tables",
"Selected tables": "Selected tables"
}
@@ -30,11 +30,16 @@
"Reloading": "加载中",
"Data source synchronization in progress": "数据源同步中",
"Data source synchronization successful": "数据源同步成功",
"Filter target key":"筛选目标键",
"Filter target key": "筛选目标键",
"Select field": "选择字段",
"OK": "确定",
"Please select a field.": "请选择一个字段。",
"Are you sure you want to set the \"{{title}}\" field as a record unique key? This setting cannot be changed after it's been set.": "你确定将 “{{title}}” 字段设置为主键吗?设置成功后不可修改。",
"If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.": "当数据表没有主键时,你需要配置记录唯一标识符,用于在区块中定位行记录,不配置将无法创建该表的数据区块。",
"Filter data based on the specific field, with the requirement that the field value must be unique.": "根据特定的字段筛选数据,字段值必须具备唯一性。"
"Filter data based on the specific field, with the requirement that the field value must be unique.": "根据特定的字段筛选数据,字段值必须具备唯一性。",
"Field synchronization confirmation prompt": "确认要对所有数据表执行字段同步吗?取决于数据表和字段数量,可能会耗时几秒到几十秒。若只需同步个别表,请使用相应表的“配置字段” - “从数据库同步”进行操作。",
"Load tables from database": "从数据库加载表",
"Sync field changes from database": "从数据库同步字段变更",
"Available tables": "可选数据表",
"Selected tables": "已选数据表"
}
@@ -0,0 +1,117 @@
/**
* 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 { DataSourceModel } from '../models/data-source';
import { mapDataSourceWithCollection } from '../utils';
import { DataSourceManager, DatabaseDataSource } from '@nocobase/data-source-manager';
const canRefreshStatus = ['loaded', 'loading-failed', 'reloading-failed'];
export default {
async ['dataSources:listEnabled'](ctx: Context, next: Next) {
const dataSources = await ctx.db.getRepository('dataSources').find({
filter: {
enabled: true,
'type.$ne': 'main',
},
});
ctx.body = dataSources.map((dataSourceModel: DataSourceModel) => {
return mapDataSourceWithCollection(ctx.app, dataSourceModel);
});
await next();
},
async ['dataSources:testConnection'](ctx: Context, next: Next) {
const { values } = ctx.action.params;
const { options, type } = values;
const klass = ctx.app.dataSourceManager.factory.getClass(type);
try {
await klass.testConnection(ctx.app.environment.renderJsonTemplate(options));
} catch (error) {
throw new Error(`Test connection failed: ${error.message}`);
}
ctx.body = {
success: true,
};
await next();
},
async ['dataSources:refresh'](ctx: Context, next: Next) {
const plugin = ctx.app.pm.get('data-source-manager');
const { filterByTk, clientStatus } = ctx.action.params;
const dataSourceModel: DataSourceModel = await ctx.db.getRepository('dataSources').findOne({
filter: {
key: filterByTk,
},
});
const currentStatus = plugin.dataSourceStatus[filterByTk];
if (
canRefreshStatus.includes(currentStatus) &&
(clientStatus ? clientStatus && canRefreshStatus.includes(clientStatus) : true)
) {
dataSourceModel.loadIntoApplication({
app: ctx.app,
refresh: true,
});
ctx.app.syncMessageManager.publish(plugin.name, {
type: 'loadDataSource',
dataSourceKey: dataSourceModel.get('key'),
});
}
ctx.body = {
status: plugin.dataSourceStatus[filterByTk],
};
await next();
},
async ['dataSources:readTables'](ctx: Context, next: Next) {
const { dataSourceKey, dbOptions } = ctx.action.params.values || {};
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
let dataSource: DatabaseDataSource;
if (dbOptions) {
dataSource = dataSourceManager.factory.create(dbOptions.type, {
name: dataSourceKey,
...dbOptions,
}) as DatabaseDataSource;
} else {
dataSource = dataSourceManager.dataSources.get(dataSourceKey) as DatabaseDataSource;
if (!dataSource) {
throw new Error(`dataSource ${dataSourceKey} not found`);
}
}
const tables = await dataSource.readTables();
ctx.body = tables;
await next();
},
async ['dataSources:loadTables'](ctx: Context, next: Next) {
const { dataSourceKey, tables } = ctx.action.params.values || {};
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
const dataSource = dataSourceManager.dataSources.get(dataSourceKey) as DatabaseDataSource;
if (!dataSource) {
throw new Error(`dataSource ${dataSourceKey} not found`);
}
await dataSource.loadTables(ctx, tables);
await next();
},
};
@@ -0,0 +1,50 @@
/**
* 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 { DatabaseDataSource } from '@nocobase/data-source-manager';
import { ALLOW_MAX_COLLECTIONS_COUNT } from '../constants';
export async function loadDataSourceTablesIntoCollections(ctx: Context, next: Next) {
const { actionName, resourceName, params } = ctx.action;
if (resourceName === 'dataSources' && (actionName === 'create' || actionName === 'update')) {
const { options, type, collections, key } = params.values || {};
let dataSource: DatabaseDataSource;
if (actionName === 'create') {
dataSource = ctx.app.dataSourceManager.factory.create(type, {
name: key,
...options,
});
dataSource.setLogger(ctx.logger);
} else {
const { filterByTk: dataSourceKey } = params;
dataSource = ctx.app.dataSourceManager.dataSources.get(dataSourceKey);
if (!dataSource) {
throw new Error(`dataSource ${dataSourceKey} not found`);
}
}
if (!(dataSource instanceof DatabaseDataSource)) {
return next();
}
if (options?.addAllCollections) {
const allTables = await dataSource.introspector.getTables();
if (allTables.length > ALLOW_MAX_COLLECTIONS_COUNT) {
throw new Error(
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
);
}
} else {
await dataSource.loadTables(ctx, collections);
}
if (collections) {
delete ctx.action.params.values.collections;
}
}
await next();
}
@@ -8,7 +8,6 @@
*/
import { Application, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { DataSourcesCollectionModel } from './models/data-sources-collection-model';
import { DataSourcesFieldModel } from './models/data-sources-field-model';
import remoteCollectionsResourcer from './resourcers/data-sources-collections';
@@ -16,20 +15,18 @@ import remoteFieldsResourcer from './resourcers/data-sources-collections-fields'
import rolesConnectionResourcesResourcer from './resourcers/data-sources-resources';
import databaseConnectionsRolesResourcer from './resourcers/data-sources-roles';
import { rolesRemoteCollectionsResourcer } from './resourcers/roles-data-sources-collections';
import { DataSourceManager, LoadingProgress } from '@nocobase/data-source-manager';
import dataSourcesActions from './actions/data-sources';
import { LoadingProgress } from '@nocobase/data-source-manager';
import lodash from 'lodash';
import { DataSourcesRolesResourcesModel } from './models/connections-roles-resources';
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
import { DataSourceModel } from './models/data-source';
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
import { mergeRole } from '@nocobase/acl';
import { ALLOW_MAX_COLLECTIONS_COUNT } from './constants';
import { loadDataSourceTablesIntoCollections } from './middlewares/load-tables';
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
const canRefreshStatus = ['loaded', 'loading-failed', 'reloading-failed'];
export class PluginDataSourceManagerServer extends Plugin {
public dataSourceErrors: {
[dataSourceKey: string]: Error;
@@ -357,43 +354,7 @@ export class PluginDataSourceManagerServer extends Plugin {
await next();
});
this.app.resourceManager.use(async function verifyDatasourceCollectionsCount(ctx, next) {
if (!ctx.action) {
await next();
return;
}
const { actionName, resourceName, params } = ctx.action;
if (resourceName === 'dataSources' && (actionName === 'add' || actionName === 'update')) {
const { values, filterByTk: dataSourceKey } = params;
if (values.options?.addAllCollections) {
let introspector: { getCollections: () => Promise<string[]> } = null;
const dataSourceManager = ctx.app['dataSourceManager'] as DataSourceManager;
if (actionName === 'add') {
const klass = dataSourceManager.factory.getClass(values.options.type);
// @ts-ignore
const dataSource = new klass(dbOptions);
introspector = dataSource.collectionManager.dataSource.createDatabaseIntrospector(
dataSource.collectionManager.db,
);
} else {
const dataSource = dataSourceManager.dataSources.get(dataSourceKey);
if (!dataSource) {
throw new Error(`dataSource ${dataSourceKey} not found`);
}
introspector = dataSource['introspector'];
}
const allCollections = await introspector.getCollections();
if (allCollections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
throw new Error(
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
);
}
}
}
await next();
});
this.app.resourceManager.use(loadDataSourceTablesIntoCollections);
this.app.use(async function handleAppendDataSourceCollection(ctx, next) {
await next();
@@ -419,83 +380,14 @@ export class PluginDataSourceManagerServer extends Plugin {
});
const self = this;
this.app.actions({
async ['dataSources:listEnabled'](ctx, next) {
const dataSources = await ctx.db.getRepository('dataSources').find({
filter: {
enabled: true,
'type.$ne': 'main',
},
});
this.app.resourceManager.registerActionHandlers(dataSourcesActions);
ctx.body = dataSources.map((dataSourceModel) => {
return mapDataSourceWithCollection(dataSourceModel);
});
await next();
},
async ['dataSources:testConnection'](ctx, next) {
const { values } = ctx.action.params;
const { options, type } = values;
const klass = ctx.app.dataSourceManager.factory.getClass(type);
try {
await klass.testConnection(self.renderJsonTemplate(options));
} catch (error) {
throw new Error(`Test connection failed: ${error.message}`);
}
ctx.body = {
success: true,
};
await next();
},
async ['dataSources:refresh'](ctx, next) {
const { filterByTk, clientStatus } = ctx.action.params;
const dataSourceModel: DataSourceModel = await ctx.db.getRepository('dataSources').findOne({
filter: {
key: filterByTk,
},
});
const currentStatus = plugin.dataSourceStatus[filterByTk];
if (
canRefreshStatus.includes(currentStatus) &&
(clientStatus ? clientStatus && canRefreshStatus.includes(clientStatus) : true)
) {
dataSourceModel.loadIntoApplication({
app: ctx.app,
refresh: true,
});
ctx.app.syncMessageManager.publish(self.name, {
type: 'loadDataSource',
dataSourceKey: dataSourceModel.get('key'),
});
}
ctx.body = {
status: plugin.dataSourceStatus[filterByTk],
};
await next();
},
});
this.app.resourcer.define(remoteCollectionsResourcer);
this.app.resourcer.define(remoteFieldsResourcer);
this.app.resourcer.define(rolesRemoteCollectionsResourcer);
this.app.resourcer.define(databaseConnectionsRolesResourcer);
this.app.resourcer.define(rolesConnectionResourcesResourcer);
this.app.resourcer.define({
this.app.resourceManager.define(remoteCollectionsResourcer);
this.app.resourceManager.define(remoteFieldsResourcer);
this.app.resourceManager.define(rolesRemoteCollectionsResourcer);
this.app.resourceManager.define(databaseConnectionsRolesResourcer);
this.app.resourceManager.define(rolesConnectionResourcesResourcer);
this.app.resourceManager.define({
name: 'dataSources',
});
@@ -815,9 +707,7 @@ export class PluginDataSourceManagerServer extends Plugin {
});
}
async load() {
await this.importCollections(resolve(__dirname, 'collections'));
}
async load() {}
}
export default PluginDataSourceManagerServer;
@@ -10,8 +10,6 @@
import lodash from 'lodash';
import { filterMatch } from '@nocobase/database';
import _ from 'lodash';
import { DataSourceManager } from '@nocobase/data-source-manager';
import { ALLOW_MAX_COLLECTIONS_COUNT } from '../constants';
export default {
name: 'dataSources.collections',
@@ -128,90 +126,5 @@ export default {
await next();
},
async all(ctx, next) {
const params = ctx.action.params;
const { associatedIndex: dataSourceKey, isFirst, dbOptions } = params;
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
let introspector: { getCollections: () => Promise<string[]> } = null;
if (isFirst) {
const klass = dataSourceManager.factory.getClass(dbOptions.type);
// @ts-ignore
const dataSource = new klass(dbOptions);
introspector = dataSource.collectionManager.dataSource.createDatabaseIntrospector(
dataSource.collectionManager.db,
);
} else {
const dataSource = dataSourceManager.dataSources.get(dataSourceKey);
if (!dataSource) {
throw new Error(`dataSource ${dataSourceKey} not found`);
}
introspector = dataSource['introspector'];
}
const allCollections = await introspector.getCollections();
const selectedCollections = await ctx.db.getRepository('dataSourcesCollections').find({
filter: { dataSourceKey },
});
const selectedMap = _.keyBy(selectedCollections, (x) => x.name);
const result = allCollections.map((collection) => {
return {
name: collection,
selected: !!selectedMap[collection],
};
});
ctx.body = result;
await next();
},
async add(ctx, next) {
const params = ctx.action.params;
const { associatedIndex: dataSourceKey, values } = params;
const collections = values.collections || [];
const dbOptions = values.dbOptions || {};
if (dbOptions.addAllCollections !== false) {
await next();
return;
}
if (collections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
throw new Error(
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
);
}
const transaction = await ctx.db.sequelize.transaction();
const repo = ctx.db.getRepository('dataSourcesCollections');
const alreadyInserted = await repo.find({
filter: {
dataSourceKey,
},
transaction,
});
const alreadyInsertedNames = _.keyBy(alreadyInserted, (x) => x.name);
const incomingCollections = _.keyBy(collections);
const toBeInserted = collections.filter((collection) => !alreadyInsertedNames[collection]);
const toBeDeleted = Object.keys(alreadyInsertedNames).filter((name) => !incomingCollections[name]);
if (toBeInserted.length > 0) {
const insertCollections = toBeInserted.map((collection) => {
return { name: collection, dataSourceKey };
});
await repo.model.bulkCreate(insertCollections, { transaction });
}
if (toBeDeleted.length > 0) {
await repo.model.destroy({
where: {
dataSourceKey,
name: toBeDeleted,
},
transaction,
});
}
await transaction.commit();
const dataSource = ctx.app.dataSourceManager.dataSources.get(dataSourceKey);
if (dataSource) {
await dataSource.load({ refresh: true, condition: toBeInserted ? { name: { $in: toBeInserted } } : {} });
}
ctx.body = true;
await next();
},
},
};
@@ -7,6 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import _ from 'lodash';
import { DataSourceModel } from './models/data-source';
import { Application } from '@nocobase/server';
import PluginDataSourceManagerServer from './plugin';
export function mergeOptions(fieldOptions, modelOptions) {
const newOptions = {
@@ -21,3 +24,64 @@ export function mergeOptions(fieldOptions, modelOptions) {
}
return newOptions;
}
export const mapDataSourceWithCollection = (
app: Application,
dataSourceModel: DataSourceModel,
appendCollections = true,
) => {
const plugin = app.pm.get('data-source-manager') as PluginDataSourceManagerServer;
const dataSource = app.dataSourceManager.dataSources.get(dataSourceModel.get('key'));
const dataSourceStatus = plugin.dataSourceStatus[dataSourceModel.get('key')];
const item: any = {
key: dataSourceModel.get('key'),
displayName: dataSourceModel.get('displayName'),
status: dataSourceStatus,
type: dataSourceModel.get('type'),
// @ts-ignore
isDBInstance: !!dataSource?.collectionManager.db,
};
const publicOptions = dataSource?.publicOptions();
if (publicOptions) {
item['options'] = publicOptions;
}
if (dataSourceStatus === 'loading-failed' || dataSourceStatus === 'reloading-failed') {
item['errorMessage'] = plugin.dataSourceErrors[dataSourceModel.get('key')].message;
}
if (!dataSource) {
return item;
}
if (appendCollections) {
const collections = dataSource.collectionManager.getCollections();
item.collections = collections.map((collection) => {
const collectionOptions = collection.options;
const collectionInstance = dataSource.collectionManager.getCollection(collectionOptions.name);
const fields = [...collection.fields.values()].map((field) => field.options);
const results = {
...collectionOptions,
fields,
};
if (collectionInstance && collectionInstance.availableActions) {
results['availableActions'] = collectionInstance.availableActions();
}
if (collectionInstance && collectionInstance.unavailableActions) {
results['unavailableActions'] = collectionInstance.unavailableActions();
}
return results;
});
}
return item;
};
+18 -23
View File
@@ -6746,7 +6746,7 @@
"@npmcli/agent@^2.0.0":
version "2.2.2"
resolved "https://registry.npmmirror.com/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5"
resolved "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5"
integrity sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==
dependencies:
agent-base "^7.1.0"
@@ -6770,7 +6770,7 @@
"@npmcli/fs@^3.1.0":
version "3.1.1"
resolved "https://registry.npmmirror.com/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726"
resolved "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726"
integrity sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==
dependencies:
semver "^7.3.5"
@@ -10880,7 +10880,7 @@ abbrev@1:
abbrev@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf"
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf"
integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==
abort-controller-x@^0.4.0, abort-controller-x@^0.4.3:
@@ -12917,7 +12917,7 @@ cacache@^15.0.5, cacache@^15.2.0:
cacache@^18.0.0:
version "18.0.4"
resolved "https://registry.npmmirror.com/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5"
resolved "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5"
integrity sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==
dependencies:
"@npmcli/fs" "^3.1.0"
@@ -18664,7 +18664,7 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0:
fs-minipass@^3.0.0:
version "3.0.3"
resolved "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54"
resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54"
integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==
dependencies:
minipass "^7.0.3"
@@ -20265,7 +20265,7 @@ http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0:
http-cache-semantics@^4.1.1:
version "4.2.0"
resolved "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5"
resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5"
integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==
http-deceiver@^1.2.7:
@@ -21690,11 +21690,6 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
isexe@^3.1.1:
version "3.1.1"
resolved "https://registry.npmmirror.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d"
integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==
isobject@^0.2.0:
version "0.2.0"
resolved "https://registry.npmmirror.com/isobject/-/isobject-0.2.0.tgz#a3432192f39b910b5f02cc989487836ec70aa85e"
@@ -23443,7 +23438,7 @@ make-error@^1.1.1:
make-fetch-happen@^13.0.0:
version "13.0.1"
resolved "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36"
resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36"
integrity sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==
dependencies:
"@npmcli/agent" "^2.0.0"
@@ -24956,7 +24951,7 @@ minipass-collect@^1.0.2:
minipass-collect@^2.0.1:
version "2.0.1"
resolved "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863"
resolved "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863"
integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==
dependencies:
minipass "^7.0.3"
@@ -24974,7 +24969,7 @@ minipass-fetch@^1.3.0, minipass-fetch@^1.3.2:
minipass-fetch@^3.0.0:
version "3.0.5"
resolved "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c"
resolved "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c"
integrity sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==
dependencies:
minipass "^7.0.3"
@@ -25553,7 +25548,7 @@ node-fetch@^3.2.0:
node-gyp@^10.2.0:
version "10.3.1"
resolved "https://registry.npmmirror.com/node-gyp/-/node-gyp-10.3.1.tgz#1dd1a1a1c6c5c59da1a76aea06a062786b2c8a1a"
resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz#1dd1a1a1c6c5c59da1a76aea06a062786b2c8a1a"
integrity sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==
dependencies:
env-paths "^2.2.0"
@@ -25710,7 +25705,7 @@ nopt@^5.0.0:
nopt@^7.0.0:
version "7.2.1"
resolved "https://registry.npmmirror.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
resolved "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==
dependencies:
abbrev "^2.0.0"
@@ -26411,7 +26406,7 @@ ora@^5.4.1:
oracledb@6.5.1:
version "6.5.1"
resolved "https://registry.npmmirror.com/oracledb/-/oracledb-6.5.1.tgz#814d985035acdb1a6470b1152af0ca3767569ede"
resolved "https://registry.npmjs.org/oracledb/-/oracledb-6.5.1.tgz#814d985035acdb1a6470b1152af0ca3767569ede"
integrity sha512-JzoSGei1wnvmqgKnAZK1W650mzHTZXx+7hClV4mwsbY/ZjUtrpnojNJMYJ2jkOhj7XG5oJPfXc4GqDKaNzkxqg==
ordered-read-streams@^1.0.0:
@@ -28354,7 +28349,7 @@ prismjs@~1.27.0:
proc-log@^4.1.0, proc-log@^4.2.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034"
resolved "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034"
integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==
process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
@@ -31580,7 +31575,7 @@ socks-proxy-agent@^8.0.2:
socks-proxy-agent@^8.0.3:
version "8.0.5"
resolved "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee"
resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee"
integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==
dependencies:
agent-base "^7.1.2"
@@ -31605,7 +31600,7 @@ socks@^2.3.3, socks@^2.6.2, socks@^2.7.1:
socks@^2.8.3:
version "2.8.7"
resolved "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
resolved "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==
dependencies:
ip-address "^10.0.1"
@@ -31888,7 +31883,7 @@ sshpk@^1.7.0:
ssri@^10.0.0:
version "10.0.6"
resolved "https://registry.npmmirror.com/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5"
resolved "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5"
integrity sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==
dependencies:
minipass "^7.0.3"
@@ -34068,7 +34063,7 @@ unique-filename@^1.1.0, unique-filename@^1.1.1:
unique-filename@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==
dependencies:
unique-slug "^4.0.0"
@@ -34082,7 +34077,7 @@ unique-slug@^2.0.0:
unique-slug@^4.0.0:
version "4.0.0"
resolved "https://registry.npmmirror.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3"
resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3"
integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==
dependencies:
imurmurhash "^0.1.4"