From 7d9989f9ca351ad86b190d9ad7f2acd97ce2cac5 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:48:35 +0800 Subject: [PATCH] fix(client): ignore stale SQL parser results --- .../core/completionProviderManager.ts | 6 +- .../core/sqlCompletionContext.test.ts | 1 + .../core/sqlExecutionTargetSnapshot.ts | 40 ++ .../core/sqlParserRequestCoordinator.test.ts | 400 ++++++++++++++++++ .../core/sqlParserRequestCoordinator.ts | 98 +++++ .../SQLEditor/editor/SQLEditor/index.tsx | 219 +++++++--- .../editor/SQLEditorWithOperation/index.tsx | 135 ++++-- .../src/components/SQLEditor/helper/utils.ts | 4 +- 8 files changed, 796 insertions(+), 107 deletions(-) create mode 100644 chat2db-community-client/src/components/SQLEditor/core/sqlExecutionTargetSnapshot.ts create mode 100644 chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.test.ts create mode 100644 chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.ts diff --git a/chat2db-community-client/src/components/SQLEditor/core/completionProviderManager.ts b/chat2db-community-client/src/components/SQLEditor/core/completionProviderManager.ts index b3fd38a147..7c108e1475 100644 --- a/chat2db-community-client/src/components/SQLEditor/core/completionProviderManager.ts +++ b/chat2db-community-client/src/components/SQLEditor/core/completionProviderManager.ts @@ -244,6 +244,7 @@ class CompletionProviderManager { public onParserChange = (sqlStatementList: SqlStatement[], curStatement?: SqlStatement) => { this.sqlStatementList = sqlStatementList; + this.originColumnList = []; if (curStatement) { const { tableColumns } = curStatement; const columns = (tableColumns || []).reduce((acc: ISimpleColumnVO[], cur) => { @@ -254,10 +255,7 @@ class CompletionProviderManager { return; } - setTimeout(() => { - // this.registerParserColumnProvider(columns); - this.originColumnList = columns ?? []; - }, 1000); + this.originColumnList = columns; } }; diff --git a/chat2db-community-client/src/components/SQLEditor/core/sqlCompletionContext.test.ts b/chat2db-community-client/src/components/SQLEditor/core/sqlCompletionContext.test.ts index 9b958c914f..1f2525eb10 100644 --- a/chat2db-community-client/src/components/SQLEditor/core/sqlCompletionContext.test.ts +++ b/chat2db-community-client/src/components/SQLEditor/core/sqlCompletionContext.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { getSqlCompletionContextId } from './sqlCompletionContext'; +import './sqlParserRequestCoordinator.test'; assert.equal( getSqlCompletionContextId({ consoleId: 42, workspaceTabId: 99 }), diff --git a/chat2db-community-client/src/components/SQLEditor/core/sqlExecutionTargetSnapshot.ts b/chat2db-community-client/src/components/SQLEditor/core/sqlExecutionTargetSnapshot.ts new file mode 100644 index 0000000000..61ce13622a --- /dev/null +++ b/chat2db-community-client/src/components/SQLEditor/core/sqlExecutionTargetSnapshot.ts @@ -0,0 +1,40 @@ +export interface SqlExecutionPosition { + lineNumber: number; + column: number; +} + +export interface SqlExecutionTargetSnapshot { + selectedSql: string; + cursorPosition: SqlExecutionPosition | null; +} + +export interface ResolvedSqlExecutionTarget { + sql: string; + single: boolean; +} + +export function createSqlExecutionTargetSnapshot( + selectedSql: string | null | undefined, + cursorPosition: SqlExecutionPosition | null | undefined, +): SqlExecutionTargetSnapshot { + return { + selectedSql: selectedSql || '', + cursorPosition: cursorPosition + ? { + lineNumber: cursorPosition.lineNumber, + column: cursorPosition.column, + } + : null, + }; +} + +export function resolveSqlExecutionTarget( + snapshot: SqlExecutionTargetSnapshot, + getCursorSql: (position: SqlExecutionPosition) => string, +): ResolvedSqlExecutionTarget { + const sql = snapshot.selectedSql || (snapshot.cursorPosition ? getCursorSql(snapshot.cursorPosition) : ''); + return { + sql, + single: !snapshot.selectedSql, + }; +} diff --git a/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.test.ts b/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.test.ts new file mode 100644 index 0000000000..7e10a1e2f0 --- /dev/null +++ b/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.test.ts @@ -0,0 +1,400 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + SqlParserRequestCoordinator, + runAfterCommittedSqlParser, + runAfterCommittedSqlParserWithSnapshot, + type SqlParserRequestContext, + type SqlParserRequestScope, +} from './sqlParserRequestCoordinator'; +import { + createSqlExecutionTargetSnapshot, + resolveSqlExecutionTarget, +} from './sqlExecutionTargetSnapshot'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: (value) => resolvePromise?.(value) }; +} + +function context( + scope: SqlParserRequestScope, + overrides: Partial> = {}, +): SqlParserRequestContext { + return { + scope, + databaseKey: 'db-a', + sql: 'select 1', + model: modelA, + modelVersion: 1, + ...overrides, + }; +} + +const modelA = { id: 'model-a' }; +const modelB = { id: 'model-b' }; + +async function testLatestRequestOwnsAllParserSideEffects() { + const coordinator = new SqlParserRequestCoordinator(); + const scope = coordinator.createScope(); + const first = deferred(); + const latest = deferred(); + let currentContext = context(scope, { sql: 'select 1', modelVersion: 1 }); + const sideEffects = { + statements: [] as string[], + markers: [] as string[], + completion: [] as string[], + decorations: [] as string[], + hints: [] as string[], + }; + const commit = (value: string) => { + sideEffects.statements.push(value); + sideEffects.markers.push(value); + sideEffects.completion.push(value); + sideEffects.decorations.push(value); + sideEffects.hints.push(value); + }; + + const firstStatus = coordinator.run( + currentContext, + () => currentContext, + () => first.promise, + commit, + ); + currentContext = context(scope, { sql: 'select 2', modelVersion: 2 }); + const latestStatus = coordinator.run( + currentContext, + () => currentContext, + () => latest.promise, + commit, + ); + latest.resolve('S2'); + assert.equal(await latestStatus, 'committed'); + first.resolve('S1'); + assert.equal(await firstStatus, 'stale'); + Object.values(sideEffects).forEach((values) => assert.deepEqual(values, ['S2'])); +} + +async function testEmptySqlInvalidatesPendingRequest() { + const coordinator = new SqlParserRequestCoordinator(); + const scope = coordinator.createScope(); + const request = deferred(); + const requestContext = context(scope); + const commits: string[] = []; + const status = coordinator.run( + requestContext, + () => requestContext, + () => request.promise, + (value) => { + commits.push(value); + }, + ); + coordinator.invalidate(); + request.resolve('stale'); + assert.equal(await status, 'stale'); + assert.deepEqual(commits, []); +} + +async function testUnmountInvalidatesInflightAndLateStarts() { + const coordinator = new SqlParserRequestCoordinator(); + const scope = coordinator.createScope(); + const request = deferred(); + const requestContext = context(scope); + const commits: string[] = []; + const status = coordinator.run( + requestContext, + () => requestContext, + () => request.promise, + (value) => { + commits.push(value); + }, + ); + coordinator.dispose(); + request.resolve('stale'); + assert.equal(await status, 'stale'); + let lateStarts = 0; + assert.equal( + await coordinator.run( + requestContext, + () => requestContext, + async () => { + lateStarts += 1; + return 'late'; + }, + (value) => commits.push(value), + ), + 'stale', + ); + assert.equal(lateStarts, 0); + assert.deepEqual(commits, []); +} + +async function testDatabaseAndModelChangesInvalidatePendingRequest() { + const coordinator = new SqlParserRequestCoordinator(); + const firstScope = coordinator.createScope(); + const databaseRequest = deferred(); + let currentContext = context(firstScope); + const databaseStatus = coordinator.run( + currentContext, + () => currentContext, + () => databaseRequest.promise, + () => { + assert.fail('database-stale request committed'); + }, + ); + currentContext = context(coordinator.createScope(), { databaseKey: 'db-b' }); + databaseRequest.resolve('db-a'); + assert.equal(await databaseStatus, 'stale'); + + const modelRequest = deferred(); + currentContext = context(firstScope, { model: modelA, modelVersion: 1 }); + const modelStatus = coordinator.run( + currentContext, + () => currentContext, + () => modelRequest.promise, + () => { + assert.fail('model-stale request committed'); + }, + ); + currentContext = context(firstScope, { model: modelB, modelVersion: 1 }); + modelRequest.resolve('model-a'); + assert.equal(await modelStatus, 'stale'); +} + +async function testAbaContextUsesUniqueScope() { + const coordinator = new SqlParserRequestCoordinator(); + const firstScope = coordinator.createScope(); + const request = deferred(); + let currentContext = context(firstScope, { databaseKey: 'db-a' }); + const status = coordinator.run( + currentContext, + () => currentContext, + () => request.promise, + () => { + assert.fail('ABA-stale request committed'); + }, + ); + currentContext = context(coordinator.createScope(), { databaseKey: 'db-b' }); + const secondAScope = coordinator.createScope(); + assert.notEqual(firstScope, secondAScope); + currentContext = context(secondAScope, { databaseKey: 'db-a' }); + request.resolve('first-a'); + assert.equal(await status, 'stale'); +} + +async function testStaleQuickParseCannotExecuteSharedStatement() { + const coordinator = new SqlParserRequestCoordinator(); + const scope = coordinator.createScope(); + const request = deferred(); + let currentContext = context(scope, { sql: 'select new', modelVersion: 2 }); + let sharedStatement = 'select old'; + const statusPromise = coordinator.run(currentContext, () => currentContext, () => request.promise, (statement) => { + sharedStatement = statement; + }); + currentContext = context(scope, { sql: 'select newer', modelVersion: 3 }); + request.resolve('select new'); + const executions: string[] = []; + const executed = await runAfterCommittedSqlParser( + () => statusPromise, + () => { + executions.push(sharedStatement); + }, + ); + assert.equal(executed, false); + assert.deepEqual(executions, [], 'quick execution must not read shared parser refs after a stale result'); +} + +async function testToolbarCannotExecuteStatementAfterEditorInvalidation() { + const coordinator = new SqlParserRequestCoordinator(); + const scope = coordinator.createScope(); + const request = deferred(); + let currentContext = context(scope, { sql: 'select old', modelVersion: 1 }); + let sharedStatement = 'select previously parsed'; + const parseStatus = coordinator.run(currentContext, () => currentContext, () => request.promise, (statement) => { + sharedStatement = statement; + }); + + currentContext = context(scope, { sql: 'select edited', modelVersion: 2 }); + coordinator.invalidate(); + sharedStatement = ''; + request.resolve('select old'); + const executions: string[] = []; + const executed = await runAfterCommittedSqlParser( + () => parseStatus, + () => { + executions.push(sharedStatement); + }, + ); + + assert.equal(executed, false); + assert.deepEqual(executions, [], 'the toolbar must not execute a statement parsed before the latest edit'); +} + +async function testDeferredExecutionUsesTriggerTimeSelectionAndCursor() { + const request = deferred(); + const liveCursor = { lineNumber: 1, column: 1 }; + const cursorExecutions: Array<{ sql: string; single: boolean }> = []; + const pendingCursorExecution = runAfterCommittedSqlParserWithSnapshot( + () => createSqlExecutionTargetSnapshot('', liveCursor), + async () => { + await request.promise; + return 'committed'; + }, + (cursorSnapshot) => { + cursorExecutions.push( + resolveSqlExecutionTarget(cursorSnapshot, (position) => + position.lineNumber === 1 ? 'select safe' : 'delete from important_table', + ), + ); + }, + ); + + liveCursor.lineNumber = 3; + liveCursor.column = 8; + assert.deepEqual(cursorExecutions, [], 'execution must wait for the parser to commit'); + request.resolve(); + assert.equal(await pendingCursorExecution, true); + assert.deepEqual(cursorExecutions, [{ sql: 'select safe', single: true }]); + + const selectionRequest = deferred(); + let liveSelection = 'select selected'; + const selectionExecutions: Array<{ sql: string; single: boolean }> = []; + const pendingSelectionExecution = runAfterCommittedSqlParserWithSnapshot( + () => createSqlExecutionTargetSnapshot(liveSelection, liveCursor), + async () => { + await selectionRequest.promise; + return 'committed'; + }, + (selectionSnapshot) => { + selectionExecutions.push(resolveSqlExecutionTarget(selectionSnapshot, () => 'delete from important_table')); + }, + ); + + liveSelection = 'delete from important_table'; + assert.deepEqual(selectionExecutions, [], 'selection execution must wait for the parser to commit'); + selectionRequest.resolve(); + assert.equal(await pendingSelectionExecution, true); + assert.deepEqual(selectionExecutions, [{ sql: 'select selected', single: false }]); + + let staleExecutions = 0; + assert.equal( + await runAfterCommittedSqlParserWithSnapshot( + () => createSqlExecutionTargetSnapshot('', null), + async () => 'stale', + () => { + staleExecutions += 1; + }, + ), + false, + ); + assert.equal(staleExecutions, 0); + assert.deepEqual(resolveSqlExecutionTarget(createSqlExecutionTargetSnapshot('', null), () => 'unexpected'), { + sql: '', + single: true, + }); +} + +function testProductionExecutionPathsUseCommittedParserState() { + const editorSource = readFileSync('src/components/SQLEditor/editor/SQLEditor/index.tsx', 'utf8'); + const operationSource = readFileSync( + 'src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx', + 'utf8', + ); + const completionSource = readFileSync( + 'src/components/SQLEditor/core/completionProviderManager.ts', + 'utf8', + ); + const parserChangeSource = completionSource.slice( + completionSource.indexOf('public onParserChange'), + completionSource.indexOf('public bindModelDBInfo'), + ); + const toolbarExecutionSource = operationSource.slice( + operationSource.indexOf('const handleExecuteSingleSQL'), + operationSource.indexOf('const handleShortCutExecuteSQL'), + ); + const shortcutExecutionSource = operationSource.slice( + operationSource.indexOf('const handleShortCutExecuteSQL'), + operationSource.indexOf('/** Save current editor data. */'), + ); + + assert.match( + editorSource, + /const handleImmediateContentChange[\s\S]*?invalidateSqlParserRequestWork\(\)[\s\S]*?onContentChange\?\.\(sql\)/, + 'the immediate Monaco content event must invalidate stale parser state', + ); + assert.match(editorSource, /onContentChange=\{handleImmediateContentChange\}/); + assert.match( + operationSource, + /case SQLOptType\.EXECUTE_SINGLE_SQL:[\s\S]{0,160}handleExecuteSingleSQL\(typeof params === 'string'/, + 'gutter execution must preserve the SQL from its committed quick parse', + ); + [toolbarExecutionSource, shortcutExecutionSource].forEach((executionSource) => { + assert.match( + executionSource, + /await runAfterCommittedSqlParserWithSnapshot\([\s\S]*?createSqlExecutionTargetSnapshot\([\s\S]*?editor\.getSelectedContent\(\)[\s\S]*?editor\.getInstance\(\)\?\.getPosition\(\)[\s\S]*?editor\.handleQuickSQLParser\(sqlSnapshot, executionBoundInfo\)/, + 'execution must capture selection and cursor state before awaiting its current quick parse', + ); + assert.match( + executionSource, + /resolveSqlExecutionTarget\(targetSnapshot,[\s\S]*?editor\.getCursorCurLineNearestSQL\(position\)/, + 'execution must resolve the committed statement at its trigger-time cursor position', + ); + assert.doesNotMatch( + executionSource, + /getCursorCurLineNearestSQL\(\)/, + 'execution must not read the live cursor after awaiting the parser', + ); + const postParseSource = executionSource.slice(executionSource.indexOf('editor.handleQuickSQLParser')); + assert.doesNotMatch( + postParseSource, + /getSelectedContent\(\)|getPosition\(\)/, + 'execution must not read live selection or cursor state after starting the parser', + ); + }); + assert.match( + toolbarExecutionSource, + /if \(committedSql !== undefined\) \{[\s\S]*?await execute\(committedSql\);[\s\S]*?return;[\s\S]*?runAfterCommittedSqlParserWithSnapshot/, + 'gutter execution must use its already committed SQL without starting another parser request', + ); + assert.match( + toolbarExecutionSource, + /single: true,[\s\S]*?resolveSqlExecutionTarget\(targetSnapshot,[\s\S]*?execute\(target\.sql\)/, + 'toolbar execution must stay single-statement and use SQL resolved from its trigger-time snapshot', + ); + assert.match( + shortcutExecutionSource, + /resolveSqlExecutionTarget\([\s\S]*?sql: target\.sql,[\s\S]*?single: target\.single/, + 'shortcut execution must use both SQL and selection mode from its trigger-time snapshot', + ); + assert.match(parserChangeSource, /this\.originColumnList = \[\]/); + assert.equal( + parserChangeSource.includes('setTimeout'), + false, + 'clearing parser completion state must not allow an old delayed column update to write back', + ); +} + +void Promise.all([ + testLatestRequestOwnsAllParserSideEffects(), + testEmptySqlInvalidatesPendingRequest(), + testUnmountInvalidatesInflightAndLateStarts(), + testDatabaseAndModelChangesInvalidatePendingRequest(), + testAbaContextUsesUniqueScope(), + testStaleQuickParseCannotExecuteSharedStatement(), + testToolbarCannotExecuteStatementAfterEditorInvalidation(), + testDeferredExecutionUsesTriggerTimeSelectionAndCursor(), + testProductionExecutionPathsUseCommittedParserState(), +]) + .then(() => console.log('SQL parser request coordinator tests passed')) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); diff --git a/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.ts b/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.ts new file mode 100644 index 0000000000..3c8e0643ca --- /dev/null +++ b/chat2db-community-client/src/components/SQLEditor/core/sqlParserRequestCoordinator.ts @@ -0,0 +1,98 @@ +export type SqlParserRequestScope = symbol; +export type SqlParserRequestStatus = 'committed' | 'stale'; + +export const isCommittedSqlParserRequest = (status: SqlParserRequestStatus) => status === 'committed'; + +export async function runAfterCommittedSqlParser( + parse: () => Promise, + execute: () => void | Promise, +): Promise { + const status = await parse(); + if (!isCommittedSqlParserRequest(status)) { + return false; + } + await execute(); + return true; +} + +export async function runAfterCommittedSqlParserWithSnapshot( + capture: () => T, + parse: () => Promise, + execute: (snapshot: T) => void | Promise, +): Promise { + const snapshot = capture(); + return runAfterCommittedSqlParser(parse, () => execute(snapshot)); +} + +export interface SqlParserRequestContext { + scope: SqlParserRequestScope; + databaseKey: string; + sql: string; + model: unknown; + modelVersion: number | null; +} + +export class SqlParserRequestCoordinator { + private generation = 0; + + private active = true; + + createScope(): SqlParserRequestScope { + return Symbol('sql-parser-request-scope'); + } + + activate() { + this.generation += 1; + this.active = true; + } + + invalidate() { + this.generation += 1; + } + + dispose() { + this.generation += 1; + this.active = false; + } + + async run( + context: SqlParserRequestContext, + readCurrentContext: () => SqlParserRequestContext, + request: () => Promise, + commit: (result: T) => void, + ): Promise { + if (!this.active || !isSameSqlParserRequestContext(context, readCurrentContext())) { + return 'stale'; + } + const generation = this.generation + 1; + this.generation = generation; + let result: T; + try { + result = await request(); + } catch (error) { + if (!this.isCurrent(generation, context, readCurrentContext())) { + return 'stale'; + } + throw error; + } + if (!this.isCurrent(generation, context, readCurrentContext())) { + return 'stale'; + } + commit(result); + return 'committed'; + } + + private isCurrent(generation: number, context: SqlParserRequestContext, currentContext: SqlParserRequestContext) { + return this.active && this.generation === generation && isSameSqlParserRequestContext(context, currentContext); + } +} + +function isSameSqlParserRequestContext(left: SqlParserRequestContext, right: SqlParserRequestContext) { + return ( + left.scope === right.scope && + left.databaseKey === right.databaseKey && + left.sql === right.sql && + left.model === right.model && + left.modelVersion === right.modelVersion + ); +} diff --git a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditor/index.tsx b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditor/index.tsx index ab654c0d28..8be9076d40 100644 --- a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditor/index.tsx +++ b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditor/index.tsx @@ -59,6 +59,13 @@ import { } from '@/constants/shortcut'; import { useStyles } from './style'; import LocalFileEncodingSelect from '@/components/LocalFileEncodingSelect'; +import { + SqlParserRequestCoordinator, + runAfterCommittedSqlParser, + type SqlParserRequestContext, + type SqlParserRequestScope, + type SqlParserRequestStatus, +} from '../../core/sqlParserRequestCoordinator'; const INSERT_VALUE_HINT_ACTION_ID = 'chat2db-insert-value-hints'; const EDITOR_ESCAPE_KEY_CODE = 'Escape'; @@ -94,11 +101,11 @@ export interface SQLEditorRef extends MonacoEditorRef { /** Get the SQL statement at the current cursor position. */ getCursorSQL: () => string; /** Get the nearest SQL statement to the current cursor line. */ - getCursorCurLineNearestSQL: () => string; + getCursorCurLineNearestSQL: (position?: monaco.IPosition | null) => string; /** Parse an SQL statement. */ - handleSQLParser: (sql: string, dbInfo: IBoundInfo) => void; + handleSQLParser: (sql: string, dbInfo: IBoundInfo) => Promise; /** Execute SQL through the quick action. */ - handleQuickSQLParser: (sql: string, dbInfo: IBoundInfo) => void; + handleQuickSQLParser: (sql: string, dbInfo: IBoundInfo) => Promise; /** Get the table name at the specified position. */ getTableIdentifierAtPosition: (position: monaco.IPosition | null | undefined) => EditorTableIdentifier | null; } @@ -119,6 +126,16 @@ const getTableDDLTriggerMode = (editorSettings?: EditorSettings) => editorSettin const toArray = (value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []); type EditorHintsListener = (editorHints: ISqlEditorHintVO[]) => void; +const getSqlParserDatabaseKey = (boundInfo?: IBoundInfo) => + JSON.stringify([ + boundInfo?.consoleId ?? null, + boundInfo?.workspaceTabId ?? null, + boundInfo?.dataSourceId ?? null, + boundInfo?.databaseName ?? null, + boundInfo?.schemaName ?? null, + boundInfo?.databaseType ?? null, + ]); + type BackendEditorHintsSource = 'completion' | 'content' | 'parser'; interface ApplyBackendEditorHintsOptions { @@ -174,6 +191,22 @@ const SQLEditor = forwardRef( const backendEditorHintsListenerRef = useRef(null); const backendEditorHintsRequestRef = useRef(0); const backendEditorHintsEpochRef = useRef(0); + const sqlParserRequestCoordinatorRef = useRef(); + if (!sqlParserRequestCoordinatorRef.current) { + sqlParserRequestCoordinatorRef.current = new SqlParserRequestCoordinator(); + } + const sqlParserRequestCoordinator = sqlParserRequestCoordinatorRef.current; + const latestDbInfoRef = useRef(dbInfo); + latestDbInfoRef.current = dbInfo; + const sqlParserDatabaseKey = getSqlParserDatabaseKey(dbInfo); + const sqlParserScopeRef = useRef<{ databaseKey: string; scope: SqlParserRequestScope }>(); + if (!sqlParserScopeRef.current || sqlParserScopeRef.current.databaseKey !== sqlParserDatabaseKey) { + sqlParserScopeRef.current = { + databaseKey: sqlParserDatabaseKey, + scope: sqlParserRequestCoordinator.createScope(), + }; + } + const sqlParserScope = sqlParserScopeRef.current.scope; const autoFillEditInProgressRef = useRef(false); const editorSurfaceRef = useRef(null); const cursorPositionRef = useRef(null); @@ -278,7 +311,9 @@ const SQLEditor = forwardRef( }, [contextMenuInfo]); useEffect(() => { + sqlParserRequestCoordinator.activate(); return () => { + sqlParserRequestCoordinator.dispose(); safelyDisposeEditorResource(() => decorationCollectionRef.current?.clear()); decorationCollectionRef.current = null; safelyDisposeEditorResource(() => sqlValueTypeHintCollectionRef.current?.clear()); @@ -300,6 +335,7 @@ const SQLEditor = forwardRef( backendEditorHintsRef.current = backendEditorHintStoreRef.current.clear(); refreshBackendParameterHints.cancel(); handleSQLParser.cancel(); + updateMarkMessage.cancel(); completionProvider.current?.clearModelDBInfo(getInstance()?.getModel()); setBackendCompletionModel(getInstance()?.getModel(), false); insertValueHintActionRef.current = null; @@ -310,7 +346,7 @@ const SQLEditor = forwardRef( parameterHintWidgetRef.current = null; backendEditorHintsListenerRef.current = null; }; - }, [getInstance]); + }, [getInstance, sqlParserRequestCoordinator]); useEffect(() => { syncBackendCompletionModel(getInstance()); @@ -362,57 +398,119 @@ const SQLEditor = forwardRef( } }, [sqlTemp, dbInfo]); - const handleSQLParserRightNow = async (sql: string, _dbInfo: IBoundInfo, isQuick: boolean = false) => { + const createSqlParserRequestContext = ( + sql: string, + boundInfo: IBoundInfo, + scope: SqlParserRequestScope, + ): SqlParserRequestContext => { + const model = getInstance()?.getModel() ?? null; + return { + scope, + databaseKey: getSqlParserDatabaseKey(boundInfo), + sql, + model, + modelVersion: model?.getVersionId() ?? null, + }; + }; + + const getCurrentSqlParserRequestContext = (): SqlParserRequestContext => { + const model = getInstance()?.getModel() ?? null; + return { + scope: sqlParserScopeRef.current!.scope, + databaseKey: getSqlParserDatabaseKey(latestDbInfoRef.current), + sql: model?.getValue() ?? getValue(), + model, + modelVersion: model?.getVersionId() ?? null, + }; + }; + + function invalidateSqlParserRequestWork() { + sqlParserRequestCoordinator.invalidate(); + updateMarkMessage.cancel(); + refreshBackendParameterHints.cancel(); + backendEditorHintsRequestRef.current += 1; + backendEditorHintsEpochRef.current += 1; + sqlStatementListRef.current = []; + markMessageListRef.current = []; + completionProvider.current?.onParserChange([]); + const editor = getInstance(); + const model = editor?.getModel(); + if (editor && model) { + setModelMarkers(model, editor.getId(), []); + } + decorationCollectionRef.current?.clear(); + hideParameterHint(); + } + + const handleSQLParserRightNow = async ( + sql: string, + _dbInfo: IBoundInfo, + isQuick: boolean = false, + ): Promise => { const { dataSourceId, databaseName, schemaName } = _dbInfo; - if (!dataSourceId) return; + if (!dataSourceId) { + invalidateSqlParserRequestWork(); + return 'stale'; + } if (!sql) { - sqlStatementListRef.current = []; - markMessageListRef.current = []; + invalidateSqlParserRequestWork(); clearBackendEditorHints(); - hideParameterHint(); - return; + return 'stale'; } // Skip parsing SQL statements longer than 50,000 characters. if (sql.length >= 50000) { - return; + invalidateSqlParserRequestWork(); + return 'stale'; } + if (isQuick) { + handleSQLParser.cancel(); + } + invalidateSqlParserRequestWork(); + const requestContext = createSqlParserRequestContext(sql, _dbInfo, sqlParserScope); const queryParser = isQuick ? SQLParserService.queryQuickSQLParser : SQLParserService.querySQLParser; - const parser = await queryParser({ - consoleId: _dbInfo.consoleId!, - sql, - dataSourceId, - databaseName, - schemaName, - }); - const { sqlStatementList, markMessageList } = parser || {}; - const nextSqlStatementList = sqlStatementList || []; - const nextMarkMessageList = markMessageList || []; - sqlStatementListRef.current = nextSqlStatementList; - markMessageListRef.current = nextMarkMessageList; - - if (completionProvider.current) { - const editor = getInstance(); - if (!editor) return ''; - const position = editor.getPosition(); - if (!position) return ''; - const curStatement = findSqlStatement(position, nextSqlStatementList); - - completionProvider.current.onParserChange(nextSqlStatementList, curStatement); - } - updateMarkMessage(); - updateDecoration(getInstance(), decorationCollectionRef.current); - updateParameterHint(getInstance(), localInsertValueParameterHint(getInstance())); - refreshBackendParameterHints(getInstance(), 'parser'); + return sqlParserRequestCoordinator.run( + requestContext, + getCurrentSqlParserRequestContext, + () => + queryParser({ + consoleId: _dbInfo.consoleId!, + sql, + dataSourceId, + databaseName, + schemaName, + }), + (parser) => { + const { sqlStatementList, markMessageList } = parser || {}; + const nextSqlStatementList = sqlStatementList || []; + const nextMarkMessageList = markMessageList || []; + sqlStatementListRef.current = nextSqlStatementList; + markMessageListRef.current = nextMarkMessageList; + + if (completionProvider.current) { + const editor = getInstance(); + if (!editor) return; + const position = editor.getPosition(); + if (!position) return; + const curStatement = findSqlStatement(position, nextSqlStatementList); + + completionProvider.current.onParserChange(nextSqlStatementList, curStatement); + } + updateMarkMessage(); + updateDecoration(getInstance(), decorationCollectionRef.current); + updateParameterHint(getInstance(), localInsertValueParameterHint(getInstance())); + refreshBackendParameterHints(getInstance(), 'parser'); + }, + ); }; const handleSQLParser = useCallback( debounce((sql) => { handleSQLParserRightNow(sql, dbInfo); }, 500), - [dbInfo], + [dbInfo, sqlParserScope], ); useEffect(() => { @@ -425,6 +523,15 @@ const SQLEditor = forwardRef( setHoverHelpInfo(hoverHelpDefaultConfig); }, []); + const handleImmediateContentChange = useCallback( + (sql: string) => { + invalidateSqlParserRequestWork(); + clearBackendEditorHints(); + onContentChange?.(sql); + }, + [dbInfo, onContentChange, readOnly], + ); + const handleValueChange = useCallback( ( sql: string, @@ -502,7 +609,7 @@ const SQLEditor = forwardRef( if (e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) { const target = e.target.element; if (target instanceof HTMLDivElement && target.classList.contains('execute-button-glyph')) { - handleClickExecuteButton(); + void handleClickExecuteButton(); } return; } @@ -785,9 +892,7 @@ const SQLEditor = forwardRef( }, [refreshBackendParameterHints]); useEffect(() => { - backendEditorHintsRequestRef.current += 1; - backendEditorHintsEpochRef.current += 1; - refreshBackendParameterHints.cancel(); + invalidateSqlParserRequestWork(); clearBackendEditorHints(); hideParameterHint(); }, [ @@ -916,16 +1021,17 @@ const SQLEditor = forwardRef( const position = editor.getPosition(); if (!position) return; - await handleSQLParserRightNow(editor.getValue(), dbInfo, true); - - // Find the current SQL statement. - const currentStatement = (sqlStatementListRef.current || []).find( - (stmt) => position?.lineNumber >= stmt.sqlStartRowNum && position?.lineNumber <= stmt.sqlEndRowNum, + await runAfterCommittedSqlParser( + () => handleSQLParserRightNow(editor.getValue(), dbInfo, true), + () => { + const currentStatement = (sqlStatementListRef.current || []).find( + (stmt) => position.lineNumber >= stmt.sqlStartRowNum && position.lineNumber <= stmt.sqlEndRowNum, + ); + if (currentStatement) { + action(SQLOptType.EXECUTE_SINGLE_SQL, currentStatement.sql ?? ''); + } + }, ); - if (!currentStatement) return; - - // Execute the SQL statement. - action(SQLOptType.EXECUTE_SINGLE_SQL, currentStatement?.sql ?? ''); }; const handleHover = useCallback( @@ -960,6 +1066,7 @@ const SQLEditor = forwardRef( const handleEditorMount = useCallback( (editor: monaco.editor.IStandaloneCodeEditor) => { + invalidateSqlParserRequestWork(); safelyDisposeEditorResource(() => decorationCollectionRef.current?.clear()); decorationCollectionRef.current = editor.createDecorationsCollection(); @@ -1009,12 +1116,12 @@ const SQLEditor = forwardRef( /** * Get the nearest SQL statement to the current cursor line */ - const getCursorCurLineNearestSQL = useCallback(() => { + const getCursorCurLineNearestSQL = useCallback((position?: monaco.IPosition | null) => { const editor = getInstance(); if (!editor) return ''; - const position = editor.getPosition(); - if (!position) return ''; - const curStatement = findNearestSQL(position, sqlStatementListRef.current); + const targetPosition = position === undefined ? editor.getPosition() : position; + if (!targetPosition) return ''; + const curStatement = findNearestSQL(targetPosition, sqlStatementListRef.current); return curStatement?.sql ?? ''; }, []); @@ -1033,7 +1140,7 @@ const SQLEditor = forwardRef( defaultValue={defaultValue} onMount={handleEditorMount} onChange={handleValueChange} - onContentChange={onContentChange} + onContentChange={handleImmediateContentChange} onCursorChange={handleCursorChange} onMouseClick={handleMouseClick} onContextMenu={handleContextMenu} diff --git a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx index 5f9f473d67..61a0db9bf0 100644 --- a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx +++ b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx @@ -56,6 +56,11 @@ import { createDataSourceExecutionSnapshot, type DataSourceExecutionSnapshot, } from '@/service/dataSourceExecutionSnapshot'; +import { runAfterCommittedSqlParserWithSnapshot } from '../../core/sqlParserRequestCoordinator'; +import { + createSqlExecutionTargetSnapshot, + resolveSqlExecutionTarget, +} from '../../core/sqlExecutionTargetSnapshot'; export interface SQLExecutionInvocation extends IConsoleReturnExecuteSql { executionTarget: DataSourceExecutionSnapshot; @@ -201,10 +206,11 @@ const SQLEditorWithOperation = forwardRef sqlEditorRef.current?.getSelectedContent() ?? '', getCursorSQL: () => sqlEditorRef.current?.getCursorSQL() ?? '', - getCursorCurLineNearestSQL: () => sqlEditorRef.current?.getCursorCurLineNearestSQL() ?? '', - handleSQLParser: (sql: string, _dbInfo: IBoundInfo) => sqlEditorRef.current?.handleSQLParser(sql, _dbInfo), + getCursorCurLineNearestSQL: (position) => sqlEditorRef.current?.getCursorCurLineNearestSQL(position) ?? '', + handleSQLParser: (sql: string, _dbInfo: IBoundInfo) => + sqlEditorRef.current?.handleSQLParser(sql, _dbInfo) ?? Promise.resolve('stale'), handleQuickSQLParser: (sql: string, _dbInfo: IBoundInfo) => - sqlEditorRef.current?.handleQuickSQLParser(sql, _dbInfo), + sqlEditorRef.current?.handleQuickSQLParser(sql, _dbInfo) ?? Promise.resolve('stale'), getTableIdentifierAtPosition: (position) => sqlEditorRef.current?.getTableIdentifierAtPosition(position) ?? null, executeSQL: handleExecuteSQL, hasUnsavedChangesBeforeClose, @@ -379,10 +385,10 @@ const SQLEditorWithOperation = forwardRef { - const selectSQL = sqlEditorRef.current?.getSelectedContent() || ''; - const cursorSQL = sqlEditorRef.current?.getCursorCurLineNearestSQL() || ''; - const sql = selectSQL || cursorSQL; - if (!sql) { - staticMessage.warning(i18n('common.placeholder.select', 'SQL')); + const handleExecuteSingleSQL = async (committedSql?: string) => { + const editor = sqlEditorRef.current; + if (!editor) { return; } + const executionBoundInfo = createDataSourceExecutionBoundInfo(dbInfo); + const executionTarget = createDataSourceExecutionSnapshot(executionBoundInfo); + const executionDataSourceState = dataSourceState; + const sqlSnapshot = editor.getValue(); + const execute = (sql: string) => { + if (!sql) { + staticMessage.warning(i18n('common.placeholder.select', 'SQL')); + return; + } - const executeSqlParams = { - sql, - single: true, + return props + .onExecuteSQL({ + sql, + single: true, + executionTarget, + dataSourceState: executionDataSourceState, + }) + .then(() => { + setErrorMessage(null); + }) + .catch((error) => { + setErrorMessage(error.errorMessage || ''); + }); }; - props - ?.onExecuteSQL(createExecutionInvocation(executeSqlParams)) - .then(() => { - setErrorMessage(null); - }) - .catch((error) => { - setErrorMessage(error.errorMessage || ''); - }); + if (committedSql !== undefined) { + await execute(committedSql); + return; + } + + await runAfterCommittedSqlParserWithSnapshot( + () => + createSqlExecutionTargetSnapshot( + editor.getSelectedContent(), + editor.getInstance()?.getPosition(), + ), + () => editor.handleQuickSQLParser(sqlSnapshot, executionBoundInfo), + (targetSnapshot) => { + const target = resolveSqlExecutionTarget(targetSnapshot, (position) => + editor.getCursorCurLineNearestSQL(position), + ); + return execute(target.sql); + }, + ); }; /** * Execute SQL via shortcut. */ const handleShortCutExecuteSQL = useCallback(async () => { + const editor = sqlEditorRef.current; + if (!editor) { + return; + } const executionBoundInfo = createDataSourceExecutionBoundInfo(dbInfo); const executionTarget = createDataSourceExecutionSnapshot(executionBoundInfo); const executionDataSourceState = dataSourceState; - await sqlEditorRef.current?.handleQuickSQLParser(sqlEditorRef.current?.getValue() || '', executionBoundInfo); - // await sqlEditorRef.current?.handleSQLParser(sqlEditorRef.current?.getValue() || '', dbInfo); + const sqlSnapshot = editor.getValue(); - const selectSQL = sqlEditorRef.current?.getSelectedContent() || ''; - const cursorSQL = sqlEditorRef.current?.getCursorCurLineNearestSQL() || ''; - const isSingle = selectSQL ? false : true; - const sql = selectSQL || cursorSQL; - if (!sql) { - staticMessage.warning(i18n('common.placeholder.select', 'SQL')); - return; - } + await runAfterCommittedSqlParserWithSnapshot( + () => + createSqlExecutionTargetSnapshot( + editor.getSelectedContent(), + editor.getInstance()?.getPosition(), + ), + () => editor.handleQuickSQLParser(sqlSnapshot, executionBoundInfo), + (targetSnapshot) => { + const target = resolveSqlExecutionTarget(targetSnapshot, (position) => + editor.getCursorCurLineNearestSQL(position), + ); + if (!target.sql) { + staticMessage.warning(i18n('common.placeholder.select', 'SQL')); + return; + } - props - ?.onExecuteSQL({ - sql, - single: isSingle, - executionTarget, - dataSourceState: executionDataSourceState, - }) - .then(() => { - setErrorMessage(null); - }) - .catch((error) => { - setErrorMessage(error.errorMessage || ''); - }); + return props + ?.onExecuteSQL({ + sql: target.sql, + single: target.single, + executionTarget, + dataSourceState: executionDataSourceState, + }) + .then(() => { + setErrorMessage(null); + }) + .catch((error) => { + setErrorMessage(error.errorMessage || ''); + }); + }, + ); }, [props?.onExecuteSQL, dbInfo, dataSourceState]); /** Save current editor data. */ diff --git a/chat2db-community-client/src/components/SQLEditor/helper/utils.ts b/chat2db-community-client/src/components/SQLEditor/helper/utils.ts index 0d1f0edf5a..c6880b171f 100644 --- a/chat2db-community-client/src/components/SQLEditor/helper/utils.ts +++ b/chat2db-community-client/src/components/SQLEditor/helper/utils.ts @@ -44,7 +44,7 @@ export function formatSql(sql: string, dbType?: DatabaseTypeCode): Promise (curPosition.lineNumber > statement.sqlStartRowNum || @@ -54,7 +54,7 @@ export function findSqlStatement(curPosition: monaco.Position, sqlStatementList: ); } -export function findNearestSQL(curPosition: monaco.Position, sqlStatementList: SqlStatement[]) { +export function findNearestSQL(curPosition: monaco.IPosition, sqlStatementList: SqlStatement[]) { const curLine = curPosition.lineNumber; const curCol = curPosition.column;