((props) => {
leading={headerLeading ?? {i18n('common.title.executiveLogging')}}
trailing={}
/>
+
{
flex: 1;
height: 0px;
`,
+ outputFilters: css`
+ flex: 0 0 auto;
+ padding: 8px 12px;
+ border-bottom: 1px solid ${token.colorBorderSecondary};
+ background: ${token.colorBgContainer};
+ `,
outputItem: css`
position: relative;
display: flex;
@@ -28,9 +34,7 @@ export const useStyles = createStyles(({ css, token }) => {
border-radius: 6px;
background: ${token.colorFillQuaternary};
cursor: pointer;
- transition:
- border-color 0.16s ease,
- background-color 0.16s ease;
+ transition: border-color 0.16s ease, background-color 0.16s ease;
&:hover {
border-color: ${token.colorPrimaryBorder};
diff --git a/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts
new file mode 100644
index 0000000000..402bd6bf03
--- /dev/null
+++ b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts
@@ -0,0 +1,199 @@
+import assert from 'node:assert/strict';
+import { TreeNodeType } from '@/constants/tree';
+import type { TreeNodeData } from '@/typings/tree';
+import {
+ createSelectDatabaseRequestLifecycle,
+ disposeSelectDatabaseRequests,
+ hasApplicableDatabaseNameChange,
+ invalidateDatabaseOptionRequests,
+ invalidateDataSourceOptionRequests,
+ normalizeDatabaseOptions,
+ normalizeSchemaOptions,
+ runDatabaseOptionRequest,
+ runSchemaOptionRequest,
+} from './selectDatabaseRequestLifecycle';
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((done, fail) => {
+ resolve = done;
+ reject = fail;
+ });
+ return { promise, reject, resolve };
+}
+
+function treeNode(
+ treeNodeType: TreeNodeType,
+ originalTitle: string,
+ extraParams: TreeNodeData['extraParams'],
+): TreeNodeData {
+ return {
+ key: `${treeNodeType}-${originalTitle}`,
+ originalTitle,
+ treeNodeType,
+ extraParams,
+ };
+}
+
+async function testLatestDataSourceDatabaseRequestWins() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const firstResponse = deferred();
+ const secondResponse = deferred();
+ const committedOptions: string[][] = [];
+ const firstRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => firstResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const secondRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => secondResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ secondResponse.resolve(['new-source-database']);
+ await secondRequest;
+ firstResponse.reject(new Error('stale source request failed'));
+ await firstRequest;
+
+ assert.deepEqual(committedOptions, [['new-source-database']]);
+}
+
+async function testLatestDatabaseSchemaRequestWins() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const firstResponse = deferred();
+ const secondResponse = deferred();
+ const committedOptions: string[][] = [];
+ const firstRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => firstResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ invalidateDatabaseOptionRequests(lifecycle);
+ const secondRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => secondResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ secondResponse.resolve(['new-database-schema']);
+ await secondRequest;
+ firstResponse.resolve(['stale-database-schema']);
+ await firstRequest;
+
+ assert.deepEqual(committedOptions, [['new-database-schema']]);
+}
+
+async function testParentClearInvalidatesBothDependentLevels() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const databaseResponse = deferred();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const databaseRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => databaseResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ invalidateDataSourceOptionRequests(lifecycle);
+ databaseResponse.resolve(['stale-database']);
+ schemaResponse.resolve(['stale-schema']);
+ await Promise.all([databaseRequest, schemaRequest]);
+
+ assert.deepEqual(committedOptions, []);
+}
+
+async function testUnmountInvalidatesBothDependentLevels() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const databaseResponse = deferred();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const databaseRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => databaseResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ disposeSelectDatabaseRequests(lifecycle);
+ databaseResponse.resolve(['unmounted-database']);
+ schemaResponse.resolve(['unmounted-schema']);
+ await Promise.all([databaseRequest, schemaRequest]);
+
+ assert.deepEqual(committedOptions, []);
+}
+
+async function testSchemaOnlyFullInitializationKeepsSchemaRequest() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const initData = {
+ dataSourceId: 1,
+ databaseName: undefined,
+ schemaName: 'PUBLIC',
+ };
+
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ if (hasApplicableDatabaseNameChange(initData, false)) {
+ invalidateDatabaseOptionRequests(lifecycle);
+ }
+
+ schemaResponse.resolve(['PUBLIC', 'AUDIT']);
+ await schemaRequest;
+
+ assert.deepEqual(committedOptions, [['PUBLIC', 'AUDIT']]);
+}
+
+function testOnlyRealNamedDatabaseAndSchemaNodesBecomeOptions() {
+ const databaseNodes = [
+ treeNode(TreeNodeType.DATABASE, 'orders', { databaseName: 'orders' }),
+ treeNode(TreeNodeType.DATABASE_ACCOUNTS, 'Database Accounts', { databaseName: 'inherited-name' }),
+ treeNode(TreeNodeType.AI_DATA_COLLECTIONS, 'AI data collections', {}),
+ treeNode(TreeNodeType.DATABASE, 'blank database', { databaseName: ' ' }),
+ treeNode(TreeNodeType.DATABASE, 'duplicate orders', { databaseName: 'orders' }),
+ ];
+ assert.deepEqual(normalizeDatabaseOptions(databaseNodes), [{ value: 'orders', label: 'orders' }]);
+
+ const schemaNodes = [
+ treeNode(TreeNodeType.SCHEMA, 'public', { schemaName: 'public' }),
+ treeNode(TreeNodeType.TABLES, 'Tables', {}),
+ treeNode(TreeNodeType.SCHEMA, 'blank schema', { schemaName: '' }),
+ ];
+ assert.deepEqual(normalizeSchemaOptions(schemaNodes), [{ value: 'public', label: 'public' }]);
+}
+
+async function run() {
+ await testLatestDataSourceDatabaseRequestWins();
+ await testLatestDatabaseSchemaRequestWins();
+ await testParentClearInvalidatesBothDependentLevels();
+ await testUnmountInvalidatesBothDependentLevels();
+ await testSchemaOnlyFullInitializationKeepsSchemaRequest();
+ testOnlyRealNamedDatabaseAndSchemaNodesBecomeOptions();
+ console.log('Select database request lifecycle tests passed');
+}
+
+void run();
diff --git a/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts
new file mode 100644
index 0000000000..00b8345a31
--- /dev/null
+++ b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts
@@ -0,0 +1,148 @@
+import { TreeNodeType } from '@/constants/tree';
+import type { TreeNodeData } from '@/typings/tree';
+import {
+ beginLatestRequest,
+ invalidateLatestRequest,
+ isLatestRequest,
+ type RequestGenerationRef,
+} from '@/utils/latestRequest';
+
+export interface SelectDatabaseOption {
+ value: string;
+ label: string;
+}
+
+interface LatestOptionRequest {
+ active: boolean;
+ generationRef: RequestGenerationRef;
+}
+
+export interface SelectDatabaseRequestLifecycle {
+ database: LatestOptionRequest;
+ schema: LatestOptionRequest;
+}
+
+export function hasApplicableDatabaseNameChange(changedValues: { databaseName?: string }, supportDatabase?: boolean) {
+ return supportDatabase !== false && 'databaseName' in changedValues;
+}
+
+function createLatestOptionRequest(): LatestOptionRequest {
+ return {
+ active: true,
+ generationRef: { current: 0 },
+ };
+}
+
+export function createSelectDatabaseRequestLifecycle(): SelectDatabaseRequestLifecycle {
+ return {
+ database: createLatestOptionRequest(),
+ schema: createLatestOptionRequest(),
+ };
+}
+
+function activateLatestOptionRequest(request: LatestOptionRequest) {
+ request.active = true;
+}
+
+function invalidateLatestOptionRequest(request: LatestOptionRequest) {
+ invalidateLatestRequest(request.generationRef);
+}
+
+function disposeLatestOptionRequest(request: LatestOptionRequest) {
+ request.active = false;
+ invalidateLatestOptionRequest(request);
+}
+
+export function activateSelectDatabaseRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ activateLatestOptionRequest(lifecycle.database);
+ activateLatestOptionRequest(lifecycle.schema);
+}
+
+export function invalidateDataSourceOptionRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ invalidateLatestOptionRequest(lifecycle.database);
+ invalidateLatestOptionRequest(lifecycle.schema);
+}
+
+export function invalidateDatabaseOptionRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ invalidateLatestOptionRequest(lifecycle.schema);
+}
+
+export function disposeSelectDatabaseRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ disposeLatestOptionRequest(lifecycle.database);
+ disposeLatestOptionRequest(lifecycle.schema);
+}
+
+async function runLatestOptionRequest(
+ request: LatestOptionRequest,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ if (!request.active) {
+ return false;
+ }
+
+ const generation = beginLatestRequest(request.generationRef);
+ try {
+ const value = await load();
+ if (!request.active || !isLatestRequest(request.generationRef, generation)) {
+ return false;
+ }
+ onSuccess(value);
+ return true;
+ } catch {
+ if (!request.active || !isLatestRequest(request.generationRef, generation)) {
+ return false;
+ }
+ onError();
+ return true;
+ }
+}
+
+export function runDatabaseOptionRequest(
+ lifecycle: SelectDatabaseRequestLifecycle,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ return runLatestOptionRequest(lifecycle.database, load, onSuccess, onError);
+}
+
+export function runSchemaOptionRequest(
+ lifecycle: SelectDatabaseRequestLifecycle,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ return runLatestOptionRequest(lifecycle.schema, load, onSuccess, onError);
+}
+
+function normalizeNamedOptions(
+ nodes: TreeNodeData[],
+ expectedNodeType: TreeNodeType,
+ field: 'databaseName' | 'schemaName',
+): SelectDatabaseOption[] {
+ const seenValues = new Set();
+
+ return nodes.reduce((options, node) => {
+ const value = node.extraParams?.[field];
+ if (node.treeNodeType !== expectedNodeType || typeof value !== 'string' || !value.trim() || seenValues.has(value)) {
+ return options;
+ }
+
+ seenValues.add(value);
+ options.push({
+ value,
+ label: node.originalTitle?.trim() || value,
+ });
+ return options;
+ }, []);
+}
+
+export function normalizeDatabaseOptions(nodes: TreeNodeData[]) {
+ return normalizeNamedOptions(nodes, TreeNodeType.DATABASE, 'databaseName');
+}
+
+export function normalizeSchemaOptions(nodes: TreeNodeData[]) {
+ return normalizeNamedOptions(nodes, TreeNodeType.SCHEMA, 'schemaName');
+}
diff --git a/chat2db-community-client/src/hooks/useSelectDatabase.ts b/chat2db-community-client/src/hooks/useSelectDatabase.ts
index a2aa06878f..5926677bb3 100644
--- a/chat2db-community-client/src/hooks/useSelectDatabase.ts
+++ b/chat2db-community-client/src/hooks/useSelectDatabase.ts
@@ -1,8 +1,21 @@
-import { useState, useMemo, useEffect } from 'react';
import { normalizeTreeNodeLoadResult, treeConfig } from '@/blocks/NewTree/treeConfig';
-import { DatabaseTypeCode } from '@/constants';
+import { DatabaseTypeCode, TreeNodeType } from '@/constants';
import { databaseMap } from '@/constants/database';
import { getDatabaseSupport } from '@/utils/database';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ activateSelectDatabaseRequests,
+ createSelectDatabaseRequestLifecycle,
+ disposeSelectDatabaseRequests,
+ hasApplicableDatabaseNameChange,
+ invalidateDatabaseOptionRequests,
+ invalidateDataSourceOptionRequests,
+ normalizeDatabaseOptions,
+ normalizeSchemaOptions,
+ runDatabaseOptionRequest,
+ runSchemaOptionRequest,
+ SelectDatabaseOption,
+} from './selectDatabaseRequestLifecycle';
export type ISelectDatabase = {
dataSourceId?: number;
@@ -24,36 +37,33 @@ interface IUseSelectDatabaseProps {
astrictDatabaseType?: DatabaseTypeCode;
}
+interface DataSourceOption {
+ value: number;
+ label: string;
+ databaseType: DatabaseTypeCode;
+}
+
const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
const { astrictDatabaseType } = props;
- const [dataSourceList, setDataSourceList] = useState<
- | {
- value: number;
- label: string;
- databaseType: DatabaseTypeCode;
- }[]
- | null
- >([]);
-
- const [databaseList, setDatabaseList] = useState<
- | {
- value: string;
- label: string;
- }[]
- | null
- >([]);
-
- const [schemaList, setSchemaList] = useState<
- | {
- value: string;
- label: string;
- }[]
- | null
- >([]);
-
+ const [dataSourceList, setDataSourceList] = useState([]);
+ const [databaseList, setDatabaseList] = useState([]);
+ const [schemaList, setSchemaList] = useState([]);
const [selectDatabase, setSelectDatabase] = useState();
+ const mountedRef = useRef(true);
+ const requestLifecycleRef = useRef(createSelectDatabaseRequestLifecycle());
+ const requestLifecycle = requestLifecycleRef.current;
useEffect(() => {
+ mountedRef.current = true;
+ activateSelectDatabaseRequests(requestLifecycle);
+ return () => {
+ mountedRef.current = false;
+ disposeSelectDatabaseRequests(requestLifecycle);
+ };
+ }, [requestLifecycle]);
+
+ useEffect(() => {
+ invalidateDataSourceOptionRequests(requestLifecycle);
setDatabaseList([]);
setSchemaList([]);
@@ -70,7 +80,7 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
return;
}
setSelectDatabase(null);
- }, [astrictDatabaseType]);
+ }, [astrictDatabaseType, requestLifecycle]);
const astrictDataSourceList = useMemo(() => {
if (astrictDatabaseType) {
@@ -79,115 +89,130 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
return dataSourceList;
}, [dataSourceList, astrictDatabaseType]);
- useEffect(() => {
- getDataSourceList();
- }, []);
-
const getDataSourceList = () => {
setDataSourceList(null);
+ invalidateDataSourceOptionRequests(requestLifecycle);
setDatabaseList([]);
setSchemaList([]);
- treeConfig['dataSources']
+ treeConfig[TreeNodeType.DATA_SOURCES]
.getChildren?.({
refresh: true,
})
.then((res) => {
- const _dataSourceList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.dataSourceId!,
- label: item.originalTitle,
- databaseType: item.extraParams.databaseType!,
- };
- });
- setDataSourceList(_dataSourceList);
+ if (!mountedRef.current) {
+ return;
+ }
+ const options = normalizeTreeNodeLoadResult(res).children.map((item) => ({
+ value: item.extraParams.dataSourceId!,
+ label: item.originalTitle,
+ databaseType: item.extraParams.databaseType!,
+ }));
+ setDataSourceList(options);
})
.catch(() => {
- setDataSourceList([]);
+ if (mountedRef.current) {
+ setDataSourceList([]);
+ }
});
};
+ useEffect(() => {
+ getDataSourceList();
+ }, []);
+
const getDatabaseList = (params: { dataSourceId: number; databaseType: DatabaseTypeCode }) => {
+ invalidateDatabaseOptionRequests(requestLifecycle);
setDatabaseList(null);
setSchemaList([]);
- treeConfig['dataSource']
- .getChildren?.({
- ...params,
- refresh: true,
- })
- .then((res) => {
- const _databaseList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.databaseName!,
- label: item.originalTitle,
- };
- });
- setDatabaseList(_databaseList);
- })
- .catch(() => {
- setDatabaseList([]);
- });
+
+ const getChildren = treeConfig[TreeNodeType.DATA_SOURCE].getChildren;
+ if (!getChildren) {
+ setDatabaseList([]);
+ return;
+ }
+
+ void runDatabaseOptionRequest(
+ requestLifecycle,
+ () =>
+ getChildren({
+ ...params,
+ refresh: true,
+ }),
+ (res) => setDatabaseList(normalizeDatabaseOptions(normalizeTreeNodeLoadResult(res).children)),
+ () => setDatabaseList([]),
+ );
};
- const getSchemaList = (params) => {
+ const getSchemaList = (params: NonNullable) => {
setSchemaList(null);
- treeConfig['database']
- .getChildren?.({
- ...params,
- refresh: true,
- })
- .then((res) => {
- const _schemaList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.schemaName!,
- label: item.originalTitle,
- };
- });
- setSchemaList(_schemaList);
- })
- .catch(() => {
- setSchemaList([]);
- });
+ const getChildren = treeConfig[TreeNodeType.DATABASE].getChildren;
+ if (!getChildren) {
+ setSchemaList([]);
+ return;
+ }
+
+ void runSchemaOptionRequest(
+ requestLifecycle,
+ () =>
+ getChildren({
+ ...params,
+ refresh: true,
+ }),
+ (res) => setSchemaList(normalizeSchemaOptions(normalizeTreeNodeLoadResult(res).children)),
+ () => setSchemaList([]),
+ );
};
const isSelectDone = (params: ISelectDatabase) => {
- let flag = true;
- if (params?.supportDatabase) {
- if (!params.databaseName) {
- flag = false;
- }
+ if (params?.supportDatabase && !params.databaseName) {
+ return false;
}
-
- if (params?.supportSchema) {
- if (!params.schemaName) {
- flag = false;
- }
+ if (params?.supportSchema && !params.schemaName) {
+ return false;
}
+ return true;
+ };
- return flag;
+ const resetSelectDatabase = (): ISelectDatabase => {
+ if (!astrictDatabaseType) {
+ return null;
+ }
+ const { supportSchema, supportDatabase } = databaseMap[astrictDatabaseType];
+ return {
+ databaseType: undefined,
+ supportSchema,
+ supportDatabase,
+ selectDone: false,
+ };
};
const onChangeSelectDatabase = (changedValues: IChangedValues) => {
- let newSelectDatabase: any = {
+ let newSelectDatabase: ISelectDatabase = {
...selectDatabase,
};
if ('dataSourceId' in changedValues) {
- const dataSource = astrictDataSourceList?.find((item) => item.value === changedValues?.dataSourceId);
+ invalidateDataSourceOptionRequests(requestLifecycle);
+ setDatabaseList([]);
+ setSchemaList([]);
+ const dataSource = astrictDataSourceList?.find((item) => item.value === changedValues.dataSourceId);
if (!dataSource) {
+ if (changedValues.dataSourceId !== undefined) {
+ return;
+ }
+ setSelectDatabase(resetSelectDatabase());
return;
}
const databaseType = dataSource.databaseType;
-
const { supportSchema, supportDatabase } = getDatabaseSupport(databaseType);
-
newSelectDatabase = {
dataSourceId: dataSource.value,
databaseName: undefined,
schemaName: undefined,
- selectDone: false,
+ selectDone: !supportDatabase && !supportSchema,
databaseType,
supportSchema,
supportDatabase,
@@ -196,44 +221,44 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
if (supportDatabase) {
getDatabaseList({
dataSourceId: dataSource.value,
- databaseType: dataSource.databaseType,
- });
- } else {
- getSchemaList({
- dataSourceId: dataSource.value,
- databaseType: dataSource.databaseType
+ databaseType,
});
+ } else if (supportSchema) {
+ getSchemaList(newSelectDatabase);
}
-
}
-
- if ('databaseName' in changedValues) {
+
+ if (hasApplicableDatabaseNameChange(changedValues, newSelectDatabase?.supportDatabase)) {
+ invalidateDatabaseOptionRequests(requestLifecycle);
+ setSchemaList([]);
newSelectDatabase = {
...newSelectDatabase,
schemaName: undefined,
databaseName: changedValues.databaseName,
};
- // Do you choose to complete
- if (isSelectDone(newSelectDatabase)) {
- newSelectDatabase.selectDone = true;
+ newSelectDatabase.selectDone = isSelectDone(newSelectDatabase);
+ if (changedValues.databaseName && newSelectDatabase.supportSchema) {
+ getSchemaList(newSelectDatabase);
}
- getSchemaList(newSelectDatabase);
}
-
+
if ('schemaName' in changedValues) {
newSelectDatabase = {
...newSelectDatabase,
schemaName: changedValues.schemaName,
};
- // Do you choose to complete
- if (isSelectDone(newSelectDatabase)) {
- newSelectDatabase.selectDone = true;
- }
+ newSelectDatabase.selectDone = isSelectDone(newSelectDatabase);
}
setSelectDatabase(newSelectDatabase);
};
- return { dataSourceList: astrictDataSourceList, databaseList, schemaList, selectDatabase, onChangeSelectDatabase };
+ return {
+ dataSourceList: astrictDataSourceList,
+ databaseList,
+ schemaList,
+ selectDatabase,
+ onChangeSelectDatabase,
+ };
};
export default useSelectDatabase;
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java
new file mode 100644
index 0000000000..a5059e5aec
--- /dev/null
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java
@@ -0,0 +1,18 @@
+package ai.chat2db.community.domain.api.enums.operation;
+
+/**
+ * User-facing operation log categories. Mirrors the frontend
+ * {@code OperationTypeEnum} contract in chat2db-community-client.
+ */
+public enum OperationTypeEnum {
+
+ /**
+ * SQL execution history, e.g. the console output panel.
+ */
+ SQL_EXECUTE,
+
+ /**
+ * SQL audit history.
+ */
+ SQL_AUDIT
+}
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
index 427edf2834..de926a42fc 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
@@ -38,6 +38,9 @@ public class OperationLog {
private String type;
+ private String operationType;
+
+
private String ddl;
private Boolean more = false;
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
index b6047f40e3..e12ebcc305 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
@@ -24,4 +24,7 @@ public class OpsOperationLogPageQueryRequest extends PageQueryParam {
private String schemaName;
+
+
+ private String operationType;
}
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
index c902814a20..4aac4df737 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
@@ -2,6 +2,7 @@
import java.util.Map;
+import ai.chat2db.community.domain.api.enums.operation.OperationTypeEnum;
import ai.chat2db.community.domain.api.enums.operation.SqlOperationLogStatusEnum;
import ai.chat2db.community.domain.api.model.operation.OperationLog;
import ai.chat2db.community.domain.api.model.operation.SqlOperationLogRecord;
@@ -55,6 +56,8 @@ public OperationLog sqlRecord2operationLog(SqlOperationLogRecord record) {
operationLog.setSchemaName(connectionProfile.getSchemaName());
operationLog.setUseTime(record.getUseTime());
operationLog.setType(connectionProfile.getDbType());
+ // The recording pipeline only logs executions; audit entries enter through the create endpoint.
+ operationLog.setOperationType(OperationTypeEnum.SQL_EXECUTE.name());
operationLog.setOperationRows(record.getOperationRows());
operationLog.setExtendInfo(JSON.toJSONString(Map.of(
"source", StringUtils.defaultString(record.getSource()),
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
index 3ccb3bde1c..70fc31d1f4 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
@@ -1,5 +1,6 @@
package ai.chat2db.community.domain.core.converter;
+import ai.chat2db.community.domain.api.model.operation.OperationLog;
import ai.chat2db.community.domain.api.model.operation.SqlOperationLogRecord;
import ai.chat2db.community.domain.api.model.result.ExecuteResponse;
import ai.chat2db.community.domain.api.model.result.ExecutionMetrics;
@@ -24,4 +25,18 @@ void mapsUseTimeFromExecutionMetrics() {
assertEquals(17L, record.getUseTime());
}
+
+ @Test
+ void tagsRecordedExecutionsAsSqlExecute() {
+ SqlOperationLogRecord record = SqlOperationLogRecord.builder()
+ .sql("select 1")
+ .status("success")
+ .connectionProfile(new ConnectionProfile())
+ .context(new Context())
+ .build();
+
+ OperationLog operationLog = new SqlOperationLogConverter().sqlRecord2operationLog(record);
+
+ assertEquals("SQL_EXECUTE", operationLog.getOperationType());
+ }
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
index e84681c49d..625aba7d3a 100644
--- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
+++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
@@ -2,6 +2,7 @@
import ai.chat2db.community.domain.api.model.PageResponse;
import ai.chat2db.community.domain.api.enums.StorageTypeEnum;
+import ai.chat2db.community.domain.api.enums.operation.OperationTypeEnum;
import ai.chat2db.community.domain.api.model.datasource.DataSource;
import ai.chat2db.community.domain.api.model.datasource.DataSourceIdentityColorUtils;
import ai.chat2db.community.domain.api.model.datasource.DataSourceNamespace;
@@ -28,10 +29,12 @@
import ai.chat2db.community.tools.wrapper.result.DataResult;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
+import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
+import java.util.Objects;
@Component
public class LocalWorkspaceStorage implements IWorkspaceStorage {
@@ -161,12 +164,16 @@ public void deletePinTable(PinTable request) {
public Long createOperationLog(OperationLog request) {
request.setGmtCreate(DateUtil.format(new Date(), DatePattern.NORM_DATETIME_PATTERN));
request.setGmtModified(DateUtil.format(new Date(), DatePattern.NORM_DATETIME_PATTERN));
+ request.setOperationType(StringUtils.defaultIfBlank(request.getOperationType(),
+ OperationTypeEnum.SQL_EXECUTE.name()));
return OperationLogStorage.INSTANCE.save(request);
}
@Override
public PageResponse operationLogList(OpsOperationLogPageQueryRequest operationLogPageQueryRequest) {
- List logs = OperationLogStorage.INSTANCE.getDataList();
+ List logs = OperationLogStorage.INSTANCE.getDataList().stream()
+ .filter(operationLog -> matchesOperationLog(operationLog, operationLogPageQueryRequest))
+ .toList();
return page(logs, operationLogPageQueryRequest.getPageNo(), operationLogPageQueryRequest.getPageSize());
}
@@ -219,6 +226,35 @@ private String encryptString(String password) {
return AesGcmUtil.configured().encrypt(password);
}
+ private boolean matchesOperationLog(OperationLog operationLog, OpsOperationLogPageQueryRequest request) {
+ if (operationLog == null) {
+ return false;
+ }
+ if (request.getDataSourceId() != null
+ && !Objects.equals(request.getDataSourceId(), operationLog.getDataSourceId())) {
+ return false;
+ }
+ if (StringUtils.isNotBlank(request.getDatabaseName())
+ && !Objects.equals(request.getDatabaseName(), operationLog.getDatabaseName())) {
+ return false;
+ }
+ if (StringUtils.isNotBlank(request.getSchemaName())
+ && !Objects.equals(request.getSchemaName(), operationLog.getSchemaName())) {
+ return false;
+ }
+ if (StringUtils.isNotBlank(request.getOperationType())) {
+ // Logs saved before operationType existed all come from the execution pipeline,
+ // so default them to SQL_EXECUTE instead of hiding them from the execution panel.
+ String operationType = StringUtils.defaultIfBlank(operationLog.getOperationType(),
+ OperationTypeEnum.SQL_EXECUTE.name());
+ if (!Objects.equals(request.getOperationType(), operationType)) {
+ return false;
+ }
+ }
+ String searchKey = StringUtils.trimToNull(request.getSearchKey());
+ return searchKey == null || StringUtils.containsIgnoreCase(operationLog.getDdl(), searchKey);
+ }
+
private int normalizePageNo(Integer pageNo) {
return Math.max(1, pageNo == null ? 1 : pageNo);
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
index d229ec4c39..198db889ad 100644
--- a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
+++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
@@ -97,6 +97,128 @@ void operationLogListReturnsOnlyTheRequestedSlice() {
assertFalse(page3.getHasNextPage());
}
+ @Test
+ void operationLogListFiltersFullSqlCaseInsensitivelyBeforePagination() {
+ String firstDdl = "a".repeat(220) + " FrOm OrDeRs";
+ String secondDdl = "b".repeat(220) + " FROM ORDERS";
+ Long firstMatchId = saveOperationLog(1L, "sales", "public", firstDdl);
+ Long secondMatchId = saveOperationLog(1L, "sales", "public", secondDdl);
+ saveOperationLog(1L, "sales", "public", "select * from customers");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setSearchKey("from orders");
+
+ PageResponse page1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page1.getData().size());
+ assertEquals(secondMatchId, page1.getData().get(0).getId());
+ assertEquals(secondDdl, page1.getData().get(0).getDdl());
+ assertEquals(2L, page1.getTotal());
+ assertTrue(page1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse page2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page2.getData().size());
+ assertEquals(firstMatchId, page2.getData().get(0).getId());
+ assertEquals(firstDdl, page2.getData().get(0).getDdl());
+ assertEquals(2L, page2.getTotal());
+ assertFalse(page2.getHasNextPage());
+ }
+
+ @Test
+ void operationLogListAppliesAllScopeFiltersBeforePagination() {
+ Long firstMatchId = saveOperationLog(7L, "warehouse", "analytics", "select 1");
+ Long secondMatchId = saveOperationLog(7L, "warehouse", "analytics", "select 2");
+ saveOperationLog(8L, "warehouse", "analytics", "wrong datasource");
+ saveOperationLog(7L, "reporting", "analytics", "wrong database");
+ saveOperationLog(7L, "warehouse", "staging", "wrong schema");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setDataSourceId(7L);
+ request.setDatabaseName("warehouse");
+ request.setSchemaName("analytics");
+
+ PageResponse page1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page1.getData().size());
+ assertEquals(secondMatchId, page1.getData().get(0).getId());
+ assertEquals(2L, page1.getTotal());
+ assertTrue(page1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse page2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page2.getData().size());
+ assertEquals(firstMatchId, page2.getData().get(0).getId());
+ assertEquals(2L, page2.getTotal());
+ assertFalse(page2.getHasNextPage());
+ }
+
+ @Test
+ void operationLogListSeparatesSqlExecuteAndSqlAuditStreamsBeforePagination() {
+ Long firstExecuteId = saveOperationLog(1L, "sales", "public", "select 1", "SQL_EXECUTE");
+ Long secondExecuteId = saveOperationLog(1L, "sales", "public", "select 2", "SQL_EXECUTE");
+ saveOperationLog(1L, "sales", "public", "select 3", "SQL_AUDIT");
+ saveOperationLog(1L, "sales", "public", "select 4", "SQL_AUDIT");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setOperationType("SQL_EXECUTE");
+
+ PageResponse executePage1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage1.getData().size());
+ assertEquals(secondExecuteId, executePage1.getData().get(0).getId());
+ assertEquals("SQL_EXECUTE", executePage1.getData().get(0).getOperationType());
+ assertEquals(2L, executePage1.getTotal());
+ assertTrue(executePage1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse executePage2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage2.getData().size());
+ assertEquals(firstExecuteId, executePage2.getData().get(0).getId());
+ assertEquals("SQL_EXECUTE", executePage2.getData().get(0).getOperationType());
+ assertEquals(2L, executePage2.getTotal());
+ assertFalse(executePage2.getHasNextPage());
+
+ request.setPageNo(1);
+ request.setOperationType("SQL_AUDIT");
+ PageResponse auditPage1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage1.getData().size());
+ assertEquals("select 4", auditPage1.getData().get(0).getDdl());
+ assertEquals(2L, auditPage1.getTotal());
+ assertTrue(auditPage1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse auditPage2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage2.getData().size());
+ assertEquals("select 3", auditPage2.getData().get(0).getDdl());
+ assertEquals(2L, auditPage2.getTotal());
+ assertFalse(auditPage2.getHasNextPage());
+ }
+
+ @Test
+ void operationLogListTreatsLegacyUntypedLogsAsSqlExecute() {
+ Long legacyId = saveOperationLog(1L, "sales", "public", "select 0");
+ saveOperationLog(1L, "sales", "public", "select 9", "SQL_AUDIT");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(10);
+ request.setOperationType("SQL_EXECUTE");
+ PageResponse executePage = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage.getData().size());
+ assertEquals(legacyId, executePage.getData().get(0).getId());
+ assertEquals(1L, executePage.getTotal());
+
+ request.setOperationType("SQL_AUDIT");
+ PageResponse auditPage = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage.getData().size());
+ assertEquals("select 9", auditPage.getData().get(0).getDdl());
+ assertEquals(1L, auditPage.getTotal());
+ }
+
@Test
void listDataSourcesReturnsOnlyTheRequestedSlice() {
for (int i = 0; i < 3; i++) {
@@ -191,4 +313,19 @@ void fullUpdateStillPreservesStoredPasswordInsideDatasourceStorage() {
System.clearProperty(AesGcmUtil.KEY_PROPERTY);
}
}
+
+ private static Long saveOperationLog(Long dataSourceId, String databaseName, String schemaName, String ddl) {
+ return saveOperationLog(dataSourceId, databaseName, schemaName, ddl, null);
+ }
+
+ private static Long saveOperationLog(Long dataSourceId, String databaseName, String schemaName, String ddl,
+ String operationType) {
+ OperationLog log = new OperationLog();
+ log.setDataSourceId(dataSourceId);
+ log.setDatabaseName(databaseName);
+ log.setSchemaName(schemaName);
+ log.setDdl(ddl);
+ log.setOperationType(operationType);
+ return OperationLogStorage.INSTANCE.save(log);
+ }
}
diff --git a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
index ac2ed2ff49..c03b916690 100644
--- a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
+++ b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
@@ -36,6 +36,9 @@ public class OperationLogResponse {
private String type;
+ private String operationType;
+
+
private String ddl;
diff --git a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
new file mode 100644
index 0000000000..8b1f4c84d5
--- /dev/null
+++ b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
@@ -0,0 +1,45 @@
+package ai.chat2db.community.web.api.converter.operation.log;
+
+import ai.chat2db.community.domain.api.model.operation.OperationLog;
+import ai.chat2db.community.domain.api.model.request.operation.OpsOperationLogPageQueryRequest;
+import ai.chat2db.community.web.api.model.request.operation.log.OperationLogQueryRequest;
+import ai.chat2db.community.web.api.model.response.operation.log.OperationLogResponse;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class OperationLogConverterTest {
+
+ private final OperationLogConverter converter = new OperationLogConverterImpl();
+
+ @Test
+ void request2paramCarriesOperationTypeAndScopeFilters() {
+ OperationLogQueryRequest request = new OperationLogQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(20);
+ request.setSearchKey("from orders");
+ request.setDataSourceId(7L);
+ request.setDatabaseName("warehouse");
+ request.setSchemaName("analytics");
+ request.setOperationType("SQL_EXECUTE");
+
+ OpsOperationLogPageQueryRequest param = converter.request2param(request);
+
+ assertEquals("SQL_EXECUTE", param.getOperationType());
+ assertEquals("from orders", param.getSearchKey());
+ assertEquals(7L, param.getDataSourceId());
+ assertEquals("warehouse", param.getDatabaseName());
+ assertEquals("analytics", param.getSchemaName());
+ }
+
+ @Test
+ void toResponseExposesOperationType() {
+ OperationLog operationLog = new OperationLog();
+ operationLog.setDdl("select 1");
+ operationLog.setOperationType("SQL_EXECUTE");
+
+ OperationLogResponse response = converter.toResponse(operationLog);
+
+ assertEquals("SQL_EXECUTE", response.getOperationType());
+ }
+}