8557 lines
398 KiB
TypeScript
8557 lines
398 KiB
TypeScript
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
|
||
import { defineStore } from "pinia";
|
||
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
|
||
import { uuid } from "@/lib/common/utils";
|
||
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
|
||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
|
||
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
|
||
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
|
||
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
|
||
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
|
||
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
|
||
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs, type OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
|
||
import {
|
||
evaluateMongoAggregateSafety,
|
||
evaluateMongoWriteSafety,
|
||
mongoCollectionStatsToQueryResult,
|
||
mongoCountToQueryResult,
|
||
mongoDatabasesToQueryResult,
|
||
mongoDistinctToQueryResult,
|
||
mongoCreateIndexToQueryResult,
|
||
mongoDocumentsToQueryResult,
|
||
describeMongoCommandParseFailure,
|
||
mongoDroppedIndexesToQueryResult,
|
||
mongoFindLogicalTotal,
|
||
mongoIndexesToQueryResult,
|
||
planMongoFindPagination,
|
||
mongoUseToQueryResult,
|
||
mongoVersionToQueryResult,
|
||
mongoWriteToQueryResult,
|
||
splitMongoCommandRanges,
|
||
type MongoAggregateSafetyOptions,
|
||
} from "@/lib/mongo/mongoShellCommand";
|
||
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
|
||
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
|
||
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
|
||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
|
||
import { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
|
||
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
|
||
import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy";
|
||
import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
|
||
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||
import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, updateCachedTableMetadataType, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache";
|
||
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
|
||
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
|
||
import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
|
||
import { sqlServerCountUsesLocalTempTable } from "@/lib/query/queryResultCountSession";
|
||
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
|
||
import { appendLargeValueCells, canUseTableDataLargeValuePreview, remapLargeValueCells, tableDataLargeValuePreviewOptions, TABLE_DATA_RESULT_MAX_BYTES } from "@/lib/dataGrid/dataGridLargeValues";
|
||
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
|
||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||
import { agentProtocolQueryResultMaxRows, capQueryResultTotal, effectiveQueryResultMaxRows, limitQueryPagination, queryResultLimitReached } from "@/lib/dataGrid/queryResultRowLimit";
|
||
import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges";
|
||
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||
import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUseDatabaseFromStatement } from "@/lib/sql/sqlCompletionLookupTarget";
|
||
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
|
||
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
|
||
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
|
||
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
|
||
import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
|
||
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||
import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
|
||
import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation";
|
||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||
import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
|
||
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
|
||
import * as api from "@/lib/backend/api";
|
||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||
import { useExportTracker } from "@/composables/useExportTracker";
|
||
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
|
||
import { appendDebugLog } from "@/lib/backend/debugLog";
|
||
import { BackendErrorException, formatError, isManualTransactionSessionExpired, isUnsupportedManualTransactionMethod, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
|
||
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
|
||
import { isDetachedWindow, resolveWindowContext } from "@/lib/app/windowContext";
|
||
import type { DetachedTabHandoff, DetachedTabRuntimeState } from "@/lib/app/detachedTabHandoff";
|
||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
|
||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
|
||
import { buildEditableObjectSource } from "@/lib/table/objectSourceEditor";
|
||
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||
import type { SavedSqlFile } from "@/types/database";
|
||
import i18n, { currentLocale } from "@/i18n";
|
||
import { translateBackendError } from "@/i18n/backend-errors";
|
||
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
|
||
import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions";
|
||
import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution";
|
||
|
||
const QUERY_SURFACE_ACTIVATION_EVENT = "dbx:activate-query-surface";
|
||
|
||
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
|
||
const ORACLE_DEFERRED_LOB_TYPES = new Set<string>(["CLOB", "NCLOB", "BLOB", "BFILE", "XMLTYPE", "SYS.XMLTYPE"]);
|
||
|
||
// Bounded concurrency for grouped-query display column loads, scoped per
|
||
// connection so different connections never block each other. Matches the
|
||
// project's existing completion-metadata concurrency policy (<2>).
|
||
const GROUPED_DISPLAY_METADATA_CONCURRENCY = 2;
|
||
const GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX = "query-column-comments:";
|
||
const groupedDisplayMetadataLimiter = new MetadataTaskLimiter(GROUPED_DISPLAY_METADATA_CONCURRENCY, (event) => {
|
||
console.debug("[DBX][metadata-load:grouped-display-limiter]", event);
|
||
});
|
||
const UPPERCASE_FOLDED_METADATA_TYPES = new Set<string>([...ORACLE_LIKE_METADATA_TYPES, "saphana"]);
|
||
const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle", "xugu"]);
|
||
const QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR = "Streaming export is unsupported for this query. Simplify it or use a supported driver.";
|
||
const BACKGROUND_CLIENT_SESSION_SUFFIXES = ["count", "explain", "export"] as const;
|
||
const CANCEL_QUERY_TIMEOUT_MS = 10_000;
|
||
const CANCEL_ACK_SETTLE_TIMEOUT_MS = 2_000;
|
||
const ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS = 1_000;
|
||
const ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT = Symbol("oracle-query-metadata-preflight-timeout");
|
||
const SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS = 500;
|
||
type CloseConfirmContext = "tab" | "batch" | "app";
|
||
|
||
export interface EditorGroup {
|
||
id: string;
|
||
tabIds: string[];
|
||
activeTabId: string | null;
|
||
}
|
||
|
||
export interface EditorWorkspacePersistState {
|
||
groups: EditorGroup[];
|
||
focusedGroupId: string;
|
||
orientation: "vertical" | "horizontal";
|
||
sizes: number[];
|
||
}
|
||
|
||
interface BatchSqlResumeOptions {
|
||
batch: BatchSqlExecution;
|
||
previousResults: QueryResult[];
|
||
startStatementIndex: number;
|
||
continueOnError: boolean;
|
||
}
|
||
|
||
function hasHiddenPhysicalRowKey(databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[]): boolean {
|
||
return hiddenPrimaryKeys.some((projection) => !usesSyntheticRowIdKey(databaseType, [projection.sourceName]));
|
||
}
|
||
|
||
function cloneTabDraft<T>(value: T): T {
|
||
return JSON.parse(JSON.stringify(value)) as T;
|
||
}
|
||
|
||
interface BuildQueryResultExportRequestOptions {
|
||
exportId: string;
|
||
filePath: string;
|
||
format: "csv" | "xlsx" | "txt" | "sql";
|
||
includeSqlSheet?: boolean;
|
||
exportTableName?: string;
|
||
exportColumnTypes?: Array<string | null | undefined>;
|
||
insertMode?: SqlInsertMode;
|
||
}
|
||
|
||
interface OpenSavedSqlOptions {
|
||
targetMode?: SavedSqlOpenTargetMode;
|
||
}
|
||
|
||
interface OpenObjectSourceTabOptions {
|
||
connectionId: string;
|
||
database: string;
|
||
title: string;
|
||
schema?: string;
|
||
catalog?: string;
|
||
sql: string;
|
||
objectSource: NonNullable<QueryTab["objectSource"]>;
|
||
}
|
||
|
||
/**
|
||
* 请求身份:`objectSource.objectType` 要等 routine fallback 跑完才知道,所以
|
||
* pending 去重只能按请求时的身份判定。请求身份必须含 objectType —— PACKAGE 与
|
||
* PACKAGE_BODY 同名同 schema,仅靠 name+schema 会错误合并。
|
||
*/
|
||
interface ObjectSourceRequestIdentity {
|
||
name: string;
|
||
objectType: ObjectSourceKind;
|
||
signature?: string;
|
||
}
|
||
|
||
interface OpenPendingObjectSourceTabOptions {
|
||
connectionId: string;
|
||
database: string;
|
||
title: string;
|
||
schema?: string;
|
||
catalog?: string;
|
||
request: ObjectSourceRequestIdentity;
|
||
}
|
||
|
||
/**
|
||
* 拿到源码也没有可编辑形态的对象类型:只填内容,不挂 objectSource。
|
||
* (`App.vue` 的 Ctrl+click 路径有一份少了 `JOB` 的旧副本,是既有不一致。)
|
||
*/
|
||
const OBJECT_SOURCE_READ_ONLY_TYPES: readonly ObjectSourceKind[] = ["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"];
|
||
|
||
interface UpdateExecutionTargetOptions {
|
||
persistSavedSqlTarget?: boolean;
|
||
}
|
||
|
||
type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW";
|
||
|
||
interface DroppedTableObjectTarget {
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
schemaCandidates?: Array<string | undefined>;
|
||
name: string;
|
||
objectType?: DroppedTableObjectType;
|
||
}
|
||
|
||
interface TableDataRefreshTarget {
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
schemaCandidates?: Array<string | undefined>;
|
||
catalog?: string;
|
||
name: string;
|
||
}
|
||
|
||
function tabClientSessionId(tab: Pick<QueryTab, "id">, suffix?: (typeof BACKGROUND_CLIENT_SESSION_SUFFIXES)[number]): string {
|
||
return suffix ? `${tab.id}:${suffix}` : tab.id;
|
||
}
|
||
|
||
function resultRunCacheKey(tabId: string, runId: string): string {
|
||
return `tab:${tabId}:run:${runId}`;
|
||
}
|
||
|
||
function normalizeOptionalSchema(schema: string | null | undefined): string {
|
||
return schema?.trim() ?? "";
|
||
}
|
||
|
||
function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): Set<string> {
|
||
const schemas = target.schemaCandidates?.length ? target.schemaCandidates : [target.schema];
|
||
return new Set(schemas.map(normalizeOptionalSchema));
|
||
}
|
||
|
||
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
|
||
markRaw(result.rows);
|
||
if (result.large_value_cells) markRaw(result.large_value_cells);
|
||
if (result.mongo_documents) markRaw(result.mongo_documents);
|
||
if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents);
|
||
return result;
|
||
}
|
||
|
||
function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] {
|
||
for (const result of results) markQueryResultRowsRaw(result);
|
||
return results;
|
||
}
|
||
|
||
function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | undefined, pageOffset: number | undefined, useAgentResultSession: boolean | undefined): number | undefined {
|
||
if (typeof pageLimit !== "number" || result.rows.length >= pageLimit || result.truncated === true) return undefined;
|
||
// Cursor-backed drivers must explicitly confirm exhaustion; an omitted
|
||
// has_more value may come from an older driver or an exhausted row cap.
|
||
if (useAgentResultSession && result.has_more !== false) return undefined;
|
||
return (pageOffset ?? 0) + result.rows.length;
|
||
}
|
||
|
||
export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
|
||
if (segment.execution_error) {
|
||
throw segment.error ? new BackendErrorException(segment.error) : new BackendErrorException(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
|
||
}
|
||
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
|
||
throw new Error("Result columns changed while loading the next segment");
|
||
}
|
||
const remainingRows = Math.max(0, maxRows - previous.rows.length);
|
||
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
|
||
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
|
||
if (!existing || !next) return undefined;
|
||
if (existing.length !== previous.rows.length || next.length !== segment.rows.length) return undefined;
|
||
return [...existing, ...next.slice(0, appendedRowCount)];
|
||
};
|
||
const sridByColumn = new Map<number, number | null>();
|
||
for (const column of previous.spatial_columns ?? []) {
|
||
sridByColumn.set(column.column_index, column.srid);
|
||
}
|
||
for (const column of segment.spatial_columns ?? []) {
|
||
const existing = sridByColumn.get(column.column_index) ?? null;
|
||
sridByColumn.set(column.column_index, existing ?? column.srid);
|
||
}
|
||
const spatial_columns = Array.from(sridByColumn.entries())
|
||
.map(([column_index, srid]) => ({ column_index, srid }))
|
||
.sort((a, b) => a.column_index - b.column_index);
|
||
// Keep prior row objects intact so source-index based dirty/new/deleted state
|
||
// remains valid, while bounding the in-memory result by the configured cap.
|
||
return markQueryResultRowsRaw({
|
||
...segment,
|
||
appended_from_row_count: previous.rows.length,
|
||
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
|
||
spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined,
|
||
spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values),
|
||
large_value_cells: appendLargeValueCells(previous.large_value_cells, segment.large_value_cells, previous.rows.length, appendedRowCount),
|
||
mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents),
|
||
mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents),
|
||
execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0),
|
||
has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more,
|
||
});
|
||
}
|
||
|
||
function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
|
||
for (const run of resultRuns) {
|
||
if (run.result) markQueryResultRowsRaw(run.result);
|
||
if (run.results) markQueryResultsRowsRaw(run.results);
|
||
if (run.resultLocalSortOriginalRows) markRaw(run.resultLocalSortOriginalRows);
|
||
if (run.resultLocalSortOriginalLargeValueCells) markRaw(run.resultLocalSortOriginalLargeValueCells);
|
||
if (run.resultLocalSortOriginalMongoDocuments) markRaw(run.resultLocalSortOriginalMongoDocuments);
|
||
if (run.resultLocalSortOriginalMongoCopyDocuments) markRaw(run.resultLocalSortOriginalMongoCopyDocuments);
|
||
}
|
||
return resultRuns;
|
||
}
|
||
|
||
/** Drop heavyweight fields even when a deactivated DataGrid still retains the result object. */
|
||
function releaseResultObjectPayload(result: QueryResult): void {
|
||
result.columns = [];
|
||
result.rows = [];
|
||
result.column_types = undefined;
|
||
result.column_sortables = undefined;
|
||
result.spatial_columns = undefined;
|
||
result.spatial_values = undefined;
|
||
result.hidden_column_indexes = undefined;
|
||
result.local_column_filters = undefined;
|
||
result.local_hidden_column_keys = undefined;
|
||
result.mongo_documents = undefined;
|
||
result.mongo_copy_documents = undefined;
|
||
result.large_value_cells = undefined;
|
||
result.elasticsearch_raw_body = undefined;
|
||
result.messages = undefined;
|
||
result.error = undefined;
|
||
result.sourceLabel = undefined;
|
||
result.sourceStatement = undefined;
|
||
}
|
||
|
||
function preservedResultIndex(results: QueryResult[], currentIndex: number | undefined, preserve: boolean | undefined): number | undefined {
|
||
if (preserve !== true || typeof currentIndex !== "number" || currentIndex < 0 || currentIndex >= results.length) return undefined;
|
||
return currentIndex;
|
||
}
|
||
|
||
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType, sourceOffset?: number, parameterOptions?: SqlParameterOptions): { results: QueryResult[]; sqlServerUseDatabase?: string } {
|
||
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
|
||
let statementIndex = 0;
|
||
let sourceDatabase = database;
|
||
let sqlServerUseDatabase: string | undefined;
|
||
for (const result of results) {
|
||
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
|
||
const sourceIndex = explicitIndex ?? statementIndex;
|
||
statementIndex = Math.max(statementIndex, sourceIndex + 1);
|
||
const statement = statements[sourceIndex];
|
||
if (!statement) continue;
|
||
annotateQueryResultSource(result, statement.sql, sourceDatabase, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
|
||
const customName = queryResultNameFromPreamble(sql.slice(statement.hitFrom, statement.from));
|
||
if (customName) result.sourceLabel = customName;
|
||
const successfulUseDatabase = databaseType === "sqlserver" && result.execution_error !== true ? sqlServerUseDatabaseFromStatement(statement.sql) : undefined;
|
||
if (successfulUseDatabase) {
|
||
sourceDatabase = successfulUseDatabase;
|
||
sqlServerUseDatabase = successfulUseDatabase;
|
||
}
|
||
}
|
||
return { results, sqlServerUseDatabase };
|
||
}
|
||
|
||
const NON_STREAMING_BATCH_DATABASE_TYPES = new Set<DatabaseType>(["sqlserver", "turso", "cloudflare-d1"]);
|
||
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
|
||
|
||
function cloneBatchSqlExecution(batch: BatchSqlExecution | undefined): BatchSqlExecution | undefined {
|
||
return batch ? { ...batch, executionTarget: batch.executionTarget ? { ...batch.executionTarget } : undefined, items: batch.items.map((item) => ({ ...item })) } : undefined;
|
||
}
|
||
|
||
function batchSqlExecutionFor(tab: QueryTab, executionId: string): BatchSqlExecution | undefined {
|
||
const liveBatch = liveBatchSqlExecutions.get(tab);
|
||
if (liveBatch?.executionId === executionId) return liveBatch;
|
||
return tab.batchSqlExecution?.executionId === executionId ? tab.batchSqlExecution : undefined;
|
||
}
|
||
|
||
function clearLiveBatchSqlExecution(tab: QueryTab, executionId: string) {
|
||
if (liveBatchSqlExecutions.get(tab)?.executionId === executionId) liveBatchSqlExecutions.delete(tab);
|
||
}
|
||
|
||
function createBatchSqlExecution(executionId: string, editorSql: string, submittedSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined, executionTarget: MultiDbExecutionTarget, parameterOptions?: SqlParameterOptions): BatchSqlExecution | undefined {
|
||
const statements = databaseType === "mongodb" ? splitMongoCommandRanges(submittedSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(submittedSql, databaseType, parameterOptions);
|
||
if (statements.length === 0) return undefined;
|
||
if (statements.length > 1 && databaseType && NON_STREAMING_BATCH_DATABASE_TYPES.has(databaseType)) return undefined;
|
||
const offset = sourceOffset ?? 0;
|
||
return {
|
||
executionId,
|
||
submittedSql,
|
||
editorFingerprint: sqlTextFingerprint(editorSql),
|
||
sourceOffset: offset,
|
||
completed: 0,
|
||
total: statements.length,
|
||
startedAt: Date.now(),
|
||
executionTarget: { ...executionTarget },
|
||
items: statements.map((statement, statementIndex) => ({
|
||
statementIndex,
|
||
sql: statement.sql,
|
||
from: offset + statement.from,
|
||
to: offset + statement.to,
|
||
status: statementIndex === 0 ? "running" : "pending",
|
||
})),
|
||
};
|
||
}
|
||
|
||
function applyBatchSqlProgress(
|
||
tab: QueryTab,
|
||
progress: {
|
||
executionId: string;
|
||
statementIndex: number;
|
||
completed: number;
|
||
total: number;
|
||
success: boolean;
|
||
executionTimeMs: number;
|
||
affectedRows: number;
|
||
error?: BackendError;
|
||
},
|
||
continueOnError: boolean,
|
||
statementOffset = 0,
|
||
) {
|
||
const batch = batchSqlExecutionFor(tab, progress.executionId);
|
||
if (!batch) return;
|
||
const statementIndex = statementOffset + progress.statementIndex;
|
||
const item = batch.items[statementIndex];
|
||
if (!item) return;
|
||
if (progress.completed > 1) {
|
||
for (let index = statementOffset; index < statementOffset + progress.completed - 1; index += 1) {
|
||
const completedItem = batch.items[index];
|
||
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
|
||
completedItem.status = "success";
|
||
}
|
||
}
|
||
}
|
||
item.status = progress.success ? "success" : "error";
|
||
item.executionTimeMs = progress.executionTimeMs;
|
||
item.affectedRows = progress.affectedRows;
|
||
item.errorDetails = progress.error;
|
||
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
|
||
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
|
||
if ((progress.success || continueOnError) && progress.completed < progress.total) {
|
||
const next = batch.items[statementOffset + progress.completed];
|
||
if (next?.status === "pending") next.status = "running";
|
||
}
|
||
}
|
||
|
||
function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: QueryResult[]) {
|
||
const batch = batchSqlExecutionFor(tab, executionId);
|
||
if (!batch) return;
|
||
let fallbackIndex = 0;
|
||
for (const result of results) {
|
||
const statementIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index! : fallbackIndex;
|
||
fallbackIndex = Math.max(fallbackIndex, statementIndex + 1);
|
||
const item = batch.items[statementIndex];
|
||
if (!item) continue;
|
||
const failed = result.execution_error === true;
|
||
item.status = failed ? "error" : "success";
|
||
item.executionTimeMs = result.execution_time_ms;
|
||
item.affectedRows = result.affected_rows;
|
||
item.errorDetails = failed ? result.error : undefined;
|
||
item.error = failed ? (result.error ? translateBackendError(i18n.global.t, result.error, result.rows[0]?.[0]) : String(result.rows[0]?.[0] ?? "")) : undefined;
|
||
}
|
||
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
|
||
}
|
||
|
||
function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknown, cancelled: boolean) {
|
||
const batch = batchSqlExecutionFor(tab, executionId);
|
||
if (!batch) return;
|
||
const item = batch.items.find((candidate) => candidate.status === "running") ?? batch.items.find((candidate) => candidate.status === "pending");
|
||
if (!item) return;
|
||
item.status = cancelled ? "cancelled" : "error";
|
||
item.errorDetails = cancelled ? undefined : (normalizeBackendError(error) ?? undefined);
|
||
item.error = cancelled ? undefined : translateBackendError(i18n.global.t, error, error instanceof Error ? error.message : undefined);
|
||
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
|
||
}
|
||
|
||
function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled: boolean) {
|
||
const batch = batchSqlExecutionFor(tab, executionId);
|
||
if (!batch) return;
|
||
if (cancelled) {
|
||
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && (item.errorDetails?.code === "DBX-JDBC-2003" || /cancel|取消/i.test(item.error ?? "")));
|
||
if (cancelledError) {
|
||
cancelledError.status = "cancelled";
|
||
cancelledError.error = undefined;
|
||
}
|
||
}
|
||
let markedCancelled = false;
|
||
for (const item of batch.items) {
|
||
if (item.status === "running" && cancelled && !markedCancelled) {
|
||
item.status = "cancelled";
|
||
markedCancelled = true;
|
||
} else if (item.status === "running" || item.status === "pending") {
|
||
item.status = "skipped";
|
||
}
|
||
}
|
||
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
|
||
batch.finishedAt = Date.now();
|
||
}
|
||
|
||
function sqlStatementWithoutLeadingComments(statement: string | undefined): string {
|
||
let remaining = statement?.trimStart() ?? "";
|
||
while (remaining) {
|
||
if (remaining.startsWith("--")) {
|
||
const newline = remaining.indexOf("\n");
|
||
remaining = newline < 0 ? "" : remaining.slice(newline + 1).trimStart();
|
||
continue;
|
||
}
|
||
if (remaining.startsWith("/*")) {
|
||
const end = remaining.indexOf("*/", 2);
|
||
if (end < 0) return "";
|
||
remaining = remaining.slice(end + 2).trimStart();
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
return remaining;
|
||
}
|
||
|
||
function isOracleCurrentSchemaStatement(statement: string | undefined): boolean {
|
||
return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(sqlStatementWithoutLeadingComments(statement));
|
||
}
|
||
|
||
function isSapHanaSetSchemaStatement(statement: string | undefined): boolean {
|
||
return /^SET\s+SCHEMA\s+(?:"(?:[^"]|"")*"|[A-Za-z_][\w$#]*)\s*;?\s*$/i.test(sqlStatementWithoutLeadingComments(statement));
|
||
}
|
||
|
||
function sapHanaCurrentSchemaFromResult(result: QueryResult): string | undefined {
|
||
const schema = result.rows[0]?.[0];
|
||
return typeof schema === "string" && schema.trim() ? schema.trim() : undefined;
|
||
}
|
||
|
||
function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType, sourceRange?: { from: number; to: number }): QueryResult {
|
||
result.sourceStatement = sourceStatement;
|
||
if (sourceRange) {
|
||
result.sourceFrom = sourceRange.from;
|
||
result.sourceTo = sourceRange.to;
|
||
}
|
||
const label = databaseType ? queryResultSourceLabel(sourceStatement, { database, databaseType }) : undefined;
|
||
if (label) result.sourceLabel = label;
|
||
return result;
|
||
}
|
||
|
||
function elasticsearchHttpErrorStatus(result: QueryResult): number | undefined {
|
||
const statusIndex = result.columns.findIndex((column) => column.toLowerCase() === "status");
|
||
if (statusIndex < 0) return undefined;
|
||
const value = result.rows[0]?.[statusIndex];
|
||
const status = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||
return Number.isInteger(status) && status >= 400 ? status : undefined;
|
||
}
|
||
|
||
function displayedQueryMetadataSql(tab: QueryTab, fallbackSql: string): string {
|
||
return tab.results?.length ? (tab.result?.sourceStatement ?? fallbackSql) : fallbackSql;
|
||
}
|
||
|
||
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string): Promise<T> {
|
||
const timeoutMs = frontendQueryTimeoutDelayMs(timeoutSecs);
|
||
if (timeoutMs === undefined) return promise;
|
||
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function withCancelQueryTimeout<T>(promise: Promise<T>): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error("Cancel request timed out after 10s.")), CANCEL_QUERY_TIMEOUT_MS);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function waitForOracleQueryMetadataPreflight<T>(promise: Promise<T>): Promise<T | typeof ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<typeof ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT>((resolve) => {
|
||
timer = setTimeout(() => resolve(ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT), ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function normalizeUppercaseFoldedMetadataIdentifier(dbType: string, identifier: string | undefined, quoted?: boolean) {
|
||
if (!identifier || quoted || !UPPERCASE_FOLDED_METADATA_TYPES.has(dbType)) return identifier;
|
||
return identifier.toUpperCase();
|
||
}
|
||
|
||
function normalizeUppercaseFoldedQueryAnalysis(dbType: string, analysis: EditableQueryInfo, schema: string | undefined, tableName: string): EditableQueryInfo {
|
||
if (!UPPERCASE_FOLDED_METADATA_TYPES.has(dbType)) return analysis;
|
||
return {
|
||
...analysis,
|
||
schema,
|
||
tableName,
|
||
sources: analysis.sources?.map((source) => ({
|
||
...source,
|
||
schema: normalizeUppercaseFoldedMetadataIdentifier(dbType, source.schema, source.schemaQuoted),
|
||
tableName: normalizeUppercaseFoldedMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!,
|
||
})),
|
||
columns: analysis.columns.map((column) => ({
|
||
...column,
|
||
sourceName: normalizeUppercaseFoldedMetadataIdentifier(dbType, column.sourceName, column.sourceNameQuoted),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function editableQuerySources(analysis: EditableQueryInfo): EditableQuerySource[] {
|
||
return analysis.sources?.length
|
||
? analysis.sources
|
||
: [
|
||
{
|
||
key: `${analysis.tableAlias ?? analysis.tableName}:0`,
|
||
catalog: analysis.catalog,
|
||
catalogQuoted: analysis.catalogQuoted,
|
||
schema: analysis.schema,
|
||
schemaQuoted: analysis.schemaQuoted,
|
||
tableName: analysis.tableName,
|
||
tableNameQuoted: analysis.tableNameQuoted,
|
||
alias: analysis.tableAlias,
|
||
},
|
||
];
|
||
}
|
||
|
||
function projectsAllColumnsForSource(analysis: EditableQueryInfo, sourceKey: string): boolean {
|
||
return analysis.selectStar || analysis.columns.some((column) => column.star && (!column.sourceKey || column.sourceKey === sourceKey));
|
||
}
|
||
|
||
function oracleQueryProjectsDeferredLob(analysis: EditableQueryInfo, sourceKey: string, columns: readonly { name: string; data_type: string }[]): boolean {
|
||
const deferredColumns = new Set(columns.filter((column) => ORACLE_DEFERRED_LOB_TYPES.has(column.data_type.trim().toUpperCase())).map((column) => column.name.toLowerCase()));
|
||
if (deferredColumns.size === 0) return false;
|
||
if (projectsAllColumnsForSource(analysis, sourceKey)) return true;
|
||
return analysis.columns.some((column) => column.sourceName && column.sourceKey === sourceKey && deferredColumns.has(column.sourceName.toLowerCase()));
|
||
}
|
||
|
||
function oracleColumnsAllowDeferredLobMarkers(columns: readonly { name: string }[]): boolean {
|
||
return !columns.some((column) => column.name.toUpperCase().startsWith("__DBX_LARGE_VALUE_BYTES_"));
|
||
}
|
||
|
||
function cloneAnalysisForSource(analysis: EditableQueryInfo, source: EditableQuerySource): EditableQueryInfo {
|
||
return {
|
||
...analysis,
|
||
catalog: source.catalog,
|
||
catalogQuoted: source.catalogQuoted,
|
||
schema: source.schema,
|
||
schemaQuoted: source.schemaQuoted,
|
||
tableName: source.tableName,
|
||
tableNameQuoted: source.tableNameQuoted,
|
||
tableAlias: source.alias,
|
||
editableSourceKey: source.key,
|
||
allowInsertDelete: analysis.sources?.length || analysis.distinct ? false : analysis.allowInsertDelete,
|
||
};
|
||
}
|
||
|
||
function resolveSourceColumnName(dbType: string, columnName: string, quoted: boolean | undefined, tableColumns: readonly { name: string }[]): string | undefined {
|
||
return resolveMetadataColumnName(
|
||
dbType,
|
||
columnName,
|
||
quoted,
|
||
tableColumns.map((column) => column.name),
|
||
);
|
||
}
|
||
|
||
function bindColumnsForSource(
|
||
dbType: string,
|
||
analysis: EditableQueryInfo,
|
||
source: EditableQuerySource,
|
||
tableColumns: readonly { name: string }[],
|
||
allSourceColumns: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }> = [{ source, columns: tableColumns }],
|
||
): EditableQueryInfo {
|
||
return {
|
||
...analysis,
|
||
columns: analysis.columns.map((column) => {
|
||
if (!column.sourceName) return column;
|
||
if (column.sourceKey) {
|
||
if (column.sourceKey !== source.key) return column;
|
||
if (dbType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return column;
|
||
const canonicalName = resolveSourceColumnName(dbType, column.sourceName, column.sourceNameQuoted, tableColumns);
|
||
return { ...column, sourceName: canonicalName };
|
||
}
|
||
if (column.sourceQualifier) return column;
|
||
const matchingSources = allSourceColumns.flatMap((entry) => {
|
||
const canonicalName = resolveSourceColumnName(dbType, column.sourceName!, column.sourceNameQuoted, entry.columns);
|
||
return canonicalName ? [{ source: entry.source, canonicalName }] : [];
|
||
});
|
||
if (matchingSources.length !== 1 || matchingSources[0]?.source.key !== source.key) return column;
|
||
return { ...column, sourceName: matchingSources[0].canonicalName, sourceKey: source.key };
|
||
}),
|
||
};
|
||
}
|
||
|
||
function primaryKeysPresentForSource(dbType: string, primaryKeys: string[], resultColumns: string[], analysis: EditableQueryInfo, sourceKey: string, tableColumns: readonly { name: string }[]): boolean {
|
||
if (!analysis.selectStar) return allPrimaryKeysPresent(primaryKeys, resultColumns, analysis, sourceKey, dbType as DatabaseType);
|
||
const metadataNames = tableColumns.map((column) => column.name);
|
||
const canonicalResultColumns = resultColumns.flatMap((column) => {
|
||
const canonicalName = resolveMetadataColumnName(dbType, column, undefined, metadataNames);
|
||
return canonicalName ? [canonicalName] : [];
|
||
});
|
||
return allPrimaryKeysPresent(primaryKeys, canonicalResultColumns);
|
||
}
|
||
|
||
function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[]): EditableQueryInfo {
|
||
if (analysis.selectStar || !analysis.columns.some((column) => column.star)) return analysis;
|
||
return {
|
||
...analysis,
|
||
columns: analysis.columns.flatMap((column) => {
|
||
if (!column.star) return [column];
|
||
if (column.sourceKey && column.sourceKey !== source.key) return [column];
|
||
return tableColumns.map((tableColumn) => ({
|
||
sourceName: tableColumn.name,
|
||
sourceNameQuoted: false,
|
||
...(column.sourceQualifier ? { sourceQualifier: column.sourceQualifier } : {}),
|
||
sourceKey: source.key,
|
||
resultName: tableColumn.name,
|
||
expression: column.sourceQualifier ? `${column.sourceQualifier}.${tableColumn.name}` : tableColumn.name,
|
||
}));
|
||
}),
|
||
};
|
||
}
|
||
|
||
let saveTabsQueue = Promise.resolve();
|
||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let persistGeneration = 0;
|
||
|
||
function saveTabs(tabs: QueryTab[], activeTabId: string | null, workspace?: EditorWorkspacePersistState): Promise<void> {
|
||
if (isDetachedWindow()) return Promise.resolve();
|
||
const payload: OpenTabsStatePayload = {
|
||
tabs: serializeOpenTabs(tabs),
|
||
activeTabId,
|
||
...(workspace && tabs.length > 0
|
||
? {
|
||
groups: workspace.groups.map((group) => ({ ...group, tabIds: [...group.tabIds] })),
|
||
focusedGroupId: workspace.focusedGroupId,
|
||
orientation: workspace.orientation,
|
||
sizes: [...workspace.sizes],
|
||
}
|
||
: {}),
|
||
};
|
||
saveTabsQueue = saveTabsQueue.catch(() => undefined).then(() => api.saveOpenTabsState(payload));
|
||
return saveTabsQueue;
|
||
}
|
||
|
||
function loadLegacySavedTabs(): { rawTabs: string | null; rawActiveTabId: string | null } {
|
||
return {
|
||
rawTabs: safeLocalStorageGet(OPEN_TABS_STORAGE_KEY),
|
||
rawActiveTabId: safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY),
|
||
};
|
||
}
|
||
|
||
function clearLegacySavedTabs() {
|
||
safeLocalStorageRemove(OPEN_TABS_STORAGE_KEY);
|
||
safeLocalStorageRemove(ACTIVE_TAB_STORAGE_KEY);
|
||
}
|
||
|
||
function restoreSavedTabsFromPayload(
|
||
payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
|
||
options: { validConnectionIds?: Iterable<string> } = {},
|
||
): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
|
||
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
|
||
if (restoreMode === "none") {
|
||
return { tabs: [], activeTabId: null, workspace: undefined };
|
||
}
|
||
const restored = restoreOpenTabsPayload(payload, {
|
||
filter: restoreMode === "pinned" ? "pinned" : "all",
|
||
validConnectionIds: options.validConnectionIds,
|
||
});
|
||
return {
|
||
...restored,
|
||
workspace:
|
||
payload && "groups" in payload
|
||
? {
|
||
groups: payload.groups,
|
||
focusedGroupId: payload.focusedGroupId,
|
||
orientation: payload.orientation,
|
||
sizes: payload.sizes,
|
||
}
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable<string> } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
|
||
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
|
||
if (restoreMode === "none") {
|
||
return { tabs: [], activeTabId: null, workspace: undefined };
|
||
}
|
||
const legacy = loadLegacySavedTabs();
|
||
return {
|
||
...restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, {
|
||
filter: restoreMode === "pinned" ? "pinned" : "all",
|
||
validConnectionIds: options.validConnectionIds,
|
||
}),
|
||
workspace: undefined,
|
||
};
|
||
}
|
||
|
||
function getI18nT() {
|
||
try {
|
||
return useI18n().t;
|
||
} catch {
|
||
return ((key: string, ..._args: unknown[]) => key) as ReturnType<typeof useI18n>["t"];
|
||
}
|
||
}
|
||
|
||
export const useQueryStore = defineStore("query", () => {
|
||
const redisMonitors = new Map<string, () => void>();
|
||
const t = getI18nT();
|
||
const settingsStore = useSettingsStore();
|
||
const tabs = ref<QueryTab[]>([]);
|
||
// New tabs are created by many feature-specific entry points. Tracking id
|
||
// changes here gives every one a stable timestamp without duplicating the
|
||
// field at each call site; restored legacy tabs retain their saved order.
|
||
function ensureTabCreationTimes() {
|
||
const migrationBase = Date.now() - tabs.value.length;
|
||
tabs.value.forEach((tab, index) => {
|
||
if (typeof tab.createdAt !== "number" || !Number.isFinite(tab.createdAt)) tab.createdAt = migrationBase + index;
|
||
});
|
||
}
|
||
watch(() => tabs.value.map((tab) => tab.id), ensureTabCreationTimes, { immediate: true, flush: "sync" });
|
||
// Default auto-commit for a fresh query tab follows the user's global
|
||
// "默认事务提交方式" setting (Settings > Editor), not a hard-coded constant.
|
||
const defaultAutoCommitForDbTypeWithSetting = (dbType?: string) => defaultAutoCommitForDbType(dbType, settingsStore.editorSettings.defaultTransactionMode);
|
||
// A stable Set of "connectionId\x00database" keys. Computed only from the
|
||
// minimal tab identity fields so that it does NOT invalidate when other
|
||
// properties change (isExecuting, result, sql, tableMeta...). Previously
|
||
// isDatabaseOpen() called tabs.value.some() which tracked the full reactive
|
||
// array — every mutation during openData() forced all database-type sidebar
|
||
// TreeItems to recompute showsDatabaseOpenIndicator.
|
||
const openDatabaseKeys = computed(() => {
|
||
const keys = new Set<string>();
|
||
for (const tab of tabs.value) {
|
||
if (tab.connectionId && tab.database != null) {
|
||
keys.add(`${tab.connectionId}\x00${tab.database}`);
|
||
}
|
||
}
|
||
return keys;
|
||
});
|
||
const activeTabId = ref<string | null>(null);
|
||
const groups = ref<EditorGroup[]>([{ id: "main", tabIds: [], activeTabId: null }]);
|
||
const focusedGroupId = ref("main");
|
||
const orientation = ref<"vertical" | "horizontal">("vertical");
|
||
const sizes = ref<number[]>([100]);
|
||
const isOpenTabsLoaded = ref(false);
|
||
const activeTabHistory = ref<string[]>([]);
|
||
// Most-recently-activated tab ids, oldest first. Read-only view for the
|
||
// Ctrl+Tab switcher, which renders them in reverse.
|
||
const recentTabIds = computed(() => activeTabHistory.value);
|
||
|
||
function findGroup(groupId: string): EditorGroup | undefined {
|
||
return groups.value.find((group) => group.id === groupId);
|
||
}
|
||
|
||
function focusedGroup(): EditorGroup {
|
||
return findGroup(focusedGroupId.value) ?? groups.value[0];
|
||
}
|
||
|
||
function groupForTab(tabId: string): EditorGroup | undefined {
|
||
return groups.value.find((group) => group.tabIds.includes(tabId));
|
||
}
|
||
|
||
function syncActiveTabFromFocusedGroup() {
|
||
const group = focusedGroup();
|
||
if (!group) {
|
||
return;
|
||
}
|
||
if (group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId)) {
|
||
activeTabId.value = group.activeTabId;
|
||
return;
|
||
}
|
||
const firstAvailable = groups.value.find((candidate) => candidate.tabIds.length > 0)?.tabIds.find((id) => tabs.value.some((tab) => tab.id === id)) ?? tabs.value[0]?.id ?? null;
|
||
if (firstAvailable) {
|
||
activeTabId.value = firstAvailable;
|
||
if (group.tabIds.includes(firstAvailable)) {
|
||
group.activeTabId = firstAvailable;
|
||
}
|
||
} else {
|
||
group.activeTabId = null;
|
||
activeTabId.value = null;
|
||
}
|
||
}
|
||
|
||
function normalizeGroups() {
|
||
const validIds = new Set(tabs.value.map((tab) => tab.id));
|
||
|
||
// Group ids must be unique and non-empty; keep only the first occurrence.
|
||
const seenGroupIds = new Set<string>();
|
||
groups.value = groups.value.filter((group) => {
|
||
if (!group.id || seenGroupIds.has(group.id)) {
|
||
return false;
|
||
}
|
||
seenGroupIds.add(group.id);
|
||
return true;
|
||
});
|
||
|
||
// Enforce the four-group limit before assigning missing tabs, so tabs from
|
||
// dropped groups are merged into a kept group instead of becoming ownerless.
|
||
if (groups.value.length > 1) {
|
||
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
|
||
}
|
||
if (groups.value.length > 4) {
|
||
const target = groups.value[0];
|
||
for (const group of groups.value.slice(4)) {
|
||
target.tabIds.push(...group.tabIds);
|
||
}
|
||
groups.value = groups.value.slice(0, 4);
|
||
}
|
||
|
||
// A tab may only belong to the first group that claims it.
|
||
const globallyAssigned = new Set<string>();
|
||
for (const group of groups.value) {
|
||
const nextTabIds: string[] = [];
|
||
for (const id of group.tabIds) {
|
||
if (!validIds.has(id) || globallyAssigned.has(id)) {
|
||
continue;
|
||
}
|
||
globallyAssigned.add(id);
|
||
nextTabIds.push(id);
|
||
}
|
||
group.tabIds = nextTabIds;
|
||
if (group.activeTabId && !group.tabIds.includes(group.activeTabId)) {
|
||
group.activeTabId = group.tabIds[0] ?? null;
|
||
}
|
||
}
|
||
|
||
const assigned = new Set(groups.value.flatMap((group) => group.tabIds));
|
||
const missing = tabs.value.filter((tab) => !assigned.has(tab.id));
|
||
if (missing.length > 0) {
|
||
const target = focusedGroup() ?? groups.value[0];
|
||
if (target) {
|
||
for (const tab of missing) {
|
||
target.tabIds.push(tab.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (groups.value.length > 1) {
|
||
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
|
||
}
|
||
if (groups.value.length === 0) {
|
||
groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
|
||
}
|
||
if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
|
||
focusedGroupId.value = groups.value[0].id;
|
||
}
|
||
|
||
// Restore boundary order (guide §3.9): repair each group's active tab,
|
||
// then repair sizes, and only at the very end derive the global active tab
|
||
// from the focused group. A stale persisted activeTabId must never pull
|
||
// focus (or the group active) toward another group.
|
||
for (const group of groups.value) {
|
||
if (!group.activeTabId || !group.tabIds.includes(group.activeTabId)) {
|
||
group.activeTabId = group.tabIds[0] ?? null;
|
||
}
|
||
}
|
||
|
||
repairGroupSizes();
|
||
|
||
syncActiveTabFromFocusedGroup();
|
||
}
|
||
|
||
/** Keeps `sizes` aligned with the current group count after structural changes. */
|
||
function repairGroupSizes() {
|
||
const sizeCount = groups.value.length;
|
||
const validSizes = Array.isArray(sizes.value) && sizes.value.length === sizeCount && sizes.value.every((size) => Number.isFinite(size) && size > 0);
|
||
if (!validSizes) {
|
||
const each = Math.floor(100 / sizeCount);
|
||
sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
|
||
return;
|
||
}
|
||
const total = sizes.value.reduce((sum, size) => sum + size, 0);
|
||
if (total <= 0) {
|
||
const each = Math.floor(100 / sizeCount);
|
||
sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
|
||
} else if (Math.abs(total - 100) > 0.01) {
|
||
sizes.value = sizes.value.map((size) => (size / total) * 100);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drops groups that no longer own any tab, keeps one (possibly empty) main
|
||
* group, re-points the focused group, and re-pairs sizes. Structural store
|
||
* operations call this directly instead of leaning on a full normalization
|
||
* pass or a post-flush watcher.
|
||
*/
|
||
function pruneEmptyGroups() {
|
||
if (groups.value.length > 1) {
|
||
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
|
||
}
|
||
if (groups.value.length === 0) {
|
||
groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
|
||
}
|
||
if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
|
||
focusedGroupId.value = groups.value[0].id;
|
||
}
|
||
repairGroupSizes();
|
||
}
|
||
|
||
function focusGroup(groupId: string) {
|
||
const group = findGroup(groupId) ?? groups.value[0];
|
||
if (!group) {
|
||
return;
|
||
}
|
||
focusedGroupId.value = group.id;
|
||
const candidate = group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId) ? group.activeTabId : (group.tabIds.find((tabId) => tabs.value.some((tab) => tab.id === tabId)) ?? null);
|
||
if (candidate) {
|
||
group.activeTabId = candidate;
|
||
activeTabId.value = candidate;
|
||
}
|
||
// If the focused group has no valid active tab, keep the current global
|
||
// active tab rather than clearing it and unmounting the workspace.
|
||
settingsStore.settingsPageActive = false;
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
|
||
}
|
||
}
|
||
|
||
function activateTabInGroup(groupId: string, tabId: string) {
|
||
const group = findGroup(groupId);
|
||
if (!group || !group.tabIds.includes(tabId)) {
|
||
return;
|
||
}
|
||
group.activeTabId = tabId;
|
||
focusedGroupId.value = group.id;
|
||
activeTabId.value = tabId;
|
||
settingsStore.settingsPageActive = false;
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
|
||
}
|
||
}
|
||
|
||
function activateTab(tabId: string): boolean {
|
||
const owner = groupForTab(tabId);
|
||
if (!owner) {
|
||
return false;
|
||
}
|
||
owner.activeTabId = tabId;
|
||
focusedGroupId.value = owner.id;
|
||
activeTabId.value = tabId;
|
||
settingsStore.settingsPageActive = false;
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function initializeResultAutoSave(tab: QueryTab) {
|
||
if (tab.mode === "query" && tab.resultAutoSave === undefined && settingsStore.editorSettings.defaultAutoKeepResults === true) {
|
||
tab.resultAutoSave = true;
|
||
}
|
||
}
|
||
|
||
// Apply after the settings mutation completes, never during tab registration
|
||
// or restoration while the workspace still has incomplete group membership.
|
||
watch(
|
||
() => settingsStore.editorSettings.defaultAutoKeepResults,
|
||
(enabled) => {
|
||
for (const tab of tabs.value) {
|
||
if (tab.mode === "query") setResultAutoSave(tab, enabled === true);
|
||
}
|
||
},
|
||
);
|
||
|
||
/**
|
||
* Atomically registers a freshly built tab: the tab joins the focused group
|
||
* and (by default) becomes that group's active tab and the global active tab
|
||
* in one synchronous step. Programmatic open paths must use this instead of
|
||
* pushing to `tabs` and writing `activeTabId` separately, which used to leave
|
||
* the new tab ownerless until a post-flush watcher repaired the groups.
|
||
*/
|
||
function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
|
||
assertUpdateAllowsInteraction();
|
||
initializeResultAutoSave(tab);
|
||
const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
|
||
if (anchorIndex >= 0) {
|
||
tabs.value.splice(anchorIndex + 1, 0, tab);
|
||
} else {
|
||
tabs.value.push(tab);
|
||
}
|
||
const group = focusedGroup() ?? groups.value[0];
|
||
if (group && !group.tabIds.includes(tab.id)) {
|
||
const groupAnchorIndex = options.insertAfterTabId ? group.tabIds.indexOf(options.insertAfterTabId) : -1;
|
||
if (groupAnchorIndex >= 0) {
|
||
// Fresh tabs are never pinned; keep them behind the group's pinned block.
|
||
const pinnedCount = group.tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
|
||
const insertAt = tab.pinned ? groupAnchorIndex + 1 : Math.max(groupAnchorIndex + 1, pinnedCount);
|
||
group.tabIds.splice(insertAt, 0, tab.id);
|
||
} else {
|
||
group.tabIds.push(tab.id);
|
||
}
|
||
}
|
||
if (options.activate !== false) {
|
||
activateTab(tab.id);
|
||
}
|
||
return tab.id;
|
||
}
|
||
|
||
function splitTab(tabId: string, direction: "right" | "down"): boolean {
|
||
if (groups.value.length >= 4) {
|
||
return false;
|
||
}
|
||
// With a single open tab the split is a no-op: the tab moves into the new
|
||
// group, the emptied source group is pruned, and the layout returns to one
|
||
// group holding the same tab (only the group identity churns).
|
||
if (tabs.value.length <= 1) {
|
||
return false;
|
||
}
|
||
const tab = tabs.value.find((item) => item.id === tabId);
|
||
if (!tab) {
|
||
return false;
|
||
}
|
||
const owner = groupForTab(tabId);
|
||
if (!owner) {
|
||
return false;
|
||
}
|
||
|
||
orientation.value = direction === "right" ? "vertical" : "horizontal";
|
||
|
||
const newGroupId = uuid();
|
||
const newGroup: EditorGroup = { id: newGroupId, tabIds: [tabId], activeTabId: tabId };
|
||
const ownerIndex = groups.value.findIndex((group) => group.id === owner.id);
|
||
owner.tabIds = owner.tabIds.filter((id) => id !== tabId);
|
||
if (owner.activeTabId === tabId) {
|
||
owner.activeTabId = owner.tabIds[0] ?? null;
|
||
}
|
||
groups.value.splice(ownerIndex + 1, 0, newGroup);
|
||
// Removing the tab may leave the source group empty; pruning keeps sizes
|
||
// paired with the surviving groups without a full normalization pass.
|
||
pruneEmptyGroups();
|
||
focusedGroupId.value = newGroup.id;
|
||
activeTabId.value = tabId;
|
||
return true;
|
||
}
|
||
|
||
function splitTabRight(tabId: string): boolean {
|
||
return splitTab(tabId, "right");
|
||
}
|
||
|
||
function splitTabDown(tabId: string): boolean {
|
||
return splitTab(tabId, "down");
|
||
}
|
||
|
||
function setOrientation(value: "vertical" | "horizontal") {
|
||
orientation.value = value;
|
||
}
|
||
|
||
function moveTabToGroup(tabId: string, targetGroupId: string, index?: number): boolean {
|
||
const tab = tabs.value.find((item) => item.id === tabId);
|
||
const target = findGroup(targetGroupId);
|
||
if (!tab || !target) {
|
||
return false;
|
||
}
|
||
|
||
const source = groupForTab(tabId);
|
||
if (!source) {
|
||
return false;
|
||
}
|
||
|
||
function pinnedCountIn(tabIds: string[]): number {
|
||
return tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
|
||
}
|
||
|
||
if (source.id === target.id) {
|
||
if (index === undefined) {
|
||
return false;
|
||
}
|
||
const fromIndex = target.tabIds.indexOf(tabId);
|
||
if (fromIndex < 0) {
|
||
return false;
|
||
}
|
||
const next = [...target.tabIds];
|
||
next.splice(fromIndex, 1);
|
||
let insertAt = index;
|
||
if (fromIndex < insertAt) {
|
||
insertAt -= 1;
|
||
}
|
||
const pinnedCount = pinnedCountIn(next);
|
||
if (tab.pinned) {
|
||
insertAt = Math.max(0, Math.min(insertAt, pinnedCount));
|
||
} else {
|
||
insertAt = Math.max(pinnedCount, Math.min(insertAt, next.length));
|
||
}
|
||
next.splice(insertAt, 0, tabId);
|
||
target.tabIds = next;
|
||
return true;
|
||
}
|
||
|
||
source.tabIds = source.tabIds.filter((id) => id !== tabId);
|
||
if (source.activeTabId === tabId) {
|
||
source.activeTabId = source.tabIds[0] ?? null;
|
||
}
|
||
|
||
const pinnedCount = pinnedCountIn(target.tabIds);
|
||
const rawIndex = index ?? target.tabIds.length;
|
||
const insertAt = tab.pinned ? Math.max(0, Math.min(rawIndex, pinnedCount)) : Math.max(pinnedCount, Math.min(rawIndex, target.tabIds.length));
|
||
target.tabIds.splice(insertAt, 0, tabId);
|
||
|
||
target.activeTabId = tabId;
|
||
focusedGroupId.value = target.id;
|
||
activeTabId.value = tabId;
|
||
pruneEmptyGroups();
|
||
return true;
|
||
}
|
||
|
||
function unsplitTab(tabId: string): boolean {
|
||
const main = groups.value[0];
|
||
if (!main) {
|
||
return false;
|
||
}
|
||
if (main.tabIds.includes(tabId)) {
|
||
focusGroup(main.id);
|
||
return true;
|
||
}
|
||
const source = groupForTab(tabId);
|
||
if (!source) {
|
||
return false;
|
||
}
|
||
|
||
source.tabIds = source.tabIds.filter((id) => id !== tabId);
|
||
if (source.activeTabId === tabId) {
|
||
source.activeTabId = source.tabIds[0] ?? null;
|
||
}
|
||
main.tabIds.push(tabId);
|
||
main.activeTabId = tabId;
|
||
focusedGroupId.value = main.id;
|
||
activeTabId.value = tabId;
|
||
pruneEmptyGroups();
|
||
return true;
|
||
}
|
||
|
||
const showCloseConfirm = ref(false);
|
||
const pendingCloseTabId = ref<string | null>(null);
|
||
const pendingBatchCloseTabIds = ref<string[] | null>(null);
|
||
const pendingBatchCloseFinalActiveTabId = ref<string | null | undefined>(undefined);
|
||
let pendingBatchCloseComplete: (() => void) | null = null;
|
||
const isConfirmingAppClose = ref(false);
|
||
const closeConfirmContext = ref<CloseConfirmContext>("tab");
|
||
const tableStructureRefreshVersions = ref<Record<string, number>>({});
|
||
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||
const pendingTabSessionResets = new Map<string, Promise<void>>();
|
||
const pendingResultRunRestores = new Map<string, string>();
|
||
const pendingResultRunPreparations = new WeakMap<QueryTab, string>();
|
||
const multiDbExecutionWorkers = new Map<string, QueryTab>();
|
||
const multiDbExecutionWorkerScopes = new Map<string, Set<string>>();
|
||
let resultCacheTrimScheduled = false;
|
||
let resultCacheTrimRunning = false;
|
||
let resultCacheTrimRequested = false;
|
||
let detachedHandoffRevision = 0;
|
||
|
||
function tableStructureKey(connectionId: string, database: string, schema: string | undefined, tableName: string): string {
|
||
return [connectionId, database, schema || "", tableName].map((part) => part.toLowerCase()).join("\u0000");
|
||
}
|
||
|
||
function invalidateTableStructure(connectionId: string, database: string, schema: string | undefined, tableName: string) {
|
||
if (!tableName) return;
|
||
const key = tableStructureKey(connectionId, database, schema, tableName);
|
||
tableStructureRefreshVersions.value = {
|
||
...tableStructureRefreshVersions.value,
|
||
[key]: (tableStructureRefreshVersions.value[key] ?? 0) + 1,
|
||
};
|
||
for (const tab of tabs.value) {
|
||
if (tab.mode !== "query" || tab.connectionId !== connectionId || tab.database !== database) continue;
|
||
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
function tableStructureRefreshVersion(connectionId: string, database: string, schema: string | undefined, tableName: string): number {
|
||
return tableStructureRefreshVersions.value[tableStructureKey(connectionId, database, schema, tableName)] ?? 0;
|
||
}
|
||
const MAX_CACHED_RESULTS = 5;
|
||
const MAX_CACHED_RESULT_BYTES = 128 * 1024 * 1024;
|
||
|
||
function queryExecutionLog(level: "debug" | "info" | "warn" | "error", event: string, details: Record<string, unknown>) {
|
||
appendDebugLog(level, `[DBX][executeTabSql:${event}]`, details);
|
||
}
|
||
|
||
function findExecutionTab(id: string): QueryTab | undefined {
|
||
return tabs.value.find((tab) => tab.id === id) ?? multiDbExecutionWorkers.get(id);
|
||
}
|
||
|
||
function getExecutionTab(id: string): QueryTab | undefined {
|
||
return findExecutionTab(id);
|
||
}
|
||
|
||
function activeResultExecutionTarget(id: string): MultiDbExecutionTarget | undefined {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.activeResultRunId) return undefined;
|
||
const run = tab.resultRuns?.find((candidate) => candidate.id === tab.activeResultRunId);
|
||
return run?.multiDbExecution?.kind === "multi-db" ? run.multiDbExecution.target : undefined;
|
||
}
|
||
|
||
function queryResultExecutionLocation(tab: QueryTab) {
|
||
const target = activeResultExecutionTarget(tab.id);
|
||
return {
|
||
connectionId: target?.connectionId ?? tab.connectionId,
|
||
database: target?.database ?? tab.database,
|
||
schema: target?.schema ?? tab.schema,
|
||
catalog: target?.catalog ?? tab.catalog,
|
||
};
|
||
}
|
||
|
||
function createMultiDbExecutionWorker(sourceTabId: string, target: MultiDbExecutionTarget, scopeId: string): string | undefined {
|
||
const source = tabs.value.find((tab) => tab.id === sourceTabId);
|
||
if (!source) return undefined;
|
||
const id = `multi-worker-${uuid()}`;
|
||
const worker = reactive<QueryTab>({
|
||
...source,
|
||
id,
|
||
connectionId: target.connectionId,
|
||
catalog: target.catalog,
|
||
database: target.database,
|
||
schema: target.schema,
|
||
sql: source.sql,
|
||
result: undefined,
|
||
results: undefined,
|
||
activeResultIndex: undefined,
|
||
resultRuns: undefined,
|
||
activeResultRunId: undefined,
|
||
resultCacheKey: undefined,
|
||
resultSessionId: undefined,
|
||
resultClientSessionId: undefined,
|
||
resultCacheState: undefined,
|
||
resultEvicted: undefined,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
executionId: undefined,
|
||
executingResultRunId: undefined,
|
||
queryExecutionStartedAt: undefined,
|
||
batchSqlExecution: undefined,
|
||
cancelRequestCount: 0,
|
||
autoCommit: true,
|
||
txnSessionId: undefined,
|
||
txnAutoRolledBack: undefined,
|
||
});
|
||
multiDbExecutionWorkers.set(id, worker);
|
||
const workers = multiDbExecutionWorkerScopes.get(scopeId) ?? new Set<string>();
|
||
workers.add(id);
|
||
multiDbExecutionWorkerScopes.set(scopeId, workers);
|
||
return id;
|
||
}
|
||
|
||
async function removeMultiDbExecutionWorker(workerId: string, scopeId?: string): Promise<void> {
|
||
const worker = multiDbExecutionWorkers.get(workerId);
|
||
const transferredResultSessionId = worker?.resultSessionId && tabs.value.some((tab) => tab.resultRuns?.some((run) => run.resultSessionId === worker.resultSessionId)) ? worker.resultSessionId : undefined;
|
||
await closeResultSession(worker, transferredResultSessionId);
|
||
if (transferredResultSessionId && worker) {
|
||
const connection = useConnectionStore().getConfig(worker.connectionId);
|
||
const executionDatabase = dataTabExecutionDatabase(connection, worker.database, worker.catalog);
|
||
for (const suffix of BACKGROUND_CLIENT_SESSION_SUFFIXES) {
|
||
await closeClientSessionId(worker.connectionId, executionDatabase, tabClientSessionId(worker, suffix), worker.catalog, { tabId: worker.id });
|
||
}
|
||
} else {
|
||
await closeClientConnectionSession(worker);
|
||
}
|
||
pendingTabSessionResets.delete(workerId);
|
||
liveBatchSqlExecutions.delete(worker as QueryTab);
|
||
multiDbExecutionWorkers.delete(workerId);
|
||
if (scopeId) {
|
||
const workers = multiDbExecutionWorkerScopes.get(scopeId);
|
||
workers?.delete(workerId);
|
||
if (workers?.size === 0) multiDbExecutionWorkerScopes.delete(scopeId);
|
||
} else {
|
||
for (const [scope, workers] of multiDbExecutionWorkerScopes) {
|
||
workers.delete(workerId);
|
||
if (workers.size === 0) multiDbExecutionWorkerScopes.delete(scope);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cancelMultiDbExecutionScope(scopeId: string): Promise<void> {
|
||
const workers = [...(multiDbExecutionWorkerScopes.get(scopeId) ?? [])];
|
||
await Promise.all(workers.map((workerId) => cancelTabExecution(workerId)));
|
||
}
|
||
|
||
async function refreshLoadedMongoIndexesAfterMutation(connectionId: string, database: string, collection: string, traceId: string) {
|
||
const connStore = useConnectionStore();
|
||
try {
|
||
await refreshLoadedMongoIndexes(connStore, { connectionId, database, collection });
|
||
} catch (error) {
|
||
queryExecutionLog("warn", "mongo-indexes:refresh-failed", {
|
||
traceId,
|
||
connectionId,
|
||
database,
|
||
collection,
|
||
error: formatError(error),
|
||
});
|
||
}
|
||
}
|
||
|
||
async function closeResultSession(tab: QueryTab | undefined, preserveSessionId?: string, throwOnError = false) {
|
||
const sessionId = tab?.resultSessionId ?? tab?.result?.session_id;
|
||
if (!tab || !sessionId || sessionId === preserveSessionId) return;
|
||
const resultClientSessionId = tab.resultClientSessionId;
|
||
const catalog = tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog;
|
||
const location = tab.mode === "query" ? queryResultExecutionLocation(tab) : { connectionId: tab.connectionId, database: tab.database, catalog };
|
||
const connection = location.catalog ? useConnectionStore().getConfig(location.connectionId) : undefined;
|
||
const executionDatabase = dataTabExecutionDatabase(connection, location.database, location.catalog);
|
||
try {
|
||
const clientSessionId = tab.resultClientSessionId ?? tab.id;
|
||
if (location.catalog) await api.closeQuerySession(location.connectionId, executionDatabase, sessionId, clientSessionId, location.catalog);
|
||
else await api.closeQuerySession(location.connectionId, executionDatabase, sessionId, clientSessionId);
|
||
} catch (error) {
|
||
console.warn("[DBX][query-session:close:error]", { tabId: tab.id, sessionId, error });
|
||
if (throwOnError) throw error;
|
||
} finally {
|
||
if (tab.resultSessionId === sessionId) tab.resultSessionId = undefined;
|
||
if (!tab.resultSessionId) tab.resultClientSessionId = undefined;
|
||
if (tab.result?.session_id === sessionId) {
|
||
tab.result.session_id = undefined;
|
||
// 原地修改了负载,让持有它的 tab 与 run 的估算值都失效
|
||
invalidateResultEstimateForPayload(tab.result);
|
||
}
|
||
if (resultClientSessionId && resultClientSessionId !== tab.id) {
|
||
await closeClientSessionId(location.connectionId, executionDatabase, resultClientSessionId, location.catalog, { tabId: tab.id }, throwOnError);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function closeClientSessionId(connectionId: string, database: string, clientSessionId: string, catalog: string | undefined, logContext: Record<string, unknown> = {}, throwOnError = false) {
|
||
try {
|
||
if (catalog) await api.closeClientConnectionSession(connectionId, database, clientSessionId, catalog);
|
||
else await api.closeClientConnectionSession(connectionId, database, clientSessionId);
|
||
} catch (error) {
|
||
console.warn("[DBX][client-session:close:error]", { ...logContext, clientSessionId, error });
|
||
if (throwOnError) throw error;
|
||
}
|
||
}
|
||
|
||
async function closeClientConnectionSession(tab: QueryTab | undefined, throwOnError = false) {
|
||
if (!tab?.connectionId) return;
|
||
const catalog = tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog;
|
||
const connection = catalog ? useConnectionStore().getConfig(tab.connectionId) : undefined;
|
||
const executionDatabase = dataTabExecutionDatabase(connection, tab.database, catalog);
|
||
const clientSessionIds = [...new Set([tabClientSessionId(tab), ...BACKGROUND_CLIENT_SESSION_SUFFIXES.map((suffix) => tabClientSessionId(tab, suffix)), tab.explainClientSessionId].filter((sessionId): sessionId is string => !!sessionId))];
|
||
for (const clientSessionId of clientSessionIds) {
|
||
await closeClientSessionId(tab.connectionId, executionDatabase, clientSessionId, catalog, { tabId: tab.id }, throwOnError);
|
||
}
|
||
}
|
||
|
||
function queueTabSessionReset(tab: QueryTab) {
|
||
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
|
||
const previousReset = pendingTabSessionResets.get(tab.id);
|
||
const reset = (async () => {
|
||
if (previousReset) await previousReset;
|
||
// A schema reset must fail closed: reusing the old session would retain Oracle CURRENT_SCHEMA.
|
||
await closeResultSession(tab, undefined, true);
|
||
await closeClientConnectionSession(tab, true);
|
||
})();
|
||
pendingTabSessionResets.set(tab.id, reset);
|
||
const clearPendingReset = () => {
|
||
if (pendingTabSessionResets.get(tab.id) === reset) pendingTabSessionResets.delete(tab.id);
|
||
};
|
||
void reset.then(clearPendingReset, clearPendingReset);
|
||
}
|
||
|
||
async function waitForTabSessionReset(tabId: string) {
|
||
while (true) {
|
||
const pendingReset = pendingTabSessionResets.get(tabId);
|
||
if (!pendingReset) return;
|
||
await pendingReset;
|
||
if (pendingTabSessionResets.get(tabId) === pendingReset) pendingTabSessionResets.delete(tabId);
|
||
}
|
||
}
|
||
|
||
function touchResult(tab: QueryTab | undefined, accessedAt = Date.now(), options: { reuseEstimatedBytes?: boolean } = {}) {
|
||
if (tab?.result || tab?.results) {
|
||
tab.resultAccessedAt = accessedAt;
|
||
// 纯访问路径(如切换标签页)可复用已算好的估算值:estimateQueryResultsBytes
|
||
// 会同步深遍历整份结果集,挂在 sync watch 上会直接阻塞切页交互。
|
||
if (!options.reuseEstimatedBytes || tab.resultEstimatedBytes === undefined) {
|
||
tab.resultEstimatedBytes = estimateQueryResultsBytes(tab.result, tab.results);
|
||
}
|
||
tab.resultCacheState = "memory";
|
||
tab.resultEvicted = undefined;
|
||
}
|
||
}
|
||
|
||
/** 结果负载被原地修改(如保存后写回单元格)时,让持有它的 tab/run 的字节估算失效,下次访问按需重算。 */
|
||
function invalidateResultEstimateForPayload(result: QueryResult | undefined) {
|
||
if (!result) return;
|
||
for (const tab of tabs.value) {
|
||
if (tab.result === result || tab.results?.includes(result)) tab.resultEstimatedBytes = undefined;
|
||
for (const run of tab.resultRuns ?? []) {
|
||
if (run.result === result || run.results?.includes(result)) run.resultEstimatedBytes = undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean; preserveCacheSnapshot?: boolean } = {}) {
|
||
tab.result = undefined;
|
||
tab.results = undefined;
|
||
tab.activeResultIndex = undefined;
|
||
tab.batchSqlExecution = undefined;
|
||
tab.resultEditorFingerprint = undefined;
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
tab.resultSortMode = undefined;
|
||
tab.resultSessionId = undefined;
|
||
tab.resultClientSessionId = undefined;
|
||
tab.resultAccessedAt = undefined;
|
||
tab.resultEstimatedBytes = undefined;
|
||
tab.queryAnalysis = undefined;
|
||
tab.querySourceColumns = undefined;
|
||
tab.queryWriteTargets = undefined;
|
||
tab.resultColumnComments = undefined;
|
||
tab.queryDisplaySourceColumns = undefined;
|
||
tab.queryEditabilityReason = undefined;
|
||
tab.mongoEditTarget = undefined;
|
||
if (tab.mode === "query") tab.tableMeta = undefined;
|
||
tab.resultEvicted = options.evicted ? true : undefined;
|
||
tab.resultCacheState = options.evicted ? tab.resultCacheState : undefined;
|
||
if (!options.evicted) {
|
||
if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey);
|
||
tab.resultCacheKey = undefined;
|
||
// Drop the stale view snapshot but do NOT tombstone: ordinary execution
|
||
// clears the payload before running, and the replacement result must stay
|
||
// free to capture a fresh snapshot when the user switches away. Tab
|
||
// closure (closeTab/closeTabsWhere/releaseTabsWhere) uses the tombstone.
|
||
// An evicted result keeps its snapshot so returning to the tab can replay it.
|
||
clearDataGridViewSnapshotsForTab(tab.id);
|
||
}
|
||
}
|
||
|
||
function clearResultNavigationState(tab: QueryTab) {
|
||
tab.resultSortedSql = undefined;
|
||
tab.resultSortColumn = undefined;
|
||
tab.resultSortColumnIndex = undefined;
|
||
tab.resultSortDirection = undefined;
|
||
tab.resultSortMode = undefined;
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
tab.orderByInput = undefined;
|
||
tab.resultPageSql = undefined;
|
||
tab.resultPageLimit = undefined;
|
||
tab.resultPageOffset = undefined;
|
||
tab.resultCountSql = undefined;
|
||
tab.resultTotalRowCount = undefined;
|
||
tab.resultTotalRowCountLoading = false;
|
||
tab.resultSessionId = undefined;
|
||
tab.resultClientSessionId = undefined;
|
||
}
|
||
|
||
function clearResultRunSnapshots(tab: QueryTab) {
|
||
for (const run of tab.resultRuns ?? []) {
|
||
if (run.resultSessionId) void closeResultRunSession(tab, run);
|
||
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
|
||
}
|
||
}
|
||
|
||
function releaseTabResultObjectPayloads(tab: QueryTab) {
|
||
const results = new Set<QueryResult>();
|
||
if (tab.result) results.add(tab.result);
|
||
for (const result of tab.results ?? []) results.add(result);
|
||
for (const run of tab.resultRuns ?? []) {
|
||
if (run.result) results.add(run.result);
|
||
for (const result of run.results ?? []) results.add(result);
|
||
}
|
||
for (const result of results) releaseResultObjectPayload(result);
|
||
}
|
||
|
||
function clearResultRuns(tab: QueryTab) {
|
||
for (const run of tab.resultRuns ?? []) clearResultRunPayload(run);
|
||
tab.resultRuns = undefined;
|
||
tab.activeResultRunId = undefined;
|
||
}
|
||
|
||
function clearResultRunPayload(run: NonNullable<QueryTab["resultRuns"]>[number], options: { evicted?: boolean } = {}) {
|
||
if (run.result) releaseResultObjectPayload(run.result);
|
||
for (const result of run.results ?? []) releaseResultObjectPayload(result);
|
||
run.result = undefined;
|
||
run.results = undefined;
|
||
run.resultLocalSortOriginalRows = undefined;
|
||
run.resultLocalSortOriginalLargeValueCells = undefined;
|
||
run.resultLocalSortOriginalMongoDocuments = undefined;
|
||
run.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
run.resultSessionId = undefined;
|
||
run.resultClientSessionId = undefined;
|
||
run.resultEstimatedBytes = undefined;
|
||
run.queryAnalysis = undefined;
|
||
run.querySourceColumns = undefined;
|
||
run.queryWriteTargets = undefined;
|
||
run.resultColumnComments = undefined;
|
||
run.queryDisplaySourceColumns = undefined;
|
||
run.queryEditabilityReason = undefined;
|
||
run.mongoEditTarget = undefined;
|
||
run.tableMeta = undefined;
|
||
run.resultEvicted = options.evicted ? true : undefined;
|
||
run.resultCacheState = options.evicted ? "disk" : undefined;
|
||
}
|
||
|
||
function projectResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
|
||
const activeIndex = run.activeResultIndex ?? 0;
|
||
tab.activeResultRunId = run.id;
|
||
tab.result = run.result ?? run.results?.[activeIndex];
|
||
tab.results = run.results;
|
||
tab.activeResultIndex = run.activeResultIndex;
|
||
tab.resultGridRevision = run.resultGridRevision;
|
||
// A legacy run without the token must not inherit a stale tab value: fail
|
||
// safe by starting a fresh logical result.
|
||
tab.resultViewGeneration = run.resultViewGeneration ?? uuid();
|
||
tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution);
|
||
tab.resultBaseSql = run.resultBaseSql;
|
||
tab.resultEditorFingerprint = run.resultEditorFingerprint;
|
||
tab.resultSortedSql = run.resultSortedSql;
|
||
tab.resultSortColumn = run.resultSortColumn;
|
||
tab.resultSortColumnIndex = run.resultSortColumnIndex;
|
||
tab.resultSortDirection = run.resultSortDirection;
|
||
tab.resultSortMode = run.resultSortMode;
|
||
tab.resultLocalSortOriginalRows = run.resultLocalSortOriginalRows;
|
||
tab.resultLocalSortOriginalLargeValueCells = run.resultLocalSortOriginalLargeValueCells;
|
||
tab.resultLocalSortOriginalMongoDocuments = run.resultLocalSortOriginalMongoDocuments;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = run.resultLocalSortOriginalMongoCopyDocuments;
|
||
tab.orderByInput = run.orderByInput;
|
||
tab.resultPageSql = run.resultPageSql;
|
||
tab.resultPageLimit = run.resultPageLimit;
|
||
tab.resultPageOffset = run.resultPageOffset;
|
||
tab.resultCountSql = run.resultCountSql;
|
||
tab.resultTotalRowCount = run.resultTotalRowCount;
|
||
tab.resultTotalRowCountLoading = run.resultTotalRowCountLoading;
|
||
tab.resultSessionId = run.resultSessionId;
|
||
tab.resultClientSessionId = run.resultClientSessionId;
|
||
tab.resultAccessedAt = run.resultAccessedAt;
|
||
tab.resultCacheKey = run.resultCacheKey;
|
||
tab.resultCacheState = run.resultCacheState;
|
||
tab.resultEstimatedBytes = run.resultEstimatedBytes ?? estimateQueryResultsBytes(run.result, run.results);
|
||
tab.resultEvicted = run.resultEvicted;
|
||
tab.queryAnalysis = run.queryAnalysis;
|
||
tab.querySourceColumns = run.querySourceColumns;
|
||
tab.queryWriteTargets = run.queryWriteTargets;
|
||
tab.resultColumnComments = run.resultColumnComments;
|
||
tab.queryDisplaySourceColumns = run.queryDisplaySourceColumns;
|
||
tab.queryEditabilityReason = run.queryEditabilityReason;
|
||
tab.mongoEditTarget = run.mongoEditTarget;
|
||
tab.tableMeta = run.tableMeta;
|
||
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
|
||
}
|
||
|
||
function restorePendingResultRun(tab: QueryTab, executionId: string): boolean {
|
||
const runId = pendingResultRunRestores.get(executionId);
|
||
pendingResultRunRestores.delete(executionId);
|
||
if (!runId) return false;
|
||
const run = tab.resultRuns?.find((item) => item.id === runId);
|
||
if (!run || !resultRunHasPayload(run)) return false;
|
||
projectResultRun(tab, run);
|
||
evictInactiveResultRunPayloads(tab);
|
||
return true;
|
||
}
|
||
|
||
async function restoreResultRunPayload(tab: QueryTab, runId: string, isCurrent?: () => boolean) {
|
||
const run = tab.resultRuns?.find((item) => item.id === runId);
|
||
if (!run || run.result || run.results?.length) return run;
|
||
|
||
const cacheKey = run.resultCacheKey ?? tab.resultCacheKey;
|
||
if (!cacheKey) return run;
|
||
|
||
const snapshot = await readTabResultSnapshot(cacheKey);
|
||
if (isCurrent && !isCurrent()) return undefined;
|
||
const snapshotRun = snapshot?.resultRuns?.find((item) => item.id === runId);
|
||
if (!snapshotRun) return run;
|
||
|
||
const restoredRun = markQueryResultRunsRowsRaw([
|
||
{
|
||
...run,
|
||
...snapshotRun,
|
||
id: run.id,
|
||
title: run.title,
|
||
sequence: run.sequence,
|
||
sql: run.sql,
|
||
createdAt: run.createdAt,
|
||
pinned: run.pinned,
|
||
activeResultIndex: run.activeResultIndex,
|
||
resultCacheKey: run.resultCacheKey ?? snapshotRun.resultCacheKey,
|
||
result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined,
|
||
results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined,
|
||
resultCacheState: "memory" as const,
|
||
resultEvicted: undefined,
|
||
// 快照编解码会重建负载(如省略 session_id),落盘前的估算值不再对应
|
||
// 恢复后的对象,置空以便 projectResultRun 按当前负载重算
|
||
resultEstimatedBytes: undefined,
|
||
},
|
||
])[0]!;
|
||
tab.resultRuns = tab.resultRuns?.map((item) => (item.id === runId ? restoredRun : item));
|
||
return restoredRun;
|
||
}
|
||
|
||
async function setActiveResultRun(id: string, runId: string, options: { evictInactive?: boolean; isCurrent?: () => boolean } = {}) {
|
||
const tab = findExecutionTab(id);
|
||
if (!tab) return false;
|
||
const existingRun = tab.resultRuns?.find((item) => item.id === runId);
|
||
const run = existingRun && resultRunHasPayload(existingRun) ? existingRun : await restoreResultRunPayload(tab, runId, options.isCurrent);
|
||
if (options.isCurrent && !options.isCurrent()) return false;
|
||
if (!run?.result && !run?.results?.length) return false;
|
||
projectResultRun(tab, run);
|
||
if (options.evictInactive !== false) evictInactiveResultRunPayloads(tab);
|
||
return true;
|
||
}
|
||
|
||
function toggleResultRunPinned(id: string, runId: string): boolean | undefined {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
|
||
if (!tab?.resultRuns || runIndex < 0) return undefined;
|
||
|
||
const run = { ...tab.resultRuns[runIndex]!, pinned: tab.resultRuns[runIndex]!.pinned ? undefined : true };
|
||
tab.resultRuns[runIndex] = run;
|
||
void persistResultRun(tab, run);
|
||
return run.pinned === true;
|
||
}
|
||
|
||
function unpinAllResultRuns(id: string): number {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab?.resultRuns?.length) return 0;
|
||
|
||
let changed = 0;
|
||
tab.resultRuns = tab.resultRuns.map((run) => {
|
||
if (!run.pinned) return run;
|
||
changed += 1;
|
||
const updated = { ...run, pinned: undefined };
|
||
void persistResultRun(tab, updated);
|
||
return updated;
|
||
});
|
||
return changed;
|
||
}
|
||
|
||
async function closeOtherResultRuns(id: string, keepRunId: string): Promise<boolean> {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab?.resultRuns?.some((run) => run.id === keepRunId)) return false;
|
||
|
||
const runIds = tab.resultRuns.filter((run) => run.id !== keepRunId).map((run) => run.id);
|
||
if (runIds.length === 0) return false;
|
||
// Do not delete otherwise usable runs until the run the user chose to keep
|
||
// has been restored successfully. Disk-backed snapshots can be unavailable.
|
||
if (!(await setActiveResultRun(id, keepRunId, { evictInactive: false }))) return false;
|
||
for (const runId of runIds) {
|
||
await removeResultRun(id, runId);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function closeResultRunsToLeft(id: string, runId: string): Promise<boolean> {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
|
||
if (!tab?.resultRuns || runIndex <= 0) return false;
|
||
|
||
if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false;
|
||
for (const run of tab.resultRuns.slice(0, runIndex)) {
|
||
await removeResultRun(id, run.id);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function closeResultRunsToRight(id: string, runId: string): Promise<boolean> {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
|
||
if (!tab?.resultRuns || runIndex < 0 || runIndex >= tab.resultRuns.length - 1) return false;
|
||
|
||
if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false;
|
||
for (const run of tab.resultRuns.slice(runIndex + 1)) {
|
||
await removeResultRun(id, run.id);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function removeResultRun(id: string, runId: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
|
||
if (!tab || !tab.resultRuns || runIndex < 0) return false;
|
||
|
||
const removedRun = tab.resultRuns[runIndex];
|
||
if (removedRun?.resultSessionId) void closeResultRunSession(tab, removedRun);
|
||
if (removedRun?.resultCacheKey) void deleteTabResultSnapshot(removedRun.resultCacheKey);
|
||
if (removedRun) clearResultRunPayload(removedRun);
|
||
const wasActive = tab.activeResultRunId === runId;
|
||
const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId);
|
||
tab.resultRuns = remainingRuns;
|
||
|
||
if (!wasActive) return true;
|
||
|
||
const adjacentIndex = Math.min(runIndex, remainingRuns.length - 1);
|
||
for (let offset = 0; offset < remainingRuns.length; offset += 1) {
|
||
const candidate = remainingRuns[(adjacentIndex + offset) % remainingRuns.length];
|
||
// Disk-backed runs may have missing or unreadable snapshots; keep searching before clearing output.
|
||
if (candidate && (await setActiveResultRun(id, candidate.id))) return true;
|
||
}
|
||
|
||
tab.activeResultRunId = undefined;
|
||
clearResultPayload(tab);
|
||
return true;
|
||
}
|
||
|
||
async function closeQueryResult(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
|
||
|
||
if (tab.activeResultRunId) return removeResultRun(id, tab.activeResultRunId);
|
||
if (!tab.result && !tab.results?.length && !tab.resultEvicted) return false;
|
||
|
||
const closeSession = closeResultSession(tab);
|
||
releaseTabResultObjectPayloads(tab);
|
||
clearResultPayload(tab);
|
||
await closeSession;
|
||
return true;
|
||
}
|
||
|
||
async function clearQueryResults(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
|
||
|
||
const resultRuns = tab.resultRuns ?? [];
|
||
if (!tab.result && !tab.results?.length && !tab.resultEvicted && resultRuns.length === 0) return false;
|
||
|
||
const closedSessionIds = new Set<string>();
|
||
const currentSessionId = tab.resultSessionId ?? tab.result?.session_id;
|
||
if (currentSessionId) closedSessionIds.add(currentSessionId);
|
||
const closeOperations = [closeResultSession(tab)];
|
||
|
||
for (const run of resultRuns) {
|
||
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
|
||
if (!run.resultSessionId || closedSessionIds.has(run.resultSessionId)) continue;
|
||
closedSessionIds.add(run.resultSessionId);
|
||
closeOperations.push(closeResultRunSession(tab, run));
|
||
}
|
||
|
||
releaseTabResultObjectPayloads(tab);
|
||
clearResultRuns(tab);
|
||
clearResultPayload(tab);
|
||
await Promise.all(closeOperations);
|
||
return true;
|
||
}
|
||
|
||
function nextResultRunSequence(tab: QueryTab): number {
|
||
return (tab.resultRuns?.reduce((max, run) => Math.max(max, run.sequence), 0) ?? 0) + 1;
|
||
}
|
||
|
||
async function closeResultRunSession(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
|
||
if (!run.resultSessionId) return;
|
||
const target = run.multiDbExecution?.target;
|
||
const sessionOwner = {
|
||
...tab,
|
||
id: `result-run:${run.id}`,
|
||
connectionId: target?.connectionId ?? tab.connectionId,
|
||
database: target?.database ?? tab.database,
|
||
schema: target?.schema ?? tab.schema,
|
||
catalog: target?.catalog ?? tab.catalog,
|
||
result: run.result,
|
||
resultSessionId: run.resultSessionId,
|
||
resultClientSessionId: run.resultClientSessionId,
|
||
activeResultRunId: undefined,
|
||
} as QueryTab;
|
||
await closeResultSession(sessionOwner);
|
||
run.resultSessionId = undefined;
|
||
run.resultClientSessionId = undefined;
|
||
}
|
||
|
||
function persistResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]): Promise<boolean> {
|
||
// An evicted run only has metadata in memory. Writing it back here would
|
||
// replace its valid disk snapshot with an empty payload.
|
||
if (!resultRunHasPayload(run)) return Promise.resolve(false);
|
||
const key = run.resultCacheKey ?? resultRunCacheKey(tab.id, run.id);
|
||
run.resultCacheKey = key;
|
||
run.resultCacheState = "memory";
|
||
return writeTabResultSnapshot(
|
||
key,
|
||
{
|
||
result: run.result,
|
||
results: run.results,
|
||
activeResultIndex: run.activeResultIndex,
|
||
resultEditorFingerprint: run.resultEditorFingerprint,
|
||
resultRuns: [run],
|
||
activeResultRunId: run.id,
|
||
queryAnalysis: run.queryAnalysis,
|
||
querySourceColumns: run.querySourceColumns,
|
||
queryWriteTargets: run.queryWriteTargets,
|
||
resultColumnComments: run.resultColumnComments,
|
||
queryDisplaySourceColumns: run.queryDisplaySourceColumns,
|
||
queryEditabilityReason: run.queryEditabilityReason,
|
||
tableMeta: run.tableMeta,
|
||
resultPageSql: run.resultPageSql,
|
||
resultPageLimit: run.resultPageLimit,
|
||
resultPageOffset: run.resultPageOffset,
|
||
resultCountSql: run.resultCountSql,
|
||
resultTotalRowCount: run.resultTotalRowCount,
|
||
cachedAt: Date.now(),
|
||
},
|
||
tab.connectionId,
|
||
);
|
||
}
|
||
|
||
function evictInactiveResultRunPayloads(tab: QueryTab) {
|
||
const activeRunId = tab.activeResultRunId;
|
||
if (!activeRunId || !tab.resultRuns?.length) return;
|
||
|
||
for (const run of tab.resultRuns) {
|
||
if (run.id === activeRunId || !resultRunHasPayload(run)) continue;
|
||
const runId = run.id;
|
||
void closeResultRunSession(tab, run)
|
||
.then(() => persistResultRun(tab, run))
|
||
.then((cached) => {
|
||
const currentRun = tab.resultRuns?.find((item) => item.id === runId);
|
||
if (!cached || !currentRun || currentRun.id === tab.activeResultRunId || !resultRunHasPayload(currentRun)) return;
|
||
if (tab.result === currentRun.result || (currentRun.results && tab.results === currentRun.results)) return;
|
||
clearResultRunPayload(currentRun, { evicted: true });
|
||
});
|
||
}
|
||
}
|
||
|
||
type ResultRunCaptureOptions = {
|
||
reuseResultCacheKey?: boolean;
|
||
title?: string;
|
||
multiDbExecution?: MultiDbResultRunExecution;
|
||
persist?: boolean;
|
||
};
|
||
|
||
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now(), options: ResultRunCaptureOptions = {}) {
|
||
if (tab.mode !== "query" || !tab.result) return;
|
||
const sequence = nextResultRunSequence(tab);
|
||
const run: NonNullable<QueryTab["resultRuns"]>[number] = {
|
||
id: uuid(),
|
||
title: options.title ?? `Run ${sequence}`,
|
||
sequence,
|
||
sql,
|
||
createdAt,
|
||
result: tab.result,
|
||
results: tab.results,
|
||
activeResultIndex: tab.activeResultIndex,
|
||
resultGridRevision: tab.resultGridRevision,
|
||
resultViewGeneration: tab.resultViewGeneration,
|
||
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
|
||
resultBaseSql: tab.resultBaseSql,
|
||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||
resultSortedSql: tab.resultSortedSql,
|
||
resultSortColumn: tab.resultSortColumn,
|
||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||
resultSortDirection: tab.resultSortDirection,
|
||
resultSortMode: tab.resultSortMode,
|
||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
|
||
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
|
||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
|
||
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
|
||
orderByInput: tab.orderByInput,
|
||
resultPageSql: tab.resultPageSql,
|
||
resultPageLimit: tab.resultPageLimit,
|
||
resultPageOffset: tab.resultPageOffset,
|
||
resultCountSql: tab.resultCountSql,
|
||
resultTotalRowCount: tab.resultTotalRowCount,
|
||
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
|
||
resultSessionId: tab.resultSessionId,
|
||
resultClientSessionId: tab.resultClientSessionId,
|
||
resultAccessedAt: tab.resultAccessedAt,
|
||
resultEstimatedBytes: tab.resultEstimatedBytes,
|
||
resultCacheKey: options.reuseResultCacheKey === false ? undefined : tab.resultCacheKey,
|
||
resultCacheState: tab.resultCacheState,
|
||
resultEvicted: tab.resultEvicted,
|
||
queryAnalysis: tab.queryAnalysis,
|
||
querySourceColumns: tab.querySourceColumns,
|
||
queryWriteTargets: tab.queryWriteTargets,
|
||
resultColumnComments: tab.resultColumnComments,
|
||
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
|
||
queryEditabilityReason: tab.queryEditabilityReason,
|
||
mongoEditTarget: tab.mongoEditTarget,
|
||
tableMeta: tab.tableMeta,
|
||
multiDbExecution: options.multiDbExecution,
|
||
};
|
||
if (options.persist !== false) void persistResultRun(tab, run);
|
||
tab.resultRuns = [...(tab.resultRuns ?? []), run];
|
||
tab.activeResultRunId = run.id;
|
||
if (options.reuseResultCacheKey === false) {
|
||
tab.resultCacheKey = run.resultCacheKey;
|
||
tab.resultCacheState = run.resultCacheState;
|
||
}
|
||
evictInactiveResultRunPayloads(tab);
|
||
}
|
||
|
||
function captureMultiDbExecutionWorkerResult(sourceTabId: string, workerId: string, sql: string, execution: MultiDbResultRunExecution): string | undefined {
|
||
const source = tabs.value.find((tab) => tab.id === sourceTabId);
|
||
const worker = multiDbExecutionWorkers.get(workerId);
|
||
if (!source || !worker?.result) return undefined;
|
||
|
||
// Preserve a pre-existing displayed result once, so the source tab can
|
||
// show the original result alongside the multi-db result pages.
|
||
if (source.result && !source.activeResultRunId) {
|
||
captureDisplayedResultRun(source, source.resultBaseSql ?? source.lastExecutedSql ?? source.sql);
|
||
}
|
||
|
||
const title = execution.title ?? (execution.target.database || execution.target.connectionId);
|
||
captureDisplayedResultRun(worker, sql, Date.now(), { title, multiDbExecution: execution, persist: false });
|
||
const workerRun = worker.resultRuns?.find((run) => run.id === worker.activeResultRunId);
|
||
if (!workerRun) return undefined;
|
||
|
||
const runId = uuid();
|
||
const run = {
|
||
...workerRun,
|
||
id: runId,
|
||
title,
|
||
sequence: nextResultRunSequence(source),
|
||
multiDbExecution: execution,
|
||
resultCacheKey: undefined,
|
||
resultCacheState: "memory" as const,
|
||
resultEvicted: undefined,
|
||
};
|
||
source.resultRuns = [...(source.resultRuns ?? []), run];
|
||
projectResultRun(source, run);
|
||
void persistResultRun(source, run);
|
||
evictInactiveResultRunPayloads(source);
|
||
return runId;
|
||
}
|
||
|
||
function toggleResultAutoSave(id: string): boolean {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "query") return false;
|
||
setResultAutoSave(tab, tab.resultAutoSave !== true);
|
||
return tab.resultAutoSave === true;
|
||
}
|
||
|
||
function setResultAutoSave(tab: QueryTab, enabled: boolean) {
|
||
tab.resultAutoSave = enabled;
|
||
if (enabled && !tab.isExecuting && tab.result && !tab.activeResultRunId) {
|
||
captureDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
|
||
}
|
||
}
|
||
|
||
function syncActiveResultRunFromDisplayed(tab: QueryTab, sql?: string) {
|
||
if (!tab.activeResultRunId || !tab.resultRuns?.length) return;
|
||
const index = tab.resultRuns.findIndex((run) => run.id === tab.activeResultRunId);
|
||
if (index < 0) return;
|
||
const run = {
|
||
...tab.resultRuns[index],
|
||
...(sql ? { sql } : {}),
|
||
result: tab.result,
|
||
results: tab.results,
|
||
activeResultIndex: tab.activeResultIndex,
|
||
resultGridRevision: tab.resultGridRevision,
|
||
resultViewGeneration: tab.resultViewGeneration,
|
||
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
|
||
resultBaseSql: tab.resultBaseSql,
|
||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||
resultSortedSql: tab.resultSortedSql,
|
||
resultSortColumn: tab.resultSortColumn,
|
||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||
resultSortDirection: tab.resultSortDirection,
|
||
resultSortMode: tab.resultSortMode,
|
||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
|
||
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
|
||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
|
||
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
|
||
orderByInput: tab.orderByInput,
|
||
resultPageSql: tab.resultPageSql,
|
||
resultPageLimit: tab.resultPageLimit,
|
||
resultPageOffset: tab.resultPageOffset,
|
||
resultCountSql: tab.resultCountSql,
|
||
resultTotalRowCount: tab.resultTotalRowCount,
|
||
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
|
||
resultSessionId: tab.resultSessionId,
|
||
resultClientSessionId: tab.resultClientSessionId,
|
||
resultAccessedAt: tab.resultAccessedAt,
|
||
resultEstimatedBytes: tab.resultEstimatedBytes,
|
||
resultCacheKey: tab.resultCacheKey,
|
||
resultCacheState: tab.resultCacheState,
|
||
resultEvicted: tab.resultEvicted,
|
||
queryAnalysis: tab.queryAnalysis,
|
||
querySourceColumns: tab.querySourceColumns,
|
||
queryWriteTargets: tab.queryWriteTargets,
|
||
resultColumnComments: tab.resultColumnComments,
|
||
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
|
||
queryEditabilityReason: tab.queryEditabilityReason,
|
||
mongoEditTarget: tab.mongoEditTarget,
|
||
tableMeta: tab.tableMeta,
|
||
};
|
||
void persistResultRun(tab, run);
|
||
tab.resultRuns[index] = run;
|
||
}
|
||
|
||
function syncDisplayedResultRun(tab: QueryTab, sql: string, captureNewRun = false, options: ResultRunCaptureOptions = {}) {
|
||
if (tab.mode !== "query" || !tab.result) return;
|
||
if (captureNewRun) {
|
||
captureDisplayedResultRun(tab, sql, Date.now(), { ...options, reuseResultCacheKey: false });
|
||
} else if (tab.activeResultRunId) {
|
||
syncActiveResultRunFromDisplayed(tab, sql);
|
||
} else if (tab.resultAutoSave) {
|
||
captureDisplayedResultRun(tab, sql);
|
||
}
|
||
}
|
||
|
||
function assignDisplayedResult(tab: QueryTab, result: QueryResult) {
|
||
tab.result = markQueryResultRowsRaw(result);
|
||
if (tab.results?.length) {
|
||
const activeIndex = tab.activeResultIndex ?? 0;
|
||
if (activeIndex >= 0 && activeIndex < tab.results.length) {
|
||
tab.results[activeIndex] = tab.result;
|
||
}
|
||
}
|
||
}
|
||
|
||
type ResultPublicationOrigin = "execute" | "refresh" | "page" | "sort" | "local-sort" | "append" | "disk-restore";
|
||
|
||
/**
|
||
* Single writer of `resultViewGeneration`, the logical-result identity used by
|
||
* the tab-switch view snapshot cache (`dataGridViewStateCache.ts`).
|
||
*
|
||
* New value by default, so a path that forgets to classify itself still fails
|
||
* safe (the old view snapshot stops matching) instead of replaying a stale
|
||
* viewport. `append` extends the current dataset; `disk-restore` inherits, so
|
||
* an evicted-then-restored payload keeps its captured view. No other code path
|
||
* may assign the token directly.
|
||
*/
|
||
function publishResultGeneration(tab: QueryTab, origin: ResultPublicationOrigin) {
|
||
if (origin === "disk-restore") return;
|
||
if (origin === "append") {
|
||
tab.resultViewGeneration ??= uuid();
|
||
return;
|
||
}
|
||
tab.resultViewGeneration = uuid();
|
||
}
|
||
|
||
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.result) return;
|
||
|
||
if (!tab.resultLocalSortOriginalRows) {
|
||
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
|
||
tab.resultLocalSortOriginalLargeValueCells = tab.result.large_value_cells?.map((cell) => ({ ...cell }));
|
||
tab.resultLocalSortOriginalMongoDocuments = tab.result.mongo_documents?.slice();
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = tab.result.mongo_copy_documents?.slice();
|
||
}
|
||
|
||
const originalRows = tab.resultLocalSortOriginalRows;
|
||
const columnType = tab.result.column_types?.[columnIndex];
|
||
const rowIndexes = direction ? sortDataGridRowIndexes(originalRows, columnIndex, direction, columnType) : originalRows.map((_, index) => index);
|
||
const rows = rowIndexes.map((index) => originalRows[index]!);
|
||
const originalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments;
|
||
const mongo_documents = originalMongoDocuments ? rowIndexes.map((index) => originalMongoDocuments[index]) : undefined;
|
||
const originalMongoCopyDocuments = tab.resultLocalSortOriginalMongoCopyDocuments;
|
||
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
|
||
const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes);
|
||
assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents });
|
||
// Reordering rows invalidates source-index view snapshots.
|
||
publishResultGeneration(tab, "local-sort");
|
||
|
||
tab.resultSortColumn = direction ? column : undefined;
|
||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||
tab.resultSortDirection = direction ?? undefined;
|
||
tab.resultSortMode = direction ? "local" : undefined;
|
||
tab.resultSortedSql = undefined;
|
||
if (!direction) {
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
}
|
||
|
||
// 本地排序只是重排既有行/文档,字节规模不变,可复用估算值
|
||
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
|
||
syncDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
|
||
}
|
||
|
||
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
|
||
return !!run.result || !!run.results?.length;
|
||
}
|
||
|
||
function resultSnapshotHasPayload(snapshot: NonNullable<ReturnType<typeof buildTabResultSnapshot>>): boolean {
|
||
return !!snapshot.result || !!snapshot.results?.length || !!snapshot.resultRuns?.some(resultRunHasPayload);
|
||
}
|
||
|
||
async function evictCachedResult(tab: QueryTab) {
|
||
await closeResultSession(tab);
|
||
const cacheKey = tabResultCacheKey(tab.id);
|
||
const cached = await writeTabResultSnapshot(cacheKey, buildTabResultSnapshot(tab), tab.connectionId);
|
||
tab.resultCacheKey = cached ? cacheKey : undefined;
|
||
tab.resultCacheState = cached ? "disk" : "missing";
|
||
clearResultPayload(tab, { evicted: true });
|
||
}
|
||
|
||
function applyRestoredOpenTabs(restored: { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown }) {
|
||
const connectionStore = useConnectionStore();
|
||
for (const tab of restored.tabs) {
|
||
const connection = connectionStore.getConfig(tab.connectionId);
|
||
if (tab.mode === "query" && tab.autoCommit === undefined) {
|
||
tab.autoCommit = defaultAutoCommitForDbTypeWithSetting(connection?.db_type);
|
||
} else if (tab.mode === "data" && connection) {
|
||
tab.schema = connectionObjectTreeNodeSchema(connection, tab.database, tab.schema);
|
||
}
|
||
}
|
||
restored.tabs.forEach(initializeResultAutoSave);
|
||
tabs.value = restored.tabs;
|
||
for (const tab of restored.tabs) {
|
||
if (tab.mode === "data") {
|
||
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||
}
|
||
}
|
||
|
||
// Restore order (guide §3.9): the persisted global active tab is only a
|
||
// hint for legacy tabs-only payloads. Group membership, group actives and
|
||
// the focused group are repaired first; the global active tab is derived
|
||
// from the focused group last, so a stale payload value can never move
|
||
// focus to a different group.
|
||
const workspace = restored.workspace && typeof restored.workspace === "object" ? (restored.workspace as Record<string, unknown>) : undefined;
|
||
if (!Array.isArray(workspace?.groups) && restored.activeTabId && tabs.value.some((tab) => tab.id === restored.activeTabId)) {
|
||
groups.value = [{ id: "main", tabIds: tabs.value.map((tab) => tab.id), activeTabId: restored.activeTabId }];
|
||
focusedGroupId.value = "main";
|
||
} else if (workspace) {
|
||
if (Array.isArray(workspace.groups)) {
|
||
groups.value = (workspace.groups as Array<Record<string, unknown>>)
|
||
.filter((group) => group && typeof group.id === "string")
|
||
.map((group) => ({
|
||
id: group.id as string,
|
||
tabIds: Array.isArray(group.tabIds) ? (group.tabIds as unknown[]).map(String) : [],
|
||
activeTabId: typeof group.activeTabId === "string" ? group.activeTabId : null,
|
||
}));
|
||
}
|
||
if (typeof workspace.focusedGroupId === "string") {
|
||
focusedGroupId.value = workspace.focusedGroupId;
|
||
}
|
||
if (workspace.orientation === "vertical" || workspace.orientation === "horizontal") {
|
||
orientation.value = workspace.orientation;
|
||
}
|
||
if (Array.isArray(workspace.sizes)) {
|
||
sizes.value = (workspace.sizes as unknown[]).map(Number);
|
||
}
|
||
}
|
||
// Start from no global active so normalization cannot treat the persisted
|
||
// value as authoritative over the restored focused group.
|
||
activeTabId.value = null;
|
||
normalizeGroups();
|
||
activeTabHistory.value = activeTabId.value ? [activeTabId.value] : [];
|
||
}
|
||
|
||
async function recoverDetachedTabsToMain(options: { validConnectionIds?: Iterable<string> } = {}) {
|
||
if (!isTauriRuntime() || useSettingsStore().editorSettings.openTabsRestoreMode === "none") return;
|
||
const validConnectionIds = options.validConnectionIds ? new Set(options.validConnectionIds) : undefined;
|
||
const handoffs = await api.listDetachedTabHandoffs().catch(() => []);
|
||
for (const handoff of handoffs) {
|
||
if (!handoff || handoff.schemaVersion !== 1 || !handoff.tabId || !handoff.tab || typeof handoff.tab.id !== "string" || handoff.tab.id !== handoff.tabId) continue;
|
||
if (validConnectionIds && handoff.tab.mode !== "query" && !validConnectionIds.has(handoff.tab.connectionId)) continue;
|
||
try {
|
||
await adoptDetachedTab(handoff);
|
||
await api.deleteDetachedTabHandoff(handoff.tabId);
|
||
} catch (error) {
|
||
console.warn("[DBX][detached-tab:restore:error]", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scheduleResultCacheMaintenance() {
|
||
const maintain = () => {
|
||
const liveKeys = tabs.value.flatMap((tab) => [tab.resultCacheKey, ...(tab.resultRuns?.map((run) => run.resultCacheKey) ?? [])]).filter((key): key is string => !!key);
|
||
void pruneTabResultSnapshots(liveKeys).catch((error) => console.warn("[DBX][result-cache:maintenance:error]", error));
|
||
};
|
||
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(maintain, { timeout: 5000 });
|
||
else if (typeof window !== "undefined") window.setTimeout(maintain, 0);
|
||
else setTimeout(maintain, 0);
|
||
}
|
||
|
||
async function initOpenTabs(options: { validConnectionIds?: Iterable<string> } = {}) {
|
||
if (isDetachedWindow()) return;
|
||
if (isOpenTabsLoaded.value) return;
|
||
const saved = await api.loadOpenTabsState().catch(() => null);
|
||
if (saved?.tabs && Array.isArray(saved.tabs)) {
|
||
const restored = restoreSavedTabsFromPayload(saved, options);
|
||
applyRestoredOpenTabs(restored);
|
||
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||
// Restore is explicitly disabled, so stale saved payloads should not
|
||
// reappear if the user later changes the setting.
|
||
clearLegacySavedTabs();
|
||
await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => undefined);
|
||
}
|
||
await recoverDetachedTabsToMain(options);
|
||
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
|
||
safeLocalStorageRemove(UPDATE_RESTORE_KEY);
|
||
isOpenTabsLoaded.value = true;
|
||
scheduleResultCacheMaintenance();
|
||
return;
|
||
}
|
||
|
||
const legacy = loadLegacySavedTabs();
|
||
if (legacy.rawTabs || legacy.rawActiveTabId) {
|
||
const restored = restoreLegacySavedTabs(options);
|
||
applyRestoredOpenTabs(restored);
|
||
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||
// Restore is explicitly disabled, so keeping the legacy startup payload
|
||
// would resurrect old tabs if the user later changes the setting.
|
||
clearLegacySavedTabs();
|
||
isOpenTabsLoaded.value = true;
|
||
scheduleResultCacheMaintenance();
|
||
return;
|
||
}
|
||
await recoverDetachedTabsToMain(options);
|
||
try {
|
||
await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
|
||
// Keep old desktop installs readable until the async store has the
|
||
// migrated state; only then remove the synchronous startup payload.
|
||
clearLegacySavedTabs();
|
||
} catch {
|
||
/* keep legacy values for a later migration attempt */
|
||
}
|
||
}
|
||
await recoverDetachedTabsToMain(options);
|
||
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
|
||
isOpenTabsLoaded.value = true;
|
||
scheduleResultCacheMaintenance();
|
||
}
|
||
|
||
const _persistSnapshot = computed(() =>
|
||
tabs.value.map((t) => ({
|
||
id: t.id,
|
||
title: t.title,
|
||
connectionId: t.connectionId,
|
||
database: t.database,
|
||
schema: t.schema,
|
||
sql: t.sql,
|
||
editorViewport: t.editorViewport,
|
||
editorSelection: t.editorSelection,
|
||
savedSqlId: t.savedSqlId,
|
||
externalSqlPath: t.externalSqlPath,
|
||
externalSqlFileVersion: t.externalSqlFileVersion,
|
||
externalSqlIgnoredFileVersion: t.externalSqlIgnoredFileVersion,
|
||
externalSqlFileMissing: t.externalSqlFileMissing,
|
||
lastExecutedSql: t.lastExecutedSql,
|
||
resultBaseSql: t.resultBaseSql,
|
||
resultSortedSql: t.resultSortedSql,
|
||
resultSortColumn: t.resultSortColumn,
|
||
resultSortColumnIndex: t.resultSortColumnIndex,
|
||
resultSortDirection: t.resultSortDirection,
|
||
resultSortMode: t.resultSortMode,
|
||
orderByInput: t.orderByInput,
|
||
resultPageLimit: t.resultPageLimit,
|
||
resultPageOffset: t.resultPageOffset,
|
||
whereInput: t.whereInput,
|
||
pinned: t.pinned,
|
||
mode: t.mode,
|
||
autoCommit: t.autoCommit,
|
||
resultAutoSave: t.resultAutoSave,
|
||
uiState: t.uiState,
|
||
structureTableName: t.structureTableName,
|
||
structureDraft: t.structureDraft,
|
||
objectBrowser: t.objectBrowser,
|
||
objectSource: t.objectSource,
|
||
sourceView: t.sourceView,
|
||
tableMeta: t.tableMeta,
|
||
mongoEditTarget: t.mongoEditTarget,
|
||
resultEvicted: t.resultEvicted,
|
||
resultCacheKey: t.resultCacheKey,
|
||
// Keep the watch dependency limited to the metadata that is serialized
|
||
// for each result run, without tracking the potentially large payload.
|
||
resultRuns: t.resultRuns?.map((run) => ({
|
||
id: run.id,
|
||
title: run.title,
|
||
sequence: run.sequence,
|
||
sql: run.sql,
|
||
createdAt: run.createdAt,
|
||
pinned: run.pinned,
|
||
activeResultIndex: run.activeResultIndex,
|
||
resultCacheKey: run.resultCacheKey,
|
||
resultEvicted: run.resultEvicted,
|
||
})),
|
||
activeResultRunId: t.activeResultRunId,
|
||
})),
|
||
);
|
||
|
||
const storePersistGeneration = ++persistGeneration;
|
||
watch(
|
||
[_persistSnapshot, activeTabId, groups, focusedGroupId, orientation, sizes],
|
||
() => {
|
||
if (storePersistGeneration !== persistGeneration) return;
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(() => {
|
||
if (isDetachedWindow()) {
|
||
void flushDetachedTabPersistence(activeTabId.value ?? undefined).catch(() => {});
|
||
} else {
|
||
void saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => {});
|
||
}
|
||
persistTimer = null;
|
||
}, 300);
|
||
},
|
||
{ flush: "post", deep: true },
|
||
);
|
||
|
||
// Group invariants are maintained by the atomic store operations themselves
|
||
// (registerOpenTab / activateTab / split / move / close); there is
|
||
// deliberately no post-flush watcher repairing them here. Normalization is
|
||
// reserved for the persistence restore boundary.
|
||
|
||
onScopeDispose(() => {
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
void disposeAllSqlServerActivityTraces();
|
||
});
|
||
|
||
// Immediately flush any pending debounced persist so the on-disk content
|
||
// reflects the latest in-memory tabs without waiting for the 300ms debounce.
|
||
// Lets callers (e.g. tests that reload the store) read back persisted state
|
||
// deterministically instead of racing the debounce timer.
|
||
function flushPendingPersist(): Promise<void> {
|
||
if (isDetachedWindow()) return flushDetachedTabPersistence(activeTabId.value ?? undefined);
|
||
if (storePersistGeneration !== persistGeneration) return Promise.resolve();
|
||
if (persistTimer) {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
}
|
||
return saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
|
||
}
|
||
|
||
async function prepareDetachedTab(tabId: string, runtime: DetachedTabRuntimeState = {}): Promise<DetachedTabHandoff> {
|
||
const tab = tabs.value.find((candidate) => candidate.id === tabId);
|
||
if (!tab) throw new Error("Tab is no longer open");
|
||
if (tab.mode !== "query" && tab.mode !== "data") throw new Error("Only SQL and data tabs can be detached");
|
||
if (tab.isExecuting || tab.isCancelling || tab.isExplaining) throw new Error("Wait for the current operation to finish before detaching this tab");
|
||
|
||
const savedTab = serializeOpenTabs([tab])[0];
|
||
if (!savedTab) throw new Error("Unable to serialize tab");
|
||
|
||
const resultCacheKey = tabResultCacheKey(tab.id);
|
||
const snapshot = buildTabResultSnapshot(tab) ?? (tab.resultCacheKey ? await readTabResultSnapshot(tab.resultCacheKey) : undefined);
|
||
if (snapshot) {
|
||
const cached = await writeTabResultSnapshot(resultCacheKey, snapshot, tab.connectionId);
|
||
if (!cached) throw new Error("Unable to cache the tab result for the detached window");
|
||
tab.resultCacheKey = resultCacheKey;
|
||
tab.resultCacheState = "disk";
|
||
}
|
||
|
||
detachedHandoffRevision += 1;
|
||
return {
|
||
schemaVersion: 1,
|
||
tabId,
|
||
sourceWindowLabel: resolveWindowContext().windowLabel,
|
||
revision: detachedHandoffRevision,
|
||
tab: savedTab,
|
||
runtime: {
|
||
editorViewport: tab.editorViewport,
|
||
editorSelection: tab.editorSelection,
|
||
txnSessionId: tab.txnSessionId,
|
||
txnAutoRolledBack: tab.txnAutoRolledBack,
|
||
oracleTxnPossiblyDirty: tab.oracleTxnPossiblyDirty,
|
||
...runtime,
|
||
},
|
||
...(snapshot ? { resultCacheKey } : {}),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}
|
||
|
||
async function flushDetachedTabPersistence(tabId?: string, runtime: DetachedTabRuntimeState = {}): Promise<void> {
|
||
if (!isDetachedWindow() || !tabId) return;
|
||
const handoff = await prepareDetachedTab(tabId, runtime);
|
||
await api.saveDetachedTabHandoff(tabId, handoff);
|
||
}
|
||
|
||
/**
|
||
* Lands a workspace-adopted tab in the pane layout. The split workspace
|
||
* renders from group membership, so a tab arriving through a detached
|
||
* handoff must belong to a group to be visible at all. A group that still
|
||
* carries the id (a return to a pane that never cleaned up) just gets its
|
||
* active tab repaired; everything else adopts into the first group. Used by
|
||
* detached-window open, return-to-main, and startup recovery alike.
|
||
*/
|
||
function ensureTabInWorkspace(tabId: string) {
|
||
const owner = groups.value.find((group) => group.tabIds.includes(tabId));
|
||
if (owner) {
|
||
owner.activeTabId = tabId;
|
||
focusedGroupId.value = owner.id;
|
||
return;
|
||
}
|
||
const main = groups.value[0];
|
||
main.tabIds.push(tabId);
|
||
main.activeTabId = tabId;
|
||
focusedGroupId.value = main.id;
|
||
}
|
||
|
||
async function adoptDetachedTab(handoff: DetachedTabHandoff): Promise<string> {
|
||
if (!handoff || handoff.schemaVersion !== 1 || !handoff.tab || typeof handoff.tab.id !== "string" || handoff.tab.id !== handoff.tabId) throw new Error("Unsupported detached tab state");
|
||
const restored = restoreOpenTabsPayload({ tabs: [handoff.tab], activeTabId: handoff.tabId });
|
||
const restoredTab = restored.tabs[0];
|
||
if (!restoredTab) throw new Error("Unable to restore detached tab");
|
||
// serializeOpenTabs() blanks `sql` for a clean saved-SQL-library tab to avoid
|
||
// duplicating on-disk state across app restarts, relying on hydrateSavedSqlTabs()
|
||
// to refill it afterwards. The detach handoff goes through the same serialization
|
||
// but skips that hydration step, so do it here too or the new window opens empty.
|
||
// A freshly created detached window never runs the main-window bootstrap that
|
||
// populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent()
|
||
// can't find the file locally either — go straight to the backend instead.
|
||
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql && restoredTab.originalSql === undefined) {
|
||
const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined);
|
||
if (file) {
|
||
restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name;
|
||
restoredTab.sql = file.sql;
|
||
restoredTab.originalSql = file.sql;
|
||
}
|
||
}
|
||
Object.assign(restoredTab, handoff.runtime);
|
||
if (handoff.resultCacheKey) {
|
||
restoredTab.resultCacheKey = handoff.resultCacheKey;
|
||
const restoredResult = restoreCachedResultPayload(restoredTab, await readTabResultSnapshot(handoff.resultCacheKey));
|
||
if (!restoredResult) restoredTab.resultCacheState = "missing";
|
||
}
|
||
initializeResultAutoSave(restoredTab);
|
||
const existingIndex = tabs.value.findIndex((tab) => tab.id === handoff.tabId);
|
||
if (existingIndex >= 0) {
|
||
tabs.value.splice(existingIndex, 1, restoredTab);
|
||
} else {
|
||
tabs.value.push(restoredTab);
|
||
}
|
||
ensureTabInWorkspace(handoff.tabId);
|
||
activeTabId.value = handoff.tabId;
|
||
return handoff.tabId;
|
||
}
|
||
|
||
function removeTabAfterDetachedReady(tabId: string): boolean {
|
||
const index = tabs.value.findIndex((tab) => tab.id === tabId);
|
||
if (index < 0) return false;
|
||
// Group-aware removal: the split workspace renders from group membership,
|
||
// so the tab must leave its pane too. The emptied pane is pruned exactly
|
||
// like a bulk close, and a dangling pane (group entry without a tab) is
|
||
// never left behind for the return trip to trip over.
|
||
tabs.value.splice(index, 1);
|
||
removeTabsFromGroups(new Set([tabId]));
|
||
if (activeTabId.value === tabId) syncActiveTabFromFocusedGroup();
|
||
return true;
|
||
}
|
||
|
||
function findTabByIdentity(connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, catalog?: string) {
|
||
if (mode === "meilisearch-system") {
|
||
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.mode === mode);
|
||
}
|
||
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
|
||
}
|
||
|
||
function createTab(
|
||
connectionId: string,
|
||
database: string,
|
||
title?: string,
|
||
mode: QueryTab["mode"] = "query",
|
||
schema?: string,
|
||
initialSql?: string,
|
||
catalog?: string,
|
||
options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean; sourceView?: boolean } = {},
|
||
) {
|
||
if (title && !options.forceNew) {
|
||
const existing = findTabByIdentity(connectionId, database, title, mode, schema, catalog);
|
||
if (existing) {
|
||
if (options.sourceView) existing.sourceView = true;
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
}
|
||
|
||
const id = uuid();
|
||
const dbType = useConnectionStore().getConfig(connectionId)?.db_type;
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: title || `query_${tabs.value.length + 1}`,
|
||
customTitle: mode === "query" && title ? true : undefined,
|
||
forceWordWrap: options.forceWordWrap,
|
||
sourceView: options.sourceView,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
sql: initialSql ?? "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode,
|
||
...(mode === "query" ? { autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType) } : {}),
|
||
};
|
||
if (mode === "query") tab.originalSql = initialSql ?? "";
|
||
return registerOpenTab(tab, {
|
||
...(options.activate === false ? { activate: false } : {}),
|
||
...(options.insertAfterActive && activeTabId.value ? { insertAfterTabId: activeTabId.value } : {}),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 对象源码 tab 的判重键。裁决点是**解析后**的 objectSource.objectType:
|
||
* routine fallback 会把 PROCEDURE↔FUNCTION、PACKAGE↔PACKAGE_BODY 归一,
|
||
* 因此「PROCEDURE foo」与「FUNCTION foo」应当共用一个 tab。pending 阶段
|
||
* 拿不到解析结果,只能做请求身份去重(见 openObjectSourceTabPending),
|
||
* 解析完成后再回到这里落定,避免改变既有语义。
|
||
*/
|
||
function findMatchingObjectSourceTab(options: OpenObjectSourceTabOptions, excludeTabId?: string): QueryTab | undefined {
|
||
return tabs.value.find(
|
||
(tab) =>
|
||
tab.id !== excludeTabId &&
|
||
tab.mode === "query" &&
|
||
tab.connectionId === options.connectionId &&
|
||
tab.database === options.database &&
|
||
(tab.schema || "") === (options.schema || "") &&
|
||
(tab.catalog || "") === (options.catalog || "") &&
|
||
tab.objectSource?.name === options.objectSource.name &&
|
||
tab.objectSource.objectType === options.objectSource.objectType &&
|
||
(tab.objectSource.schema || "") === (options.objectSource.schema || "") &&
|
||
(tab.objectSource.signature || "") === (options.objectSource.signature || ""),
|
||
);
|
||
}
|
||
|
||
function openObjectSourceTab(options: OpenObjectSourceTabOptions) {
|
||
const existing = findMatchingObjectSourceTab(options);
|
||
if (existing) {
|
||
existing.sourceView = true;
|
||
switchTab(existing.id);
|
||
if (!isTabDirty(existing)) {
|
||
updateSql(existing.id, options.sql);
|
||
markTabClean(existing);
|
||
}
|
||
return existing.id;
|
||
}
|
||
|
||
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true, sourceView: true });
|
||
setObjectSource(id, options.objectSource);
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* 正在后台重新校验源码的 tab。非响应式:仅用于避免同一个 tab 上叠起多次
|
||
* 取源请求(Oracle 的 GET_DDL 正是慢的那一步)。
|
||
*/
|
||
const sourceRevalidateInFlight = new Set<string>();
|
||
|
||
function findPendingObjectSourceTab(options: OpenPendingObjectSourceTabOptions): QueryTab | undefined {
|
||
return tabs.value.find(
|
||
(tab) =>
|
||
!!tab.sourceLoad &&
|
||
tab.connectionId === options.connectionId &&
|
||
tab.database === options.database &&
|
||
(tab.schema || "") === (options.schema || "") &&
|
||
(tab.catalog || "") === (options.catalog || "") &&
|
||
tab.sourceLoad.request.name === options.request.name &&
|
||
tab.sourceLoad.request.objectType === options.request.objectType &&
|
||
(tab.sourceLoad.request.signature || "") === (options.request.signature || ""),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 立即建出源码 tab 并挂上加载态,再异步取源码(issue #9035)。
|
||
* 此前是「等连接 + 等源码都完成才建 tab」,等待期间没有任何可见 UI,
|
||
* 用户看到的是点击后毫无反应。
|
||
*/
|
||
function openObjectSourceTabPending(options: OpenPendingObjectSourceTabOptions): string {
|
||
// 这个对象已经打开过:立刻切过去,再在后台重新校验源码。
|
||
// 两条弯路都要避开 —— 再建一个 pending tab 会让界面上多出一个转圈 tab,
|
||
// 随后又被交接逻辑关掉;而只切过去不校验,会让重开看到的是旧 DDL
|
||
// (改动前每次打开都会重新取源,源码 tab 没有其它刷新入口)。
|
||
const loaded = findMatchingObjectSourceTab({
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
title: options.title,
|
||
schema: options.schema,
|
||
catalog: options.catalog,
|
||
sql: "",
|
||
objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature },
|
||
});
|
||
if (loaded) {
|
||
loaded.sourceView = true;
|
||
switchTab(loaded.id);
|
||
// 这条路径不经过 ensureConnected,但树上的动作会把该连接设为当前连接
|
||
useConnectionStore().activeConnectionId = options.connectionId;
|
||
void revalidateObjectSourceTab(loaded.id);
|
||
return loaded.id;
|
||
}
|
||
|
||
const existing = findPendingObjectSourceTab(options);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
// 再次点击同一对象 = 再试一次,不新开 tab、不重复占用一个 tab 位
|
||
if (existing.sourceLoad?.error) retryObjectSourceTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, "", options.catalog, { forceNew: true, sourceView: true });
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (tab) tab.sourceLoad = { startedAt: Date.now(), request: { ...options.request } };
|
||
void loadObjectSourceIntoTab(id);
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* 后台重新校验一个已加载源码 tab 的 DDL:不占用加载态、不打断编辑,
|
||
* 失败就保持原内容(用户并没有在等这次请求)。
|
||
*/
|
||
async function revalidateObjectSourceTab(id: string) {
|
||
if (sourceRevalidateInFlight.has(id)) return;
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
const objectSource = tab?.objectSource;
|
||
if (!tab || !objectSource) return;
|
||
const { connectionId } = tab;
|
||
const { database } = tab;
|
||
const schema = objectSource.schema || tab.schema || database;
|
||
sourceRevalidateInFlight.add(id);
|
||
try {
|
||
const connectionStore = useConnectionStore();
|
||
await connectionStore.ensureConnected(connectionId);
|
||
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
|
||
if (!databaseType) return;
|
||
const {
|
||
raw,
|
||
editableSource,
|
||
objectType: resolvedType,
|
||
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
name: objectSource.name,
|
||
objectType: objectSource.objectType,
|
||
databaseType,
|
||
signature: objectSource.signature,
|
||
});
|
||
// 期间 tab 可能被关闭、被复用或已被编辑:身份没变且用户没改过内容时才回填
|
||
const current = tabs.value.find((candidate) => candidate.id === id);
|
||
if (current?.objectSource !== objectSource || resolvedType !== objectSource.objectType) return;
|
||
if (raw.editable === false || OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType)) return;
|
||
if (isTabDirty(current)) return;
|
||
updateSql(id, editableSource);
|
||
markTabClean(current);
|
||
} catch {
|
||
// 已有的源码依然可用,静默保留
|
||
} finally {
|
||
sourceRevalidateInFlight.delete(id);
|
||
}
|
||
}
|
||
|
||
function retryObjectSourceTab(id: string) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.sourceLoad) return;
|
||
tab.sourceLoad.error = undefined;
|
||
tab.sourceLoad.startedAt = Date.now();
|
||
void loadObjectSourceIntoTab(id);
|
||
}
|
||
|
||
function clearObjectSourceLoad(tab: QueryTab) {
|
||
tab.sourceLoad = undefined;
|
||
}
|
||
|
||
async function loadObjectSourceIntoTab(id: string) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.sourceLoad || !tab.connectionId) return;
|
||
const { connectionId } = tab;
|
||
const { database } = tab;
|
||
const schema = tab.schema || database;
|
||
const { request } = tab.sourceLoad;
|
||
try {
|
||
const connectionStore = useConnectionStore();
|
||
await connectionStore.ensureConnected(connectionId);
|
||
connectionStore.activeConnectionId = connectionId;
|
||
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
|
||
if (!databaseType) throw new Error("Connection type is unavailable.");
|
||
const {
|
||
raw,
|
||
editableSource,
|
||
objectType: resolvedType,
|
||
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
name: request.name,
|
||
objectType: request.objectType,
|
||
databaseType,
|
||
signature: request.signature,
|
||
});
|
||
applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, editableSource, raw, resolvedType });
|
||
} catch (e: any) {
|
||
// 就地显示错误 + Retry:用户此刻正看着这个 tab,比 toast 更可发现
|
||
const failed = tabs.value.find((candidate) => candidate.id === id);
|
||
if (failed?.sourceLoad) failed.sourceLoad.error = e?.message || String(e);
|
||
}
|
||
}
|
||
|
||
function applyLoadedObjectSource(
|
||
id: string,
|
||
loaded: {
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
catalog?: string;
|
||
title: string;
|
||
request: ObjectSourceRequestIdentity;
|
||
editableSource: string;
|
||
raw: ObjectSource;
|
||
resolvedType: ObjectSourceKind;
|
||
},
|
||
) {
|
||
// 加载期间 tab 被关掉(用户放弃)或连接被断开:静默丢弃,不重建、不写库
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.sourceLoad) return;
|
||
const sourceIsEditable = loaded.raw.editable !== false && !OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType);
|
||
if (sourceIsEditable) {
|
||
const options: OpenObjectSourceTabOptions = {
|
||
connectionId: loaded.connectionId,
|
||
database: loaded.database,
|
||
title: loaded.title,
|
||
schema: loaded.schema,
|
||
catalog: loaded.catalog,
|
||
sql: loaded.editableSource,
|
||
objectSource: { schema: loaded.schema, name: loaded.request.name, objectType: loaded.resolvedType, signature: loaded.request.signature },
|
||
};
|
||
// 解析后的身份可能命中已存在的 tab(例:先按 FUNCTION 打开过,这次请求的是 PROCEDURE)。
|
||
// 有则交接给它并关掉 pending 占位,避免同一个对象出现两个 tab。
|
||
const existing = findMatchingObjectSourceTab(options, id);
|
||
if (existing) {
|
||
clearObjectSourceLoad(tab);
|
||
closeTab(id);
|
||
existing.sourceView = true;
|
||
switchTab(existing.id);
|
||
if (!isTabDirty(existing)) {
|
||
updateSql(existing.id, loaded.editableSource);
|
||
markTabClean(existing);
|
||
}
|
||
return;
|
||
}
|
||
updateSql(id, loaded.editableSource);
|
||
setObjectSource(id, options.objectSource);
|
||
} else {
|
||
updateSql(id, loaded.editableSource);
|
||
}
|
||
tab.sourceView = true;
|
||
markTabClean(tab);
|
||
clearObjectSourceLoad(tab);
|
||
}
|
||
|
||
function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) {
|
||
const id = createTab(connectionId, database, undefined, "query", undefined, sql);
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab) return id;
|
||
|
||
const results = markQueryResultsRowsRaw(queryResults);
|
||
const firstDataResult = results.findIndex((result) => result.columns.length > 0);
|
||
const activeIndex = firstDataResult >= 0 ? firstDataResult : 0;
|
||
tab.lastExecutedSql = sql;
|
||
tab.resultBaseSql = sql;
|
||
tab.results = results.length > 1 ? results : undefined;
|
||
tab.activeResultIndex = results.length > 1 ? activeIndex : undefined;
|
||
tab.result = results[activeIndex];
|
||
tab.isExecuting = false;
|
||
tab.isCancelling = false;
|
||
tab.executionId = undefined;
|
||
tab.executingResultRunId = undefined;
|
||
tab.queryExecutionStartedAt = undefined;
|
||
// An externally-supplied result is a brand-new dataset, not the previous
|
||
// one: publish a fresh generation so the tab can capture a view snapshot.
|
||
publishResultGeneration(tab, "execute");
|
||
if (tab.result) touchResult(tab);
|
||
return id;
|
||
}
|
||
|
||
function refreshExternalSqlFileTitles() {
|
||
const externalTabs = tabs.value.filter((tab) => tab.mode === "query" && tab.externalSqlPath);
|
||
const titles = externalSqlFileDisplayTitles(externalTabs.map((tab) => tab.externalSqlPath!));
|
||
externalTabs.forEach((tab, index) => {
|
||
tab.title = titles[index];
|
||
tab.customTitle = true;
|
||
});
|
||
}
|
||
|
||
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string) {
|
||
const normalizedPath = normalizeExternalSqlPath(path);
|
||
const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
// File-backed tabs are identified by their full path, not their basename.
|
||
// Bypassing createTab avoids overwriting another file with the same name.
|
||
const id = uuid();
|
||
const dbType = useConnectionStore().getConfig(connectionId)?.db_type;
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: "",
|
||
customTitle: true,
|
||
connectionId,
|
||
database,
|
||
catalog,
|
||
// Restoring the schema keeps the reopened file on the namespace it was
|
||
// saved from. Without it the tab has no schema, so sidebar locate and the
|
||
// metadata paths fall back to the connection default (issue #7648).
|
||
schema,
|
||
sql,
|
||
originalSql: sql,
|
||
externalSqlPath: path,
|
||
externalSqlFileVersion: version,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "query",
|
||
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
|
||
};
|
||
registerOpenTab(tab);
|
||
refreshExternalSqlFileTitles();
|
||
return id;
|
||
}
|
||
|
||
function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string, eventName?: string, eventReadOnly = false, initialObjectFilter?: "tables" | "events", eventCreateRequestId?: number) {
|
||
const title = catalog ? `${catalog}.${database} objects` : schema ? `${schema} objects` : `${database} objects`;
|
||
const existing = tabs.value.find((tab) => tab.mode === "objects" && tab.connectionId === connectionId && tab.database === database && (tab.objectBrowser?.catalog || "") === (catalog || "") && (tab.objectBrowser?.schema || "") === (schema || ""));
|
||
if (existing) {
|
||
if (eventCreateRequestId !== undefined) {
|
||
// 新建事件:显式 CREATE 请求优先,并清掉可能残留的"编辑已有事件"状态,
|
||
// 保证同一 tab 被复用时每次点击都能重新进入 CREATE 编辑器(请求号单调递增)。
|
||
existing.objectBrowser = {
|
||
...existing.objectBrowser,
|
||
eventName: undefined,
|
||
eventReadOnly: false,
|
||
eventOpenRequestId: undefined,
|
||
eventCreateRequestId,
|
||
initialObjectFilter: initialObjectFilter ?? "events",
|
||
};
|
||
} else if (eventName) {
|
||
existing.objectBrowser = {
|
||
...existing.objectBrowser,
|
||
eventName,
|
||
eventReadOnly,
|
||
eventCreateRequestId: undefined,
|
||
initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : existing.objectBrowser?.initialObjectFilter),
|
||
eventOpenRequestId: (existing.objectBrowser?.eventOpenRequestId ?? 0) + 1,
|
||
};
|
||
}
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "objects",
|
||
objectBrowser: {
|
||
catalog,
|
||
schema,
|
||
objectType: "tables",
|
||
eventName: eventCreateRequestId !== undefined ? undefined : eventName,
|
||
eventReadOnly: eventCreateRequestId !== undefined ? false : eventReadOnly,
|
||
initialObjectFilter: initialObjectFilter ?? (eventName || eventCreateRequestId !== undefined ? "events" : undefined),
|
||
eventOpenRequestId: eventName ? 1 : undefined,
|
||
eventCreateRequestId,
|
||
},
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openDatabaseBrowser(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "databases" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
return registerOpenTab({
|
||
id,
|
||
title: "Databases",
|
||
connectionId,
|
||
database: "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "databases",
|
||
});
|
||
}
|
||
|
||
function openDriverProfileWorkspace(connectionId: string, database: string, title: string, mode: QueryTab["mode"], tabScope: DriverProfileWorkspaceScope = "database", workspaceBranch?: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === mode && tab.connectionId === connectionId && (tabScope === "connection" || tab.database === database));
|
||
if (existing) {
|
||
if (existing.database !== database) updateDatabase(existing.id, database);
|
||
existing.workspaceBranch = workspaceBranch;
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
return registerOpenTab({
|
||
id,
|
||
title,
|
||
connectionId,
|
||
database,
|
||
workspaceBranch,
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode,
|
||
});
|
||
}
|
||
|
||
function switchTab(tabId: string) {
|
||
const owner = groupForTab(tabId);
|
||
if (owner) {
|
||
activateTabInGroup(owner.id, tabId);
|
||
return;
|
||
}
|
||
activeTabId.value = tabId;
|
||
settingsStore.settingsPageActive = false;
|
||
if (typeof window !== "undefined") window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
|
||
}
|
||
|
||
function openUserAdmin(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "users" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: t("userAdmin.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "users",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openProcessList(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "processlist" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("processList.title")}` : t("processList.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "processlist",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openSqlServerActivityTrace(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "sqlserver-trace" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("sqlServerTrace.title")}` : t("sqlServerTrace.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "sqlserver-trace",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openMysqlDashboard(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "mysql-dashboard" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
|
||
connectionId,
|
||
database: "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "mysql-dashboard",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openPostgresDashboard(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "postgres-dashboard" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "postgres-dashboard",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openXuguDashboard(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "xugu-dashboard" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("xuguServerDashboard.title")}` : t("xuguServerDashboard.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "xugu-dashboard",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openNacosDashboard(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "nacos-dashboard" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "nacos-dashboard",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openDamengJobAdmin(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: t("damengJobAdmin.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "dameng-jobs",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openDamengUsers(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "dameng-users" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: t("damengUserAdmin.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "dameng-users",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openDamengRoles(connectionId: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "dameng-roles" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: t("damengRoleAdmin.title"),
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "dameng-roles",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openMongoBucket(connectionId: string, database: string, bucketName: string) {
|
||
const title = `${database}.${bucketName}`;
|
||
const existing = tabs.value.find((tab) => tab.mode === "mongo-bucket" && tab.connectionId === connectionId && tab.database === database && tab.mongoBucket?.bucketName === bucketName);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title,
|
||
connectionId,
|
||
database,
|
||
sql: bucketName,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "mongo-bucket",
|
||
mongoBucket: {
|
||
bucketName,
|
||
},
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openMongoGridFs(connectionId: string, database: string) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "mongo-gridfs" && tab.connectionId === connectionId && tab.database === database);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: "GridFS",
|
||
connectionId,
|
||
database,
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "mongo-gridfs",
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openMqAdmin(connectionId: string, target?: { tenant?: string; initialTab?: QueryTab["mqInitialTab"] }) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "mq" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
if (target?.tenant) existing.mqTenant = target.tenant;
|
||
if (target?.initialTab) existing.mqInitialTab = target.initialTab;
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: `${conn?.name || "Message Queue"} Admin`,
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "mq",
|
||
mqTenant: target?.tenant,
|
||
mqInitialTab: target?.initialTab,
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string; dataId?: string; group?: string; keyword?: string }) {
|
||
const namespace = target?.namespace ?? "";
|
||
const namespaceName = target?.namespaceName || (namespace ? namespace : "public");
|
||
const existing = tabs.value.find((tab) => tab.mode === "nacos" && tab.connectionId === connectionId && (tab.nacosNamespace || "") === namespace);
|
||
if (existing) {
|
||
existing.nacosNamespaceName = namespaceName;
|
||
if (target?.dataId) {
|
||
existing.nacosTargetDataId = target.dataId;
|
||
existing.nacosTargetGroup = target.group || "DEFAULT_GROUP";
|
||
existing.nacosTargetKeyword = target.keyword;
|
||
existing.nacosTargetRequestId = (existing.nacosTargetRequestId ?? 0) + 1;
|
||
}
|
||
if (!existing.customTitle) existing.title = `${useConnectionStore().getConfig(connectionId)?.name || "Nacos"}:${namespaceName}`;
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: `${conn?.name || "Nacos"}:${namespaceName}`,
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "nacos",
|
||
nacosNamespace: namespace,
|
||
nacosNamespaceName: namespaceName,
|
||
nacosTargetDataId: target?.dataId,
|
||
nacosTargetGroup: target?.group,
|
||
nacosTargetKeyword: target?.keyword,
|
||
nacosTargetRequestId: target?.dataId ? 1 : undefined,
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openMqttAdmin(connectionId: string, target?: { initialTopic?: string }) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "mqtt" && tab.connectionId === connectionId);
|
||
if (existing) {
|
||
if (target?.initialTopic) existing.mqttInitialTopic = target.initialTopic;
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const conn = useConnectionStore().getConfig(connectionId);
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: "connection.mqttConsoleTitle",
|
||
connectionId,
|
||
database: conn?.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "mqtt",
|
||
mqttInitialTopic: target?.initialTopic,
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) {
|
||
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
|
||
if (!tab || (requestId !== undefined && tab.nacosTargetRequestId !== requestId)) return;
|
||
tab.nacosTargetDataId = undefined;
|
||
tab.nacosTargetGroup = undefined;
|
||
tab.nacosTargetKeyword = undefined;
|
||
}
|
||
|
||
function openPluginWorkbench(pluginId: string, contributionId: string, options: { title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean } = {}) {
|
||
if (!options.forceNew) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && tab.connectionId === (options.connectionId || ""));
|
||
if (existing) {
|
||
if (options.context) existing.pluginWorkbench = { ...existing.pluginWorkbench!, context: snapshotPluginWorkbenchContext(options.context) };
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
}
|
||
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: options.title || contributionId,
|
||
connectionId: options.connectionId || "",
|
||
database: options.database || "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "plugin-workbench",
|
||
pluginWorkbench: {
|
||
pluginId,
|
||
contributionId,
|
||
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
|
||
},
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
|
||
if (!options.forceNew) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
|
||
if (existing) {
|
||
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
}
|
||
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: options.title || providerId,
|
||
connectionId: options.connectionId || "",
|
||
database: "",
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "plugin-filesystem",
|
||
pluginFilesystem: {
|
||
pluginId,
|
||
providerId,
|
||
rootUri: options.rootUri,
|
||
currentUri: options.currentUri,
|
||
},
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
async function openPluginConnection(connectionId: string) {
|
||
const connectionStore = useConnectionStore();
|
||
const connection = connectionStore.getConfig(connectionId);
|
||
if (!connection || connection.db_type !== "plugin") throw new Error("Plugin connection config not found");
|
||
const pluginId = connection.plugin_id;
|
||
const providerId = connection.plugin_connection_provider;
|
||
if (!pluginId || !providerId) throw new Error("Plugin connection binding is incomplete");
|
||
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
|
||
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
|
||
if (!provider) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' is unavailable`);
|
||
const workbench = provider.contribution.workbench ? registry.findWorkbench(pluginId, provider.contribution.workbench) : undefined;
|
||
await connectionStore.ensureConnected(connectionId);
|
||
if (workbench) {
|
||
return openPluginWorkbench(pluginId, workbench.contribution.id, {
|
||
title: connection.name,
|
||
connectionId,
|
||
context: {
|
||
connectionId,
|
||
providerId,
|
||
connectionType: connection.plugin_connection_type,
|
||
},
|
||
});
|
||
}
|
||
const filesystemProviderId = provider.contribution.filesystem_provider;
|
||
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
|
||
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
|
||
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
|
||
title: connection.name,
|
||
connectionId,
|
||
rootUri: filesystem.contribution.root_uri,
|
||
});
|
||
}
|
||
|
||
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
|
||
if (!initialTab && !initialTarget?.name) return;
|
||
if (initialTab) tab.structureInitialTab = initialTab;
|
||
tab.structureInitialTarget = initialTarget?.name ? initialTarget : undefined;
|
||
tab.structureInitialTabRequestId = (tab.structureInitialTabRequestId ?? 0) + 1;
|
||
}
|
||
|
||
function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget, catalog?: string) {
|
||
const resolvedTableName = tableName || "";
|
||
if (resolvedTableName) {
|
||
const existing = tabs.value.find((tab) => tab.mode === "structure" && tab.connectionId === connectionId && tab.database === database && (tab.catalog || "") === (catalog || "") && (tab.schema || "") === (schema || "") && (tab.structureTableName || "") === resolvedTableName);
|
||
if (existing) {
|
||
applyTableStructureInitialTab(existing, initialTab, initialTarget);
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
}
|
||
|
||
const title = resolvedTableName ? t("structureEditor.editTabTitle", { tableName: resolvedTableName }) : t("structureEditor.createTitle");
|
||
const id = uuid();
|
||
const tab: QueryTab = {
|
||
id,
|
||
title,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
sql: "",
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "structure",
|
||
structureTableName: resolvedTableName,
|
||
structureInitialTab: initialTab,
|
||
structureInitialTabRequestId: initialTab || initialTarget?.name ? 1 : undefined,
|
||
structureInitialTarget: initialTarget?.name ? initialTarget : undefined,
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
function isTabDirty(tab: QueryTab): boolean {
|
||
if (tab.mode === "structure") {
|
||
// Legacy persisted structure drafts predate the dirty flag; treat them as dirty until the editor rehydrates them.
|
||
return !!tab.structureDraft && tab.structureDraft.dirty !== false;
|
||
}
|
||
if (tab.mode !== "query") return false;
|
||
if (!tab.externalSqlPath && !tab.sql.trim() && !(tab.savedSqlId && tab.originalSql !== undefined)) return false;
|
||
const original = tab.originalSql;
|
||
if (original === undefined) return !!tab.savedSqlId;
|
||
return tab.sql !== original;
|
||
}
|
||
|
||
const hasDirtyTabs = computed(() => tabs.value.some((tab) => isTabDirty(tab)));
|
||
const shouldConfirmUnsavedSqlClose = computed(() => useSettingsStore().editorSettings.confirmUnsavedSqlClose);
|
||
const keepUnsavedTabsDraftsOnAppClose = computed(() => useSettingsStore().editorSettings.appCloseUnsavedTabsMode === "keep-drafts");
|
||
const requiresAppCloseDraftPersist = computed(() => shouldConfirmUnsavedSqlClose.value && keepUnsavedTabsDraftsOnAppClose.value && tabs.value.some((tab) => tab.mode === "query" && isTabDirty(tab)));
|
||
|
||
function shouldConfirmTabOnAppClose(tab: QueryTab): boolean {
|
||
if (!shouldConfirmTabClose(tab)) return false;
|
||
return !keepUnsavedTabsDraftsOnAppClose.value || tab.mode !== "query";
|
||
}
|
||
|
||
const closeConfirmDirtyTabIds = computed(() => {
|
||
if (isConfirmingAppClose.value) return tabs.value.filter((tab) => shouldConfirmTabOnAppClose(tab)).map((tab) => tab.id);
|
||
if (pendingBatchCloseTabIds.value) {
|
||
return pendingBatchCloseTabIds.value
|
||
.map((id) => tabs.value.find((tab) => tab.id === id))
|
||
.filter((tab): tab is QueryTab => !!tab && shouldConfirmTabClose(tab))
|
||
.map((tab) => tab.id);
|
||
}
|
||
const pendingTab = pendingCloseTabId.value ? tabs.value.find((tab) => tab.id === pendingCloseTabId.value) : undefined;
|
||
return pendingTab && isTabDirty(pendingTab) ? [pendingTab.id] : [];
|
||
});
|
||
|
||
function showDirtyTabCloseConfirm(tab: QueryTab, context: CloseConfirmContext) {
|
||
pendingCloseTabId.value = tab.id;
|
||
closeConfirmContext.value = context;
|
||
// Move focus through the atomic activate so the confirming dialog, the
|
||
// shared result area, and the dirty tab's owner group stay coherent.
|
||
if (!activateTab(tab.id)) {
|
||
activeTabId.value = tab.id;
|
||
}
|
||
showCloseConfirm.value = true;
|
||
}
|
||
|
||
function markTabClean(tab: QueryTab | undefined) {
|
||
if (tab) tab.originalSql = tab.sql;
|
||
}
|
||
|
||
function applyExternalSqlFileSnapshot(id: string, sql: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.externalSqlPath) return;
|
||
tab.sql = sql;
|
||
tab.originalSql = sql;
|
||
tab.externalSqlFileVersion = version;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
}
|
||
|
||
function markExternalSqlFileSaved(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.externalSqlPath) return;
|
||
tab.originalSql = tab.sql;
|
||
tab.externalSqlFileVersion = version;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
}
|
||
|
||
function updateExternalSqlFileVersion(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.externalSqlPath) return;
|
||
tab.externalSqlFileVersion = version;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
}
|
||
|
||
function ignoreExternalSqlFileVersion(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.externalSqlPath) return;
|
||
tab.externalSqlIgnoredFileVersion = version;
|
||
tab.externalSqlFileMissing = undefined;
|
||
}
|
||
|
||
function acknowledgeExternalSqlFileMissing(id: string) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.externalSqlPath) return;
|
||
tab.externalSqlFileMissing = true;
|
||
}
|
||
|
||
function relocateExternalSqlFilePath(previousPath: string, nextPath: string, version?: QueryTab["externalSqlFileVersion"]) {
|
||
const previous = normalizeExternalSqlPath(previousPath);
|
||
if (!previous) return;
|
||
for (const tab of tabs.value) {
|
||
if (tab.mode !== "query" || !tab.externalSqlPath || normalizeExternalSqlPath(tab.externalSqlPath) !== previous) continue;
|
||
tab.externalSqlPath = nextPath;
|
||
if (version) tab.externalSqlFileVersion = version;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
}
|
||
refreshExternalSqlFileTitles();
|
||
}
|
||
|
||
function markExternalSqlFileMissingForPath(path: string) {
|
||
const normalizedPath = normalizeExternalSqlPath(path);
|
||
if (!normalizedPath) return;
|
||
for (const tab of tabs.value) {
|
||
if (tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath) {
|
||
tab.externalSqlFileMissing = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function persistSavedSqlEditorPosition(tab: QueryTab | undefined) {
|
||
if (!tab?.savedSqlId || tab.mode !== "query") return;
|
||
const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId);
|
||
if (pending) {
|
||
clearTimeout(pending);
|
||
savedSqlEditorPositionTimers.delete(tab.savedSqlId);
|
||
}
|
||
saveSavedSqlEditorPosition(
|
||
createSavedSqlEditorPosition({
|
||
savedSqlId: tab.savedSqlId,
|
||
sql: tab.sql,
|
||
selection: tab.editorSelection,
|
||
viewport: tab.editorViewport,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function flushEditorState(id: string): Promise<void> {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab) return Promise.resolve();
|
||
persistSavedSqlEditorPosition(tab);
|
||
return flushPendingPersist();
|
||
}
|
||
|
||
function queueSavedSqlEditorPositionPersist(tab: QueryTab | undefined) {
|
||
if (!tab?.savedSqlId || tab.mode !== "query") return;
|
||
const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId);
|
||
if (pending) clearTimeout(pending);
|
||
const tabId = tab.id;
|
||
const savedSqlId = tab.savedSqlId;
|
||
const timer = setTimeout(() => {
|
||
savedSqlEditorPositionTimers.delete(savedSqlId);
|
||
persistSavedSqlEditorPosition(tabs.value.find((item) => item.id === tabId));
|
||
}, SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS);
|
||
savedSqlEditorPositionTimers.set(savedSqlId, timer);
|
||
}
|
||
|
||
function discardTabChanges(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab) return false;
|
||
if (tab.mode === "structure") {
|
||
tab.structureDraft = undefined;
|
||
return true;
|
||
}
|
||
if (tab.mode !== "query") return false;
|
||
if (tab.originalSql !== undefined) {
|
||
tab.sql = tab.originalSql;
|
||
return true;
|
||
}
|
||
if (tab.savedSqlId) {
|
||
tab.sql = "";
|
||
return true;
|
||
}
|
||
tab.sql = "";
|
||
tab.originalSql = "";
|
||
return true;
|
||
}
|
||
|
||
function finishPendingBatchClose() {
|
||
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
|
||
const onComplete = pendingBatchCloseComplete;
|
||
pendingBatchCloseTabIds.value = null;
|
||
pendingBatchCloseFinalActiveTabId.value = undefined;
|
||
pendingBatchCloseComplete = null;
|
||
if (finalActiveTabId !== undefined) {
|
||
commitBatchCloseActiveTab(finalActiveTabId);
|
||
}
|
||
return onComplete;
|
||
}
|
||
|
||
/**
|
||
* Applies a batch close's chosen active tab while keeping the group
|
||
* invariants: the surviving tab becomes its owner group's active tab and the
|
||
* focused group. Falls back to the focused group when the choice no longer
|
||
* exists.
|
||
*/
|
||
function commitBatchCloseActiveTab(finalActiveTabId: string | null) {
|
||
if (finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId)) {
|
||
if (!activateTab(finalActiveTabId)) {
|
||
syncActiveTabFromFocusedGroup();
|
||
}
|
||
return;
|
||
}
|
||
syncActiveTabFromFocusedGroup();
|
||
}
|
||
|
||
function continuePendingBatchClose() {
|
||
const pendingIds = pendingBatchCloseTabIds.value;
|
||
if (!pendingIds) return;
|
||
|
||
const remainingIds = pendingIds.filter((id) => tabs.value.some((tab) => tab.id === id));
|
||
pendingBatchCloseTabIds.value = remainingIds;
|
||
if (remainingIds.length === 0) {
|
||
finishPendingBatchClose()?.();
|
||
return;
|
||
}
|
||
|
||
const dirtyTab = remainingIds.map((id) => tabs.value.find((tab) => tab.id === id)).find((tab): tab is QueryTab => !!tab && shouldConfirmTabClose(tab));
|
||
if (dirtyTab) {
|
||
// Batch close must pause before dropping dirty tabs so the shared save/discard dialog protects every editable surface.
|
||
showDirtyTabCloseConfirm(dirtyTab, "batch");
|
||
return;
|
||
}
|
||
|
||
const onComplete = finishPendingBatchClose();
|
||
for (const id of remainingIds) closeTab(id, { force: true });
|
||
onComplete?.();
|
||
}
|
||
|
||
function beginBatchClose(ids: string[], finalActiveTabId?: string | null, onComplete?: () => void) {
|
||
const uniqueIds = [...new Set(ids)].filter((id) => tabs.value.some((tab) => tab.id === id));
|
||
if (uniqueIds.length === 0) {
|
||
onComplete?.();
|
||
return;
|
||
}
|
||
|
||
const existingIds = pendingBatchCloseTabIds.value;
|
||
if (existingIds) {
|
||
// Sidebar bulk disconnects settle independently, so later scopes must join the open dialog instead of replacing it.
|
||
const combinedIds = [...new Set([...existingIds, ...uniqueIds])];
|
||
const preferredFinalActiveTabId = finalActiveTabId !== undefined ? finalActiveTabId : pendingBatchCloseFinalActiveTabId.value;
|
||
pendingBatchCloseTabIds.value = combinedIds;
|
||
pendingBatchCloseFinalActiveTabId.value = preferredFinalActiveTabId && combinedIds.includes(preferredFinalActiveTabId) ? activeTabAfterClosing(combinedIds, preferredFinalActiveTabId) : preferredFinalActiveTabId;
|
||
if (onComplete) {
|
||
const previousComplete = pendingBatchCloseComplete;
|
||
pendingBatchCloseComplete = previousComplete
|
||
? () => {
|
||
try {
|
||
previousComplete();
|
||
} finally {
|
||
onComplete();
|
||
}
|
||
}
|
||
: onComplete;
|
||
}
|
||
} else {
|
||
pendingBatchCloseTabIds.value = uniqueIds;
|
||
pendingBatchCloseFinalActiveTabId.value = finalActiveTabId;
|
||
pendingBatchCloseComplete = onComplete ?? null;
|
||
}
|
||
continuePendingBatchClose();
|
||
}
|
||
|
||
function resumePendingBatchCloseAfter(id: string) {
|
||
const pendingIds = pendingBatchCloseTabIds.value;
|
||
if (!pendingIds?.includes(id)) return;
|
||
pendingBatchCloseTabIds.value = pendingIds.filter((pendingId) => pendingId !== id);
|
||
continuePendingBatchClose();
|
||
}
|
||
|
||
function closeTab(id: string, { force = false }: { force?: boolean } = {}) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
if (!force && shouldConfirmTabClose(tab)) {
|
||
showDirtyTabCloseConfirm(tab, "tab");
|
||
return;
|
||
}
|
||
const idx = tabs.value.findIndex((t) => t.id === id);
|
||
if (idx < 0) return;
|
||
persistSavedSqlEditorPosition(tabs.value[idx]);
|
||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||
clearDataGridPendingSnapshotsForTab(id);
|
||
beginClosingDataGridViewSnapshotsForTab(id);
|
||
beginClosingBrowserState(id);
|
||
clearDataGridStructuredFilterStatesForTab(id);
|
||
clearDataGridSearchStatesForTab(id);
|
||
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
|
||
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
|
||
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
|
||
void closeResultSession(tabs.value[idx]);
|
||
void closeClientConnectionSession(tabs.value[idx]);
|
||
clearResultRunSnapshots(tabs.value[idx]);
|
||
void deleteTabResultSnapshot(tabResultCacheKey(id));
|
||
releaseTabResultObjectPayloads(tabs.value[idx]);
|
||
clearResultRuns(tabs.value[idx]);
|
||
clearResultPayload(tabs.value[idx]);
|
||
const owner = groupForTab(id);
|
||
const ownerIndexInGroup = owner ? owner.tabIds.indexOf(id) : -1;
|
||
const wasOwnerActive = owner?.activeTabId === id;
|
||
const wasGlobalActive = activeTabId.value === id;
|
||
|
||
tabs.value.splice(idx, 1);
|
||
if (tab.externalSqlPath) refreshExternalSqlFileTitles();
|
||
|
||
if (owner) {
|
||
owner.tabIds = owner.tabIds.filter((tabId) => tabId !== id);
|
||
if (wasOwnerActive) {
|
||
const groupHistory = activeTabHistory.value.filter((tabId) => tabId !== id && owner.tabIds.includes(tabId));
|
||
owner.activeTabId = groupHistory[groupHistory.length - 1] ?? owner.tabIds[Math.min(ownerIndexInGroup, owner.tabIds.length - 1)] ?? null;
|
||
}
|
||
if (owner.tabIds.length === 0 && groups.value.length > 1) {
|
||
const removedIndex = groups.value.findIndex((group) => group.id === owner.id);
|
||
groups.value.splice(removedIndex, 1);
|
||
if (focusedGroupId.value === owner.id) {
|
||
focusedGroupId.value = groups.value[0]?.id ?? "main";
|
||
}
|
||
repairGroupSizes();
|
||
}
|
||
}
|
||
|
||
if (wasGlobalActive) {
|
||
const nextGroup = focusedGroup();
|
||
activeTabId.value = nextGroup?.activeTabId ?? null;
|
||
}
|
||
if (force) resumePendingBatchCloseAfter(id);
|
||
}
|
||
|
||
function shouldConfirmTabClose(tab: QueryTab): boolean {
|
||
if (tab.mode === "structure") return isTabDirty(tab);
|
||
return shouldConfirmUnsavedSqlClose.value && isTabDirty(tab);
|
||
}
|
||
|
||
function forceClosePendingTab() {
|
||
const id = pendingCloseTabId.value;
|
||
const confirmingAppClose = isConfirmingAppClose.value;
|
||
pendingCloseTabId.value = null;
|
||
showCloseConfirm.value = false;
|
||
closeConfirmContext.value = "tab";
|
||
if (confirmingAppClose) {
|
||
if (id) discardTabChanges(id);
|
||
isConfirmingAppClose.value = false;
|
||
return;
|
||
}
|
||
if (id) closeTab(id, { force: true });
|
||
}
|
||
|
||
function forceCloseAllPendingTabs() {
|
||
const dirtyIds = closeConfirmDirtyTabIds.value;
|
||
const pendingId = pendingCloseTabId.value;
|
||
const batchIds = pendingBatchCloseTabIds.value?.filter((id) => tabs.value.some((tab) => tab.id === id)) ?? null;
|
||
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
|
||
const onBatchComplete = pendingBatchCloseComplete;
|
||
const confirmingAppClose = isConfirmingAppClose.value;
|
||
|
||
pendingCloseTabId.value = null;
|
||
showCloseConfirm.value = false;
|
||
pendingBatchCloseTabIds.value = null;
|
||
pendingBatchCloseFinalActiveTabId.value = undefined;
|
||
pendingBatchCloseComplete = null;
|
||
isConfirmingAppClose.value = false;
|
||
closeConfirmContext.value = "tab";
|
||
|
||
for (const id of dirtyIds) discardTabChanges(id);
|
||
if (confirmingAppClose) return;
|
||
|
||
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
|
||
for (const id of idsToClose) closeTab(id, { force: true });
|
||
if (finalActiveTabId !== undefined) {
|
||
commitBatchCloseActiveTab(finalActiveTabId);
|
||
}
|
||
if (batchIds) onBatchComplete?.();
|
||
}
|
||
|
||
function cancelClosePendingTab() {
|
||
pendingCloseTabId.value = null;
|
||
showCloseConfirm.value = false;
|
||
pendingBatchCloseTabIds.value = null;
|
||
pendingBatchCloseFinalActiveTabId.value = undefined;
|
||
pendingBatchCloseComplete = null;
|
||
isConfirmingAppClose.value = false;
|
||
closeConfirmContext.value = "tab";
|
||
}
|
||
|
||
function saveAndClosePendingTab() {
|
||
const id = pendingCloseTabId.value;
|
||
pendingCloseTabId.value = null;
|
||
showCloseConfirm.value = false;
|
||
isConfirmingAppClose.value = false;
|
||
closeConfirmContext.value = "tab";
|
||
if (id) return id;
|
||
return null;
|
||
}
|
||
|
||
function suspendCloseConfirm() {
|
||
showCloseConfirm.value = false;
|
||
}
|
||
|
||
function resumeCloseConfirm() {
|
||
const dirtyId = closeConfirmDirtyTabIds.value[0];
|
||
const dirtyTab = dirtyId ? tabs.value.find((tab) => tab.id === dirtyId) : undefined;
|
||
if (!dirtyTab) return false;
|
||
pendingCloseTabId.value = dirtyTab.id;
|
||
activateTab(dirtyTab.id);
|
||
showCloseConfirm.value = true;
|
||
return true;
|
||
}
|
||
|
||
function completePendingCloseAfterSaveAll() {
|
||
const pendingId = pendingCloseTabId.value;
|
||
const batchIds = pendingBatchCloseTabIds.value?.filter((id) => tabs.value.some((tab) => tab.id === id)) ?? null;
|
||
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
|
||
const onBatchComplete = pendingBatchCloseComplete;
|
||
const confirmingAppClose = isConfirmingAppClose.value;
|
||
|
||
pendingCloseTabId.value = null;
|
||
showCloseConfirm.value = false;
|
||
pendingBatchCloseTabIds.value = null;
|
||
pendingBatchCloseFinalActiveTabId.value = undefined;
|
||
pendingBatchCloseComplete = null;
|
||
isConfirmingAppClose.value = false;
|
||
closeConfirmContext.value = "tab";
|
||
|
||
if (confirmingAppClose) return "app" as const;
|
||
|
||
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
|
||
for (const id of idsToClose) closeTab(id, { force: true });
|
||
if (finalActiveTabId !== undefined) {
|
||
commitBatchCloseActiveTab(finalActiveTabId);
|
||
}
|
||
if (batchIds) onBatchComplete?.();
|
||
return "tabs" as const;
|
||
}
|
||
|
||
function closeOtherTabs(id: string) {
|
||
if (!tabs.value.some((tab) => tab.id === id)) return;
|
||
beginBatchClose(
|
||
tabs.value.filter((tab) => tab.id !== id).map((tab) => tab.id),
|
||
id,
|
||
);
|
||
}
|
||
|
||
function closeOtherTabsInGroup(groupId: string, id: string) {
|
||
const group = findGroup(groupId);
|
||
const target = tabs.value.find((tab) => tab.id === id);
|
||
if (!group || !target) return;
|
||
const ids = group.tabIds.filter((tabId) => {
|
||
const tab = tabs.value.find((item) => item.id === tabId);
|
||
return tab && tabId !== id && Boolean(tab.pinned) === Boolean(target.pinned);
|
||
});
|
||
beginBatchClose(ids, id);
|
||
}
|
||
|
||
function closeAllTabsInGroup(groupId: string, id: string) {
|
||
const group = findGroup(groupId);
|
||
const target = tabs.value.find((tab) => tab.id === id);
|
||
if (!group || !target) return;
|
||
const ids = group.tabIds.filter((tabId) => {
|
||
const tab = tabs.value.find((item) => item.id === tabId);
|
||
return tab && Boolean(tab.pinned) === Boolean(target.pinned);
|
||
});
|
||
if (ids.length === 0) return;
|
||
const finalActiveTabId = group.activeTabId && !ids.includes(group.activeTabId) ? group.activeTabId : id;
|
||
beginBatchClose(ids, finalActiveTabId);
|
||
}
|
||
|
||
function closeTabsByIds(ids: Iterable<string>, finalActiveTabId?: string | null, onComplete?: () => void) {
|
||
beginBatchClose([...ids], finalActiveTabId, onComplete);
|
||
}
|
||
|
||
function closeRightTabs(id: string, onComplete?: () => void) {
|
||
const target = tabs.value.find((tab) => tab.id === id);
|
||
if (!target) return;
|
||
|
||
const groupedTabs = tabs.value.filter((tab) => Boolean(tab.pinned) === Boolean(target.pinned));
|
||
const targetIndex = groupedTabs.findIndex((tab) => tab.id === id);
|
||
const ids = groupedTabs.slice(targetIndex + 1).map((tab) => tab.id);
|
||
if (ids.length === 0) {
|
||
onComplete?.();
|
||
return;
|
||
}
|
||
|
||
const finalActiveTabId = activeTabId.value && !ids.includes(activeTabId.value) ? activeTabId.value : id;
|
||
closeTabsByIds(ids, finalActiveTabId, onComplete);
|
||
}
|
||
|
||
function finalActiveTabAfterClosing(ids: string[]) {
|
||
const closingIds = new Set(ids);
|
||
const activeTab = activeTabId.value ? tabs.value.find((tab) => tab.id === activeTabId.value) : undefined;
|
||
if (activeTab && !closingIds.has(activeTab.id)) return activeTab.id;
|
||
return tabs.value.find((tab) => !closingIds.has(tab.id))?.id ?? null;
|
||
}
|
||
|
||
function activeTabAfterClosing(ids: string[], preferredActiveTabId = activeTabId.value) {
|
||
const closingIds = new Set(ids);
|
||
if (preferredActiveTabId && !closingIds.has(preferredActiveTabId) && tabs.value.some((tab) => tab.id === preferredActiveTabId)) {
|
||
return preferredActiveTabId;
|
||
}
|
||
const preferredIndex = preferredActiveTabId ? tabs.value.findIndex((tab) => tab.id === preferredActiveTabId) : -1;
|
||
const remainingTabs = tabs.value.filter((tab) => !closingIds.has(tab.id));
|
||
if (preferredIndex < 0) return remainingTabs[0]?.id ?? null;
|
||
return remainingTabs[Math.min(preferredIndex, remainingTabs.length - 1)]?.id ?? null;
|
||
}
|
||
|
||
function closeOtherRegularTabs(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab || tab.pinned) return;
|
||
beginBatchClose(
|
||
tabs.value.filter((item) => !item.pinned && item.id !== id).map((item) => item.id),
|
||
id,
|
||
);
|
||
}
|
||
|
||
function closeRegularTabs() {
|
||
const ids = tabs.value.filter((tab) => !tab.pinned).map((tab) => tab.id);
|
||
beginBatchClose(ids, finalActiveTabAfterClosing(ids));
|
||
}
|
||
|
||
function closeOtherFixedTabs(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab || !tab.pinned) return;
|
||
beginBatchClose(
|
||
tabs.value.filter((item) => item.pinned && item.id !== id).map((item) => item.id),
|
||
id,
|
||
);
|
||
}
|
||
|
||
function closeFixedTabs() {
|
||
const ids = tabs.value.filter((tab) => tab.pinned).map((tab) => tab.id);
|
||
beginBatchClose(ids, finalActiveTabAfterClosing(ids));
|
||
}
|
||
|
||
function closeAllTabs() {
|
||
beginBatchClose(
|
||
tabs.value.map((tab) => tab.id),
|
||
null,
|
||
);
|
||
}
|
||
|
||
function requestAppCloseConfirmation() {
|
||
const dirtyTab = tabs.value.find((tab) => shouldConfirmTabOnAppClose(tab));
|
||
if (!dirtyTab) return false;
|
||
isConfirmingAppClose.value = true;
|
||
showDirtyTabCloseConfirm(dirtyTab, "app");
|
||
return true;
|
||
}
|
||
|
||
function duplicateTab(id: string) {
|
||
const idx = tabs.value.findIndex((t) => t.id === id);
|
||
if (idx < 0) return;
|
||
const original = tabs.value[idx];
|
||
const newId = uuid();
|
||
const newTab: QueryTab = {
|
||
id: newId,
|
||
title: original.title,
|
||
customTitle: original.customTitle,
|
||
connectionId: original.connectionId,
|
||
database: original.database,
|
||
schema: original.schema,
|
||
catalog: original.catalog,
|
||
sql: original.sql,
|
||
originalSql: "",
|
||
savedSqlId: undefined,
|
||
externalSqlPath: undefined,
|
||
lastExecutedSql: undefined,
|
||
resultBaseSql: original.resultBaseSql,
|
||
resultSortedSql: undefined,
|
||
resultSortColumn: undefined,
|
||
resultSortColumnIndex: undefined,
|
||
resultSortDirection: undefined,
|
||
resultSortMode: undefined,
|
||
resultLocalSortOriginalRows: undefined,
|
||
resultLocalSortOriginalLargeValueCells: undefined,
|
||
resultLocalSortOriginalMongoDocuments: undefined,
|
||
resultLocalSortOriginalMongoCopyDocuments: undefined,
|
||
orderByInput: undefined,
|
||
resultPageSql: undefined,
|
||
resultPageLimit: undefined,
|
||
resultPageOffset: undefined,
|
||
resultCountSql: undefined,
|
||
resultTotalRowCount: undefined,
|
||
resultTotalRowCountLoading: undefined,
|
||
resultSessionId: undefined,
|
||
resultClientSessionId: undefined,
|
||
resultAccessedAt: undefined,
|
||
resultCacheKey: undefined,
|
||
resultCacheState: undefined,
|
||
pinned: false,
|
||
result: undefined,
|
||
results: undefined,
|
||
activeResultIndex: undefined,
|
||
explainPlan: undefined,
|
||
explainError: undefined,
|
||
explainSql: undefined,
|
||
lastExplainedSql: undefined,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
queryExecutionStartedAt: undefined,
|
||
editorViewport: undefined,
|
||
editorSelection: undefined,
|
||
executionId: undefined,
|
||
executingResultRunId: undefined,
|
||
isExplaining: false,
|
||
explainExecutionId: undefined,
|
||
mode: original.mode,
|
||
mqTenant: original.mqTenant,
|
||
mqInitialTab: original.mqInitialTab,
|
||
nacosNamespace: original.nacosNamespace,
|
||
nacosNamespaceName: original.nacosNamespaceName,
|
||
structureTableName: original.structureTableName,
|
||
structureDraft: original.structureDraft ? cloneTabDraft(original.structureDraft) : undefined,
|
||
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
|
||
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
|
||
sourceView: original.sourceView,
|
||
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
|
||
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
|
||
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
|
||
queryWriteTargets: original.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
|
||
resultColumnComments: original.resultColumnComments ? [...original.resultColumnComments] : undefined,
|
||
queryDisplaySourceColumns: original.queryDisplaySourceColumns ? [...original.queryDisplaySourceColumns] : undefined,
|
||
queryEditabilityReason: original.queryEditabilityReason,
|
||
resultEvicted: undefined,
|
||
whereInput: original.whereInput,
|
||
previewSql: original.previewSql,
|
||
};
|
||
initializeResultAutoSave(newTab);
|
||
tabs.value.splice(idx + 1, 0, newTab);
|
||
|
||
const owner = groupForTab(id);
|
||
if (owner) {
|
||
const ownerIndex = owner.tabIds.indexOf(id);
|
||
owner.tabIds.splice(ownerIndex + 1, 0, newId);
|
||
owner.activeTabId = newId;
|
||
focusedGroupId.value = owner.id;
|
||
}
|
||
activeTabId.value = newId;
|
||
}
|
||
|
||
function closeTabsWhere(predicate: (tab: QueryTab) => boolean) {
|
||
const closingIds = new Set(tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id));
|
||
if (closingIds.size === 0) return;
|
||
|
||
tabs.value
|
||
.filter((tab) => closingIds.has(tab.id))
|
||
.forEach((tab) => {
|
||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||
beginClosingBrowserState(tab.id);
|
||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||
clearDataGridSearchStatesForTab(tab.id);
|
||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||
void closeResultSession(tab);
|
||
void closeClientConnectionSession(tab);
|
||
clearResultRunSnapshots(tab);
|
||
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||
releaseTabResultObjectPayloads(tab);
|
||
clearResultRuns(tab);
|
||
clearResultPayload(tab);
|
||
});
|
||
|
||
const activeClosingIndex = tabs.value.findIndex((tab) => tab.id === activeTabId.value && closingIds.has(tab.id));
|
||
tabs.value = tabs.value.filter((tab) => !closingIds.has(tab.id));
|
||
// Resolve the fallback against the POST-filter array: the survivor at the
|
||
// closing tab's index, not the closing tab itself.
|
||
const fallbackTabId = activeClosingIndex >= 0 ? tabs.value[Math.min(activeClosingIndex, tabs.value.length - 1)]?.id : undefined;
|
||
// Drop the closed ids from their owning groups, repair each group's
|
||
// active tab, and remove groups emptied by this close.
|
||
removeTabsFromGroups(closingIds);
|
||
if (fallbackTabId && tabs.value.some((tab) => tab.id === fallbackTabId)) {
|
||
activateTab(fallbackTabId);
|
||
} else {
|
||
syncActiveTabFromFocusedGroup();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Removes a set of tab ids from whichever groups own them, repairs each
|
||
* group's active tab (MRU first, then adjacent), and prunes groups emptied
|
||
* by the removal. Used by bulk close paths so group state stays consistent
|
||
* without a normalization pass.
|
||
*/
|
||
function removeTabsFromGroups(closingIds: Set<string>) {
|
||
if (closingIds.size === 0) return;
|
||
for (const group of groups.value) {
|
||
if (!group.tabIds.some((tabId) => closingIds.has(tabId))) continue;
|
||
const ownerIndexInGroup = group.activeTabId ? group.tabIds.indexOf(group.activeTabId) : -1;
|
||
const wasOwnerActive = group.activeTabId != null && closingIds.has(group.activeTabId);
|
||
group.tabIds = group.tabIds.filter((tabId) => !closingIds.has(tabId));
|
||
if (wasOwnerActive) {
|
||
const groupHistory = activeTabHistory.value.filter((tabId) => !closingIds.has(tabId) && group.tabIds.includes(tabId));
|
||
group.activeTabId = groupHistory[groupHistory.length - 1] ?? group.tabIds[Math.min(Math.max(ownerIndexInGroup, 0), group.tabIds.length - 1)] ?? null;
|
||
}
|
||
}
|
||
pruneEmptyGroups();
|
||
}
|
||
|
||
function closeScopedTabsWhere(predicate: (tab: QueryTab) => boolean, options: { force?: boolean } = {}) {
|
||
const ids = tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id);
|
||
if (options.force) {
|
||
closeTabsWhere(predicate);
|
||
return;
|
||
}
|
||
beginBatchClose(ids, activeTabAfterClosing(ids));
|
||
}
|
||
|
||
function closeConnectionTabs(connectionId: string, options?: { force?: boolean }) {
|
||
closeScopedTabsWhere((tab) => tab.connectionId === connectionId, options);
|
||
}
|
||
|
||
function closeDatabaseTabs(connectionId: string, database: string) {
|
||
closeScopedTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
|
||
}
|
||
|
||
function tabMatchesDroppedTableObject(tab: QueryTab, target: DroppedTableObjectTarget): boolean {
|
||
if (tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
|
||
const targetSchemas = droppedTableObjectSchemaCandidates(target);
|
||
|
||
if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "hbase") {
|
||
return tab.sql === target.name;
|
||
}
|
||
|
||
if (tab.mode === "data") {
|
||
const tableMeta = tableMetaForDataTab(tab);
|
||
if (!tableMeta || tableMeta.tableName !== target.name) return false;
|
||
return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema));
|
||
}
|
||
|
||
if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "structure") {
|
||
if ((tab.structureTableName || "") !== target.name) return false;
|
||
return targetSchemas.has(normalizeOptionalSchema(tab.schema));
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function tabMatchesTableDataRefreshTarget(tab: QueryTab, target: TableDataRefreshTarget): boolean {
|
||
if (tab.mode !== "data" || tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
|
||
const tableMeta = tableMetaForDataTab(tab);
|
||
if (!tableMeta || tableMeta.tableName !== target.name) return false;
|
||
if ((tableMeta.catalog || "") !== (target.catalog || "")) return false;
|
||
const targetSchemas = droppedTableObjectSchemaCandidates(target);
|
||
return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema));
|
||
}
|
||
|
||
function closeDroppedTableObjectTabs(target: DroppedTableObjectTarget) {
|
||
// A dropped table-like object makes existing data/structure tabs stale; close
|
||
// them immediately instead of letting the next refresh fail against a missing object.
|
||
closeTabsWhere((tab) => tabMatchesDroppedTableObject(tab, target));
|
||
}
|
||
|
||
async function refreshDataTabInternal(id: string, options?: { supersedeBusy?: boolean; propagateBuildError?: boolean }): Promise<boolean> {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab || tab.mode !== "data" || (tab.isExecuting && !options?.supersedeBusy)) return false;
|
||
|
||
const connStore = useConnectionStore();
|
||
const conn = connStore.getConfig(tab.connectionId);
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId);
|
||
const refreshPreparationId = uuid();
|
||
|
||
// Reserve the tab synchronously before SQL construction yields so repeated
|
||
// refresh requests cannot build and execute duplicate queries.
|
||
setExecutingWithId(tab.id, refreshPreparationId);
|
||
try {
|
||
let tableMeta = tableMetaForDataTab(tab);
|
||
if (!tableMeta?.tableName) return false;
|
||
|
||
// 生命周期代次校验:disconnect / 关库 / 死池重连后的首次刷新必须先从
|
||
// 新连接源头重建结构,否则旧显式列列表会生成错误 SELECT(issue #6623 /
|
||
// PR #6640 review blocker 2)。reload 路径不读 tableMetaUpdatedAt 风干
|
||
// 判定、只读 tableMeta 本身,因此在这里显式强制重建。
|
||
const connectionGeneration = connStore.metadataGenerationFor(tab.connectionId, tab.database);
|
||
if (isDataTabMetadataLifecycleStale(tab, connectionGeneration)) {
|
||
const metadataGenerationAtStart = connectionGeneration;
|
||
const reloadedMetadata = await loadTableMetadata({
|
||
connectionId: tab.connectionId,
|
||
database: tableMeta.database ?? tab.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
tableType: tableMeta.tableType,
|
||
catalog: tableMeta.catalog,
|
||
databaseType: effectiveDbType ?? conn?.db_type ?? "",
|
||
driverProfile: conn?.driver_profile,
|
||
force: true,
|
||
});
|
||
// 重建期间又跨越了一次连接生命周期边界 → 放弃本次刷新,避免旧结果
|
||
// 二次写回 tab(PR #6640 review blocker 1 的 tab-local 半边)
|
||
if (connStore.metadataGenerationFor(tab.connectionId, tab.database) !== metadataGenerationAtStart) return false;
|
||
const current = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!current || current.executionId !== refreshPreparationId) return false;
|
||
setTableMeta(tab.id, tableMetadataToDataTabMeta(reloadedMetadata.metadata, { schema: tableMeta.schema }));
|
||
tableMeta = tableMetaForDataTab(tab) ?? tableMeta;
|
||
}
|
||
|
||
clearInvalidDataTabSortState(tab, tableMeta.columns);
|
||
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
|
||
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
|
||
const orderBy = tab.orderByInput?.trim() || sortOrder;
|
||
const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
|
||
const offset = tab.resultPageOffset ?? 0;
|
||
const useDriverRowOffset = conn?.db_type === "jdbc" && effectiveDbType === "iris";
|
||
|
||
const sql = await buildTableSelectSql({
|
||
databaseType: effectiveDbType,
|
||
driverProfile: conn?.driver_profile,
|
||
identifierQuote,
|
||
database: tableMeta.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
tableType: tableMeta.tableType,
|
||
catalog: tableMeta.catalog,
|
||
columns: tableMeta.columns.map((column) => column.name),
|
||
primaryKeys,
|
||
...tableDataLargeValuePreviewOptions(effectiveDbType, tableMeta.columns, primaryKeys, limit),
|
||
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
|
||
includeRowId: shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableMeta.tableType),
|
||
whereInput: tab.whereInput,
|
||
injectDefaultTimeSeriesWhere: true,
|
||
orderBy,
|
||
limit,
|
||
offset,
|
||
...(useDriverRowOffset ? { useDriverRowOffset: true } : {}),
|
||
});
|
||
if (!sql.trim()) throw new Error("Failed to build table refresh SQL");
|
||
const current = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!current || current.executionId !== refreshPreparationId) return false;
|
||
updateSql(tab.id, sql);
|
||
await executeTabSql(tab.id, sql, {
|
||
pagination: { limit, offset },
|
||
preserveResultDuringExecution: true,
|
||
publicationOrigin: "refresh",
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
const current = tabs.value.find((candidate) => candidate.id === id);
|
||
if (current?.executionId === refreshPreparationId) setErrorResult(id, error);
|
||
if (options?.propagateBuildError) throw error;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function refreshDataTab(id: string): Promise<boolean> {
|
||
return refreshDataTabInternal(id);
|
||
}
|
||
|
||
async function refreshDataTabsForTable(target: TableDataRefreshTarget): Promise<number> {
|
||
const matchingTabs = tabs.value.filter((tab) => tabMatchesTableDataRefreshTarget(tab, target));
|
||
if (matchingTabs.length === 0) return 0;
|
||
|
||
let refreshed = 0;
|
||
for (const tab of matchingTabs) {
|
||
if (await refreshDataTabInternal(tab.id, { supersedeBusy: true, propagateBuildError: true })) refreshed += 1;
|
||
}
|
||
|
||
return refreshed;
|
||
}
|
||
|
||
function releaseTabsWhere(predicate: (tab: QueryTab) => boolean) {
|
||
closeTabsWhere((tab) => predicate(tab) && tab.mode !== "query");
|
||
tabs.value
|
||
.filter((tab) => predicate(tab))
|
||
.forEach((tab) => {
|
||
rollbackTabTransaction(tab, { resetAutoCommit: true });
|
||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||
beginClosingBrowserState(tab.id);
|
||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||
clearDataGridSearchStatesForTab(tab.id);
|
||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||
void closeResultSession(tab);
|
||
void closeClientConnectionSession(tab);
|
||
clearResultRunSnapshots(tab);
|
||
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||
releaseTabResultObjectPayloads(tab);
|
||
clearResultRuns(tab);
|
||
clearResultPayload(tab);
|
||
});
|
||
}
|
||
|
||
function releaseConnectionTabs(connectionId: string) {
|
||
releaseTabsWhere((tab) => tab.connectionId === connectionId);
|
||
}
|
||
|
||
function releaseDatabaseTabs(connectionId: string, database: string) {
|
||
releaseTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
|
||
}
|
||
|
||
/**
|
||
* 连接生命周期边界(断开连接 / 关闭数据库连接 / 后端连接池失效重连)。
|
||
* 数据标签页的 tableMeta 为展示/编辑保留(不清除 UI、主键与编辑安全门控),
|
||
* 但清掉 freshness 戳:此后 openData 的 tab-local 暖缓存判定、激活路径的
|
||
* stale 判定、以及网格 reload 的 metadata age 判定全部按"冷缓存"处理,
|
||
* 即使位于 30s TTL 窗口内也会重新拉取结构(issue #6623)。
|
||
*/
|
||
function staleConnectionDataTabMetadata(connectionId: string, database?: string) {
|
||
for (const tab of tabs.value) {
|
||
if (tab.mode !== "data" || tab.connectionId !== connectionId) continue;
|
||
if (database != null && tab.database !== database) continue;
|
||
tab.tableMetaUpdatedAt = undefined;
|
||
}
|
||
}
|
||
|
||
function isDatabaseOpen(connectionId: string, database: string) {
|
||
return openDatabaseKeys.value.has(`${connectionId}\x00${database}`);
|
||
}
|
||
|
||
function rollbackTabsWhere(predicate: (tab: QueryTab) => boolean, options?: { resetAutoCommit?: boolean }) {
|
||
tabs.value.filter((tab) => predicate(tab)).forEach((tab) => rollbackTabTransaction(tab, options));
|
||
}
|
||
|
||
function rollbackConnectionTransactions(connectionId: string) {
|
||
rollbackTabsWhere((tab) => tab.connectionId === connectionId, { resetAutoCommit: true });
|
||
}
|
||
|
||
function rollbackDatabaseTransactions(connectionId: string, database: string) {
|
||
rollbackTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database, { resetAutoCommit: true });
|
||
}
|
||
|
||
function updateSql(id: string, sql: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (tab) {
|
||
tab.sql = sql;
|
||
queueSavedSqlEditorPositionPersist(tab);
|
||
}
|
||
}
|
||
|
||
function updateDataGridLocalColumnFilters(id: string, filters: Record<string, string[]>) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab?.result) return;
|
||
if (Object.keys(filters).length === 0) {
|
||
delete tab.result.local_column_filters;
|
||
} else {
|
||
tab.result.local_column_filters = Object.fromEntries(Object.entries(filters).map(([columnIndex, values]) => [columnIndex, [...values]]));
|
||
}
|
||
}
|
||
|
||
function updateDataGridHiddenColumnKeys(id: string, keys: string[]) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab?.result) return;
|
||
if (keys.length === 0) {
|
||
delete tab.result.local_hidden_column_keys;
|
||
} else {
|
||
tab.result.local_hidden_column_keys = [...keys];
|
||
}
|
||
}
|
||
|
||
function setAutoCommit(id: string, autoCommit: boolean) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (tab) {
|
||
const wasManual = tab.autoCommit === false;
|
||
tab.autoCommit = autoCommit;
|
||
if (autoCommit && wasManual) {
|
||
if (tab.txnSessionId) {
|
||
void rollbackTransaction(id);
|
||
} else {
|
||
clearManualTransactionSession(tab);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function markManualTransactionDirty(id: string) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab?.txnSessionId) return;
|
||
const dbType = effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId));
|
||
if (dbType === "oracle") tab.oracleTxnPossiblyDirty = true;
|
||
}
|
||
|
||
/** Reset only the Oracle sticky-dirty bit. Used when a session continues but
|
||
* the old dirty state must be discarded (e.g. idle-expiry recovery where the
|
||
* replacement session starts fresh). Full session cleanup goes through
|
||
* `clearManualTransactionSession`. */
|
||
function clearOracleTxnPossiblyDirty(tab: { oracleTxnPossiblyDirty?: boolean }) {
|
||
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
|
||
}
|
||
|
||
/** Centralized manual-session cleanup. Clears every field tied to a manual
|
||
* transaction session exactly when that session is conclusively ended or
|
||
* discarded. Callers must not assign these fields individually. */
|
||
function clearManualTransactionSession(tab: QueryTab) {
|
||
tab.txnSessionId = undefined;
|
||
tab.txnAutoRolledBack = false;
|
||
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
|
||
}
|
||
|
||
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean; resetAutoCommitDbType?: string }) {
|
||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||
if (options?.resetAutoCommit) {
|
||
// Callers switching a tab to another connection pass the target db type
|
||
// explicitly: the tab still carries the previous connectionId at reset
|
||
// time, and unbound file/saved-SQL tabs have none at all (which would
|
||
// force auto-commit even when the default mode is manual, #8863).
|
||
const dbType = options.resetAutoCommitDbType ?? useConnectionStore().getConfig(tab.connectionId)?.db_type;
|
||
tab.autoCommit = defaultAutoCommitForDbTypeWithSetting(dbType);
|
||
}
|
||
clearOracleTxnPossiblyDirty(tab);
|
||
tab.txnAutoRolledBack = false;
|
||
}
|
||
|
||
async function commitTransaction(id: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.txnSessionId) return;
|
||
try {
|
||
await api.commitManualTransaction(tab.txnSessionId);
|
||
} finally {
|
||
clearManualTransactionSession(tab);
|
||
}
|
||
}
|
||
|
||
async function rollbackTransaction(id: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.txnSessionId) return;
|
||
try {
|
||
await api.rollbackManualTransaction(tab.txnSessionId);
|
||
} finally {
|
||
clearManualTransactionSession(tab);
|
||
}
|
||
}
|
||
|
||
function updateEditorViewport(id: string, viewport: { scrollTop: number; scrollLeft: number }) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
if (tab.editorViewport?.scrollTop === viewport.scrollTop && tab.editorViewport?.scrollLeft === viewport.scrollLeft) return;
|
||
tab.editorViewport = viewport;
|
||
queueSavedSqlEditorPositionPersist(tab);
|
||
}
|
||
|
||
function updateTabUiState(id: string, patch: Partial<NonNullable<QueryTab["uiState"]>>) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab) return;
|
||
tab.uiState = { ...tab.uiState, ...patch };
|
||
}
|
||
|
||
function updateTabPageUiState(id: string, mode: string, patch: Record<string, unknown>, owner?: QueryTab) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
|
||
const nextPageState = sanitizeTabPageUiState({ ...(tab.uiState?.page?.[mode] ?? {}), ...patch });
|
||
if (!nextPageState) return;
|
||
const page = sanitizeTabPageUiState({ ...(tab.uiState?.page ?? {}), [mode]: nextPageState });
|
||
if (!page) return;
|
||
tab.uiState = { ...tab.uiState, page: page as NonNullable<QueryTab["uiState"]>["page"] };
|
||
}
|
||
|
||
function updateTabPageResult(id: string, mode: string, result: QueryResult | undefined, owner?: QueryTab) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
|
||
if (!result) {
|
||
clearResultPayload(tab);
|
||
return;
|
||
}
|
||
tab.results = undefined;
|
||
tab.activeResultIndex = undefined;
|
||
assignDisplayedResult(tab, result);
|
||
touchResult(tab);
|
||
scheduleResultCacheTrim();
|
||
}
|
||
|
||
function updateEditorSelection(id: string, selection: { anchor: number; head: number }) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.editorSelection = selection;
|
||
queueSavedSqlEditorPositionPersist(tab);
|
||
}
|
||
|
||
function updateObjectBrowserViewport(id: string, viewport: ObjectBrowserViewport) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "objects") return;
|
||
const previous = tab.objectBrowser?.viewport;
|
||
if (previous?.scrollTop === viewport.scrollTop && previous.viewMode === viewport.viewMode) return;
|
||
tab.objectBrowser = { ...tab.objectBrowser, viewport };
|
||
}
|
||
|
||
function updateObjectBrowserSearch(id: string, query: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "objects") return;
|
||
if (tab.objectBrowser?.searchQuery === query) return;
|
||
tab.objectBrowser = { ...tab.objectBrowser, searchQuery: query };
|
||
}
|
||
|
||
function updateObjectBrowserFilter(id: string, filter: ObjectBrowserFilter) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "objects" || tab.objectBrowser?.filter === filter) return;
|
||
tab.objectBrowser = { ...tab.objectBrowser, filter };
|
||
}
|
||
|
||
function updateNacosConfigEditorViewport(connectionId: string, namespace: string, viewport: NacosConfigEditorViewport) {
|
||
if (!Number.isFinite(viewport.scrollTop) || !Number.isFinite(viewport.scrollLeft)) return;
|
||
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
|
||
if (!tab) return;
|
||
const next = {
|
||
...viewport,
|
||
scrollTop: Math.max(0, Math.round(viewport.scrollTop)),
|
||
scrollLeft: Math.max(0, Math.round(viewport.scrollLeft)),
|
||
};
|
||
const previous = tab.nacosConfigEditorViewport;
|
||
if (previous?.namespace === next.namespace && previous.dataId === next.dataId && previous.group === next.group && previous.scrollTop === next.scrollTop && previous.scrollLeft === next.scrollLeft) {
|
||
return;
|
||
}
|
||
tab.nacosConfigEditorViewport = next;
|
||
}
|
||
|
||
function renameTab(id: string, title: string) {
|
||
const trimmed = title.trim();
|
||
if (!trimmed) return false;
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "query") return false;
|
||
const normalizedTitle = tab.savedSqlId ? ensureSqlExtension(trimmed) : trimmed;
|
||
const previousTitle = tab.title;
|
||
tab.title = normalizedTitle;
|
||
tab.customTitle = true;
|
||
if (tab.savedSqlId) {
|
||
const savedSqlStore = useSavedSqlStore();
|
||
const existing = savedSqlStore.getFile(tab.savedSqlId);
|
||
if (existing && existing.name !== normalizedTitle) {
|
||
void savedSqlStore.renameFile(tab.savedSqlId, normalizedTitle).catch((error) => {
|
||
console.warn("[DBX][saved-sql:rename:error]", error);
|
||
tab.title = previousTitle;
|
||
});
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function linkSavedSql(id: string, savedSqlId: string, title?: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.savedSqlId = savedSqlId;
|
||
tab.externalSqlPath = undefined;
|
||
tab.externalSqlFileVersion = undefined;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
if (title) {
|
||
tab.title = title;
|
||
tab.customTitle = true;
|
||
}
|
||
}
|
||
|
||
function linkExternalSqlPath(id: string, path: string, title?: string, version?: QueryTab["externalSqlFileVersion"]) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.externalSqlPath = path;
|
||
tab.externalSqlFileVersion = version;
|
||
tab.externalSqlIgnoredFileVersion = undefined;
|
||
tab.externalSqlFileMissing = undefined;
|
||
tab.savedSqlId = undefined;
|
||
if (title) {
|
||
tab.title = title;
|
||
tab.customTitle = true;
|
||
}
|
||
markTabClean(tab);
|
||
refreshExternalSqlFileTitles();
|
||
}
|
||
|
||
function currentSavedSqlExecutionTarget(): SavedSqlExecutionTarget | undefined {
|
||
const activeTab = tabs.value.find((tab) => tab.id === activeTabId.value);
|
||
const target = savedSqlExecutionTargetFromTab(activeTab);
|
||
if (!target || !useConnectionStore().getConfig(target.connectionId)) return undefined;
|
||
return target;
|
||
}
|
||
|
||
function applySavedSqlExecutionTarget(tab: QueryTab, target: SavedSqlExecutionTarget) {
|
||
const options = { persistSavedSqlTarget: false };
|
||
updateConnection(tab.id, target.connectionId, target.database, options);
|
||
if (tab.catalog !== target.catalog || tab.database !== target.database) {
|
||
if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database, options);
|
||
else updateDatabase(tab.id, target.database, options);
|
||
}
|
||
updateSchema(tab.id, target.schema, options);
|
||
}
|
||
|
||
function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) {
|
||
const targetMode = options.targetMode ?? useSettingsStore().editorSettings.savedSqlOpenTargetMode;
|
||
const currentTarget = targetMode === "current" ? currentSavedSqlExecutionTarget() : undefined;
|
||
const target = resolveSavedSqlExecutionTarget(file, targetMode, currentTarget);
|
||
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
|
||
if (existing) {
|
||
persistSavedSqlEditorPosition(existing);
|
||
if (!existing.sql && file.sql && existing.originalSql === undefined) {
|
||
existing.sql = file.sql;
|
||
existing.originalSql = file.sql;
|
||
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
|
||
existing.editorSelection = restored.selection;
|
||
existing.editorViewport = restored.viewport;
|
||
}
|
||
applySavedSqlExecutionTarget(existing, target);
|
||
switchTab(existing.id);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = uuid();
|
||
const restoredPosition = restoreSavedSqlEditorPosition(file.id, file.sql);
|
||
const dbType = useConnectionStore().getConfig(target.connectionId)?.db_type;
|
||
const tab: QueryTab = {
|
||
id,
|
||
title: file.name,
|
||
customTitle: true,
|
||
connectionId: target.connectionId,
|
||
database: target.database,
|
||
schema: target.schema,
|
||
catalog: target.catalog,
|
||
sql: file.sql,
|
||
savedSqlId: file.id,
|
||
originalSql: file.sql,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "query",
|
||
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
|
||
editorSelection: restoredPosition.selection,
|
||
editorViewport: restoredPosition.viewport,
|
||
};
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
async function hydrateSavedSqlTabs() {
|
||
await initSavedSqlEditorPositions();
|
||
const savedSqlStore = useSavedSqlStore();
|
||
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "" && tab.originalSql === undefined);
|
||
for (const tab of linkedTabs) {
|
||
const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!);
|
||
if (!file) continue;
|
||
tab.title = tab.customTitle ? tab.title : file.name;
|
||
tab.sql = file.sql;
|
||
tab.originalSql = file.sql;
|
||
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
|
||
tab.editorSelection = restored.selection;
|
||
tab.editorViewport = restored.viewport;
|
||
}
|
||
}
|
||
|
||
function togglePinnedTab(id: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.pinned = !tab.pinned;
|
||
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
|
||
|
||
const owner = groupForTab(id);
|
||
if (owner) {
|
||
const pinned = owner.tabIds.filter((tabId) => tabs.value.find((item) => item.id === tabId)?.pinned);
|
||
const regular = owner.tabIds.filter((tabId) => !tabs.value.find((item) => item.id === tabId)?.pinned);
|
||
owner.tabIds = [...pinned, ...regular];
|
||
}
|
||
}
|
||
|
||
function reorderTab(id: string, targetId: string, position: "before" | "after") {
|
||
const fromIdx = tabs.value.findIndex((t) => t.id === id);
|
||
const toIdx = tabs.value.findIndex((t) => t.id === targetId);
|
||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return false;
|
||
|
||
const reordered = [...tabs.value];
|
||
const [tab] = reordered.splice(fromIdx, 1);
|
||
const newToIdx = reordered.findIndex((t) => t.id === targetId);
|
||
reordered.splice(newToIdx + (position === "after" ? 1 : 0), 0, tab);
|
||
const nextTabs = orderPinnedFirst(reordered, (item) => !!item.pinned);
|
||
if (nextTabs.every((item, index) => item.id === tabs.value[index]?.id)) return false;
|
||
tabs.value = nextTabs;
|
||
|
||
const owner = groupForTab(id);
|
||
if (owner) {
|
||
const order = new Map(tabs.value.map((tab, index) => [tab.id, index]));
|
||
owner.tabIds = [...owner.tabIds].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
|
||
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
|
||
const savedSqlStore = useSavedSqlStore();
|
||
void savedSqlStore
|
||
.updateFileExecutionTarget(tab.savedSqlId, {
|
||
connectionId: tab.connectionId,
|
||
database: tab.database,
|
||
catalog: tab.catalog,
|
||
schema: tab.schema,
|
||
})
|
||
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
|
||
}
|
||
|
||
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.database === database) return;
|
||
rollbackTabTransaction(tab);
|
||
void closeResultSession(tab);
|
||
void closeClientConnectionSession(tab);
|
||
tab.database = database;
|
||
tab.schema = undefined;
|
||
tab.objectBrowser = undefined;
|
||
clearResultPayload(tab);
|
||
tab.lastExecutedSql = undefined;
|
||
tab.resultBaseSql = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
clearExplain(tab);
|
||
tab.tableMeta = undefined;
|
||
persistSavedSqlExecutionTarget(tab, options);
|
||
}
|
||
|
||
function updateCatalog(id: string, catalog: string | undefined, database: string, options: UpdateExecutionTargetOptions = {}) {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab || (tab.catalog === catalog && tab.database === database)) return;
|
||
rollbackTabTransaction(tab);
|
||
void closeResultSession(tab);
|
||
void closeClientConnectionSession(tab);
|
||
tab.catalog = catalog;
|
||
tab.database = database;
|
||
tab.schema = undefined;
|
||
tab.objectBrowser = undefined;
|
||
clearResultPayload(tab);
|
||
tab.lastExecutedSql = undefined;
|
||
tab.resultBaseSql = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
clearExplain(tab);
|
||
tab.tableMeta = undefined;
|
||
persistSavedSqlExecutionTarget(tab, options);
|
||
}
|
||
|
||
function updateSchema(id: string, schema: string | undefined, options: UpdateExecutionTargetOptions = {}) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.schema === schema) return;
|
||
rollbackTabTransaction(tab);
|
||
const clearsQuerySchema = tab.mode === "query" && tab.schema && !schema && supportsClearableQuerySchema(useConnectionStore().getConfig(tab.connectionId)?.db_type);
|
||
if (clearsQuerySchema) {
|
||
queueTabSessionReset(tab);
|
||
clearResultPayload(tab);
|
||
tab.lastExecutedSql = undefined;
|
||
tab.resultBaseSql = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
clearExplain(tab);
|
||
}
|
||
tab.schema = schema;
|
||
if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema, filter: undefined, viewport: undefined };
|
||
persistSavedSqlExecutionTarget(tab, options);
|
||
}
|
||
|
||
function updateConnection(id: string, connectionId: string, database = "", options: UpdateExecutionTargetOptions = {}) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.connectionId === connectionId) return;
|
||
rollbackTabTransaction(tab, { resetAutoCommit: true, resetAutoCommitDbType: useConnectionStore().getConfig(connectionId)?.db_type });
|
||
void closeResultSession(tab);
|
||
void closeClientConnectionSession(tab);
|
||
tab.connectionId = connectionId;
|
||
tab.catalog = undefined;
|
||
tab.database = database;
|
||
tab.catalog = undefined;
|
||
tab.objectBrowser = undefined;
|
||
tab.schema = undefined;
|
||
clearResultPayload(tab);
|
||
tab.lastExecutedSql = undefined;
|
||
tab.resultBaseSql = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
clearExplain(tab);
|
||
tab.tableMeta = undefined;
|
||
persistSavedSqlExecutionTarget(tab, options);
|
||
}
|
||
|
||
function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable<QueryTab["tableMeta"]>["columns"]): boolean {
|
||
if (tab.mode !== "data") return false;
|
||
const hasColumn = (name: string) => columns.some((column) => column.name === name);
|
||
const structuredSortMissing = !!tab.resultSortColumn && !hasColumn(tab.resultSortColumn);
|
||
const simpleOrderMissing = simpleDataGridOrderByReferencesMissingColumn(
|
||
tab.orderByInput,
|
||
columns.map((column) => column.name),
|
||
);
|
||
if (!structuredSortMissing && !simpleOrderMissing) return false;
|
||
if (structuredSortMissing) {
|
||
tab.resultSortColumn = undefined;
|
||
tab.resultSortColumnIndex = undefined;
|
||
tab.resultSortDirection = undefined;
|
||
tab.resultSortMode = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
}
|
||
if (simpleOrderMissing) tab.orderByInput = undefined;
|
||
return true;
|
||
}
|
||
|
||
function clearInvalidDataTabSort(id: string): boolean {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.tableMeta?.columns.length) return false;
|
||
return clearInvalidDataTabSortState(tab, tab.tableMeta.columns);
|
||
}
|
||
|
||
function setTableMeta(id: string, meta: NonNullable<QueryTab["tableMeta"]>) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (tab) {
|
||
tab.tableMeta = meta;
|
||
// 记录写入时的连接元数据代次:disconnect/关库/死池重连会使该代次递增,
|
||
// 代次失配视同冷缓存,即使位于 30s TTL 窗口内也会重建结构(issue #6623 /
|
||
// PR #6640)。同一代次内的多次写入与连接Store保持一致,无需额外入参。
|
||
tab.tableMetaGeneration = useConnectionStore().metadataGenerationFor(tab.connectionId, tab.database) ?? 0;
|
||
tab.tableMetaUpdatedAt = Date.now();
|
||
if (meta.columns.length > 0) clearInvalidDataTabSortState(tab, meta.columns);
|
||
// 只有真实元数据(columns 非空)落地才结束行标识等待;多处调用方会先写
|
||
// columns/primaryKeys 为空的占位身份(如 useNavigationTargets),不得
|
||
// 借此提前解除编辑门控。失败/中止路径不清除——标签页保持只读是安全
|
||
// 兜底,刷新或重开表会重新加载元数据恢复
|
||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||
}
|
||
}
|
||
|
||
function setObjectSource(id: string, objectSource: NonNullable<QueryTab["objectSource"]>) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (tab) tab.objectSource = objectSource;
|
||
}
|
||
|
||
function setExecuting(id: string, isExecuting: boolean) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.isExecuting = isExecuting;
|
||
tab.executingResultRunId = isExecuting ? null : undefined;
|
||
tab.queryExecutionStartedAt = isExecuting ? Date.now() : undefined;
|
||
if (!isExecuting) {
|
||
tab.isCancelling = false;
|
||
tab.executionId = undefined;
|
||
}
|
||
}
|
||
|
||
function setExecutingWithId(id: string, executionId: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.isExecuting = true;
|
||
tab.executingResultRunId = null;
|
||
tab.executionId = executionId;
|
||
tab.isCancelling = false;
|
||
tab.queryExecutionStartedAt = Date.now();
|
||
}
|
||
|
||
function clearExplain(tab: QueryTab) {
|
||
tab.explainPlan = undefined;
|
||
tab.explainTableResult = undefined;
|
||
tab.explainError = undefined;
|
||
tab.explainTableError = undefined;
|
||
tab.explainSql = undefined;
|
||
tab.explainTableSql = undefined;
|
||
tab.lastExplainedSql = undefined;
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
tab.explainClientSessionId = undefined;
|
||
}
|
||
|
||
function toErrorResult(e: any): NonNullable<QueryTab["result"]> {
|
||
// Single funnel for every query execution failure, so backend messages DBX
|
||
// knows about are shown in the active locale rather than as raw English.
|
||
const error = normalizeBackendError(e) ?? undefined;
|
||
const message = translateBackendError(i18n.global.t, e, e instanceof Error ? e.message : undefined);
|
||
return markQueryResultRowsRaw({
|
||
columns: ["Error"],
|
||
execution_error: true,
|
||
error,
|
||
rows: [[message]],
|
||
affected_rows: 0,
|
||
execution_time_ms: 0,
|
||
});
|
||
}
|
||
|
||
function setErrorResult(id: string, e: any) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return;
|
||
tab.result = toErrorResult(e);
|
||
tab.results = undefined;
|
||
tab.activeResultIndex = undefined;
|
||
// An error result is a replacement, not the previous dataset.
|
||
publishResultGeneration(tab, "execute");
|
||
tab.resultSessionId = undefined;
|
||
tab.resultClientSessionId = undefined;
|
||
tab.isExecuting = false;
|
||
tab.isCancelling = false;
|
||
tab.queryExecutionStartedAt = undefined;
|
||
tab.executionId = undefined;
|
||
tab.executingResultRunId = undefined;
|
||
touchResult(tab);
|
||
}
|
||
|
||
function clearAcknowledgedCancelIfStillRunning(id: string, executionId: string) {
|
||
setTimeout(() => {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (!current || current.executionId !== executionId || !current.isCancelling) {
|
||
pendingResultRunRestores.delete(executionId);
|
||
return;
|
||
}
|
||
finishBatchSqlExecution(current, executionId, true);
|
||
current.isExecuting = false;
|
||
current.isCancelling = false;
|
||
current.executionId = undefined;
|
||
current.executingResultRunId = undefined;
|
||
current.queryExecutionStartedAt = undefined;
|
||
if (!restorePendingResultRun(current, executionId)) {
|
||
current.result = toErrorResult(new Error("Query canceled"));
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
current.resultSessionId = undefined;
|
||
current.resultClientSessionId = undefined;
|
||
publishResultGeneration(current, "execute");
|
||
touchResult(current);
|
||
}
|
||
clearLiveBatchSqlExecution(current, executionId);
|
||
}, CANCEL_ACK_SETTLE_TIMEOUT_MS);
|
||
}
|
||
|
||
async function executeCurrentTab() {
|
||
assertUpdateAllowsInteraction();
|
||
const tab = tabs.value.find((t) => t.id === activeTabId.value);
|
||
if (!tab || !tab.sql.trim()) return;
|
||
|
||
await executeCurrentSql(tab.sql);
|
||
}
|
||
|
||
async function executeCurrentSql(sql: string, options?: { tabId?: string; skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean; onExecutionStarted?: () => void }) {
|
||
// Execution targets the requesting tab; only fall back to the global
|
||
// active tab when the caller did not (or could not) capture one.
|
||
const executionTabId = options?.tabId ?? activeTabId.value;
|
||
if (!executionTabId) return;
|
||
const tab = tabs.value.find((item) => item.id === executionTabId);
|
||
if (tab && pendingResultRunPreparations.has(tab)) return false;
|
||
const previousGridKey = tab ? resultGridInstanceKey(tab) : undefined;
|
||
if (tab?.mode === "query") {
|
||
tab.resultSortColumn = undefined;
|
||
tab.resultSortColumnIndex = undefined;
|
||
tab.resultSortDirection = undefined;
|
||
tab.resultSortMode = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
}
|
||
const producedResult = await executeTabSql(executionTabId, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options });
|
||
if (producedResult !== false) {
|
||
const current = tabs.value.find((item) => item.id === executionTabId);
|
||
if (current?.result) {
|
||
current.resultGridRevision = uuid();
|
||
if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current);
|
||
await nextTick();
|
||
if (previousGridKey && options?.openInNewResultTab !== true) {
|
||
clearDataGridPendingSnapshot(previousGridKey);
|
||
clearDataGridViewSnapshot(previousGridKey);
|
||
}
|
||
}
|
||
}
|
||
return producedResult;
|
||
}
|
||
|
||
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryWriteTargets" | "queryEditabilityReason" | "tableMeta" | "resultColumnComments" | "queryDisplaySourceColumns">;
|
||
|
||
type LoadedEditableSource = {
|
||
source: EditableQuerySource;
|
||
analysis: EditableQueryInfo;
|
||
tableMeta: NonNullable<QueryTab["tableMeta"]>;
|
||
};
|
||
|
||
type EditableSourceMetadataTarget = {
|
||
source: EditableQuerySource;
|
||
analysis: EditableQueryInfo;
|
||
request: TableMetadataRequest;
|
||
writeSchema?: string;
|
||
};
|
||
|
||
/**
|
||
* Resolve result columns (by projection ordinal) back to exactly one base
|
||
* column per source, then surface the resolved column comments and a
|
||
* result->source mapping. Reuses the same database-aware binder
|
||
* as the editability analysis, so `name AS username` (uniquely resolvable
|
||
* unqualified alias) maps back to its physical column and quoted mixed-case
|
||
* identifiers keep exact casing. Ambiguous or unresolved columns yield
|
||
* `undefined` (no comment) instead of first-source-wins on a shared name.
|
||
*/
|
||
function resolveResultColumnInfo(dbType: string, analysis: EditableQueryInfo, resultColumns: string[], loadedSources: LoadedEditableSource[]): { comments: Array<string | undefined>; mapping: Array<QueryResultSourceColumnRef | undefined> } {
|
||
const refs = resolveSourceColumnsByOrdinal(
|
||
dbType,
|
||
analysis,
|
||
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
|
||
resultColumns.length,
|
||
);
|
||
const comments: Array<string | undefined> = [];
|
||
const mapping: Array<QueryResultSourceColumnRef | undefined> = [];
|
||
for (const ref of refs) {
|
||
if (!ref) {
|
||
comments.push(undefined);
|
||
mapping.push(undefined);
|
||
continue;
|
||
}
|
||
const loaded = loadedSources.find((entry) => entry.source.key === ref.sourceKey);
|
||
const comment = loaded?.tableMeta.columns.find((column) => column.name === ref.sourceColumn)?.comment?.trim();
|
||
comments.push(comment || undefined);
|
||
mapping.push({
|
||
...ref,
|
||
database: loaded?.tableMeta.database,
|
||
schema: loaded?.tableMeta.schema,
|
||
tableName: loaded?.tableMeta.tableName,
|
||
});
|
||
}
|
||
return { comments, mapping };
|
||
}
|
||
|
||
function mysqlColumnIsGenerated(column: { extra: string | null }): boolean {
|
||
const extra = column.extra?.trim().toLowerCase() ?? "";
|
||
return extra.includes("virtual generated") || extra.includes("stored generated");
|
||
}
|
||
|
||
function groupedByExactlyOneSourcePrimaryKey(loaded: LoadedEditableSource, groupByRefs: Array<QueryResultSourceColumnRef | undefined>): boolean {
|
||
const primaryKeys = loaded.tableMeta.primaryKeys;
|
||
if (!primaryKeys.length || groupByRefs.length !== primaryKeys.length) return false;
|
||
const groupedColumns = groupByRefs.flatMap((ref) => (ref?.sourceKey === loaded.source.key ? [ref.sourceColumn] : []));
|
||
return groupedColumns.length === primaryKeys.length && new Set(groupedColumns).size === primaryKeys.length && primaryKeys.every((primaryKey) => groupedColumns.includes(primaryKey));
|
||
}
|
||
|
||
function canInsertIntoEditableQuerySource(tab: QueryTab, databaseType: DatabaseType | undefined, loaded: LoadedEditableSource, sourceColumns: readonly (string | undefined)[] | undefined): boolean {
|
||
if (!canInsertTableRows(databaseType) || !sourceColumns?.length || !sourceColumns.every(Boolean)) return false;
|
||
const knownTableType = knownEditableQuerySourceTableType(tab, loaded);
|
||
return !knownTableType?.toUpperCase().includes("VIEW");
|
||
}
|
||
|
||
function knownEditableQuerySourceTableType(tab: QueryTab, loaded: LoadedEditableSource): string | undefined {
|
||
return (
|
||
loaded.tableMeta.tableType ??
|
||
useConnectionStore()
|
||
.lookupLocalCompletionTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.tableName, 20, loaded.tableMeta.schema, loaded.tableMeta.catalog)
|
||
.find((table) => table.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase())?.type
|
||
);
|
||
}
|
||
|
||
interface EditableQueryExecutionPreparation {
|
||
sql: string;
|
||
metadataSql: string;
|
||
hiddenPrimaryKeys: HiddenPrimaryKeyProjection[];
|
||
oracleLobPreview: boolean;
|
||
}
|
||
|
||
function oracleCompletionTableType(tab: QueryTab, metadataDbType: string, database: string, schema: string, tableName: string, catalog?: string): string | undefined {
|
||
if (metadataDbType !== "oracle" && metadataDbType !== "oceanbase-oracle") return undefined;
|
||
const resolvedSchema = schema.trim();
|
||
if (!resolvedSchema) return undefined;
|
||
const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
|
||
const targetName = normalizeIdentifier(tableName);
|
||
const targetSchema = normalizeIdentifier(resolvedSchema);
|
||
const targetCatalog = catalog?.trim() ? normalizeIdentifier(catalog) : undefined;
|
||
const matches = useConnectionStore()
|
||
.lookupLocalCompletionTables(tab.connectionId!, database, tableName, 20, resolvedSchema, catalog)
|
||
.filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog));
|
||
if (matches.length !== 1) return undefined;
|
||
const match = matches[0]!;
|
||
return match.tableType?.trim() || match.type?.toUpperCase();
|
||
}
|
||
|
||
function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean {
|
||
if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false;
|
||
// An unknown Oracle object may be a view whose query shape rejects ROWID
|
||
// and whose rows cannot be mapped safely for writes. Keep the result
|
||
// read-only until the object tree or tab metadata confirms its type.
|
||
return databaseType !== "oracle" || !!loaded.tableMeta.tableType?.trim();
|
||
}
|
||
|
||
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
|
||
tab.queryAnalysis = patch.queryAnalysis;
|
||
tab.querySourceColumns = patch.querySourceColumns;
|
||
tab.queryWriteTargets = patch.queryWriteTargets;
|
||
tab.queryEditabilityReason = patch.queryEditabilityReason;
|
||
tab.mongoEditTarget = undefined;
|
||
tab.tableMeta = patch.tableMeta;
|
||
tab.resultColumnComments = patch.resultColumnComments;
|
||
tab.queryDisplaySourceColumns = patch.queryDisplaySourceColumns;
|
||
}
|
||
|
||
function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget {
|
||
// Oracle-family metadata rules (schema-less resolution + uppercase folding)
|
||
// must follow the connection's effective database type. Callers built from
|
||
// the raw connection db_type pass "jdbc" for a JDBC Oracle connection,
|
||
// which would keep the service-name schema fallback and the query's
|
||
// lowercase table spelling, so the write targets a non-existent table.
|
||
const metadataDbType = effectiveDatabaseTypeForConnection(conn) || dbType;
|
||
// Metadata must resolve in the same namespace as the query execution. An
|
||
// empty query-tab database still executes in the connection's default DB,
|
||
// while database-tree dialects and SQL Server 3-part names may override it
|
||
// with a qualified source.
|
||
const qualifiedSourceDatabase = dbType === "sqlserver" ? source.catalog : connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined;
|
||
const metadataDatabase = qualifiedSourceDatabase || executionDatabase || conn?.database || tab.database;
|
||
// SQL Server does not apply the query tab's selected schema to an
|
||
// unqualified object reference. Resolve metadata through the login's
|
||
// default schema (with the driver's dbo fallback) so metadata and writes
|
||
// target the same object as the original SELECT.
|
||
const schema = source.schema || (dbType === "sqlserver" ? "" : tab.schema) || "";
|
||
// Oracle-family connection databases are service names, not schemas. When
|
||
// the query does not qualify a schema, let the driver resolve the current
|
||
// login user's schema instead of looking up metadata under the service name.
|
||
// Unqualified agent-backed PostgreSQL-family queries run in the
|
||
// connection's current search_path. Do not reinterpret the selected
|
||
// database as a schema; the agent reports the visible relation's actual
|
||
// namespace with the columns.
|
||
const resolveAgentSearchPathSchema = (dbType === "vastbase" || dbType === "kingbase") && !source.schema && !tab.schema;
|
||
// PostgreSQL-compatible unqualified names also resolve through the
|
||
// connection's search_path. Keep the metadata request unqualified when no
|
||
// schema was selected instead of assuming public (or the database name).
|
||
const useCurrentPostgresSchema = (dbType === "postgres" || dbType === "kwdb") && !source.schema && !tab.schema;
|
||
const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(metadataDbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined);
|
||
const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
|
||
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!;
|
||
const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.catalog, source.catalogQuoted);
|
||
const metadataSource: EditableQuerySource = {
|
||
...source,
|
||
catalog: metadataCatalog,
|
||
schema: metadataSchema || undefined,
|
||
tableName: metadataTableName,
|
||
};
|
||
// Keep SQL Server writes unqualified unless the SELECT source explicitly
|
||
// named a schema, so SELECT and UPDATE resolve the same object.
|
||
const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined;
|
||
const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || conn?.default_schema || "", metadataTableName, metadataCatalog);
|
||
const knownTableType = localTableType ?? (tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined);
|
||
return {
|
||
source: metadataSource,
|
||
analysis: normalizeUppercaseFoldedQueryAnalysis(metadataDbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName),
|
||
writeSchema,
|
||
request: {
|
||
connectionId: tab.connectionId!,
|
||
database: metadataDatabase,
|
||
schema: metadataSchema,
|
||
tableName: metadataTableName,
|
||
tableType: knownTableType,
|
||
databaseType: dbType,
|
||
driverProfile: conn?.driver_profile || conn?.db_type,
|
||
catalog: metadataCatalog,
|
||
},
|
||
};
|
||
}
|
||
|
||
function loadedEditableSourceFromMetadata(target: EditableSourceMetadataTarget, metadata: Awaited<ReturnType<typeof loadTableMetadata>>["metadata"]): LoadedEditableSource {
|
||
const usesReportedSchema = target.request.databaseType === "vastbase" || target.request.databaseType === "kingbase";
|
||
const writeSchema = usesReportedSchema && !target.writeSchema ? metadata.schema : target.writeSchema;
|
||
return {
|
||
source: target.source,
|
||
analysis: target.analysis,
|
||
tableMeta: {
|
||
catalog: target.request.catalog,
|
||
database: target.request.database,
|
||
schema: writeSchema,
|
||
tableName: target.request.tableName,
|
||
tableType: metadata.tableType,
|
||
columns: metadata.columns,
|
||
primaryKeys: metadata.primaryKeys,
|
||
},
|
||
};
|
||
}
|
||
|
||
function loadedEditableSourceFromColumns(target: EditableSourceMetadataTarget, loadedColumns: Awaited<ReturnType<typeof loadTableColumns>>): LoadedEditableSource {
|
||
return {
|
||
source: target.source,
|
||
analysis: target.analysis,
|
||
tableMeta: {
|
||
catalog: target.request.catalog,
|
||
database: target.request.database,
|
||
schema: target.writeSchema,
|
||
tableName: target.request.tableName,
|
||
tableType: loadedColumns.tableType,
|
||
columns: loadedColumns.columns,
|
||
// MySQL getColumns already marks declared primary-key columns. Keep the
|
||
// columns-only path free of index discovery while allowing grouped
|
||
// results to prove that one physical row is uniquely identifiable.
|
||
primaryKeys: target.request.databaseType === "mysql" && target.request.driverProfile === "mysql" ? loadedColumns.columns.filter((column) => column.is_primary_key).map((column) => column.name) : [],
|
||
},
|
||
};
|
||
}
|
||
|
||
async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise<LoadedEditableSource> {
|
||
const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase);
|
||
queryExecutionLog("info", "metadata:table:start", {
|
||
traceId,
|
||
schema: target.request.schema,
|
||
table: target.request.tableName,
|
||
alias: source.alias,
|
||
elapsed: elapsed?.(),
|
||
});
|
||
const loadedMetadata = await loadTableMetadata({
|
||
...target.request,
|
||
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }),
|
||
});
|
||
const columns = loadedMetadata.metadata.columns;
|
||
const primaryKeys = loadedMetadata.metadata.primaryKeys;
|
||
queryExecutionLog("info", "metadata:table:done", {
|
||
traceId,
|
||
columnCount: columns.length,
|
||
primaryKeyCount: primaryKeys.length,
|
||
cacheStatus: loadedMetadata.cacheStatus,
|
||
ageMs: Math.round(loadedMetadata.ageMs),
|
||
elapsed: elapsed?.(),
|
||
});
|
||
return loadedEditableSourceFromMetadata(target, loadedMetadata.metadata);
|
||
}
|
||
|
||
function missingPrimaryKeysForSource(databaseType: DatabaseType, primaryKeys: string[], analysis: EditableQueryInfo, sourceKey: string): string[] {
|
||
if (analysis.selectStar) return [];
|
||
const selectedColumns = new Set(
|
||
analysis.columns.flatMap((column) => {
|
||
if (!column.sourceName || column.sourceKey !== sourceKey) return [];
|
||
if (databaseType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return [DBX_ROWID_COLUMN, column.sourceName];
|
||
return [column.sourceName];
|
||
}),
|
||
);
|
||
return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey));
|
||
}
|
||
|
||
function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): boolean {
|
||
const knownType = loaded.tableMeta.tableType?.trim().toUpperCase();
|
||
if (knownType) return knownType === "TABLE";
|
||
const connectionStore = useConnectionStore();
|
||
const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
|
||
const targetName = normalizeIdentifier(loaded.tableMeta.tableName);
|
||
const resolvedSchema = loaded.tableMeta.schema?.trim() || tab.schema?.trim() || connectionStore.getConfig(tab.connectionId!)?.default_schema?.trim();
|
||
if (!resolvedSchema) return false;
|
||
const targetSchema = normalizeIdentifier(resolvedSchema);
|
||
const targetCatalog = loaded.tableMeta.catalog?.trim() ? normalizeIdentifier(loaded.tableMeta.catalog) : undefined;
|
||
const matches = connectionStore
|
||
.lookupLocalCompletionTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.tableName, 20, resolvedSchema, loaded.tableMeta.catalog)
|
||
.filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog));
|
||
return matches.length === 1 && matches[0]?.type === "table";
|
||
}
|
||
|
||
async function resolveOracleRowIdSafety(tab: QueryTab, loaded: LoadedEditableSource, databaseType: DatabaseType): Promise<boolean> {
|
||
if (oracleRowIdIsSafeForQuery(tab, loaded)) return true;
|
||
if (loaded.tableMeta.tableType?.trim()) return false;
|
||
// Never enumerate an Oracle schema on the query execution path: large
|
||
// schemas can make this optional editability check take minutes (#8462).
|
||
if (databaseType === "oracle") return false;
|
||
|
||
const connection = useConnectionStore().getConfig(tab.connectionId!);
|
||
const schema = loaded.tableMeta.schema?.trim() || tab.schema?.trim() || connection?.default_schema?.trim() || "";
|
||
const tables = await api.listTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, schema, loaded.tableMeta.tableName);
|
||
const exactMatches = tables.filter((table) => table.name === loaded.tableMeta.tableName);
|
||
if (exactMatches.length !== 1) return false;
|
||
|
||
loaded.tableMeta.tableType = exactMatches[0]!.table_type;
|
||
updateCachedTableMetadataType(
|
||
{
|
||
connectionId: tab.connectionId!,
|
||
database: loaded.tableMeta.database ?? tab.database,
|
||
schema: loaded.tableMeta.schema,
|
||
tableName: loaded.tableMeta.tableName,
|
||
databaseType,
|
||
driverProfile: connection?.driver_profile || connection?.db_type,
|
||
catalog: loaded.tableMeta.catalog,
|
||
},
|
||
loaded.tableMeta.tableType,
|
||
);
|
||
return oracleRowIdIsSafeForQuery(tab, loaded);
|
||
}
|
||
|
||
function primaryKeyIndex(indexes: IndexInfo[]): IndexInfo | undefined {
|
||
return indexes.find((index) => !index.filter && index.columns.length > 0 && index.is_primary);
|
||
}
|
||
|
||
function buildHiddenPrimaryKeyPreparation(tab: QueryTab, sql: string, databaseType: DatabaseType, loaded: LoadedEditableSource, primaryKeys: string[], declaredPrimaryKeys: string[], traceId: string, elapsed: () => string): EditableQueryExecutionPreparation {
|
||
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns);
|
||
const oracleLobPreview = databaseType === "oracle" && primaryKeys.length > 0 && oracleRowIdIsSafeForQuery(tab, loaded) && oracleColumnsAllowDeferredLobMarkers(loaded.tableMeta.columns) && oracleQueryProjectsDeferredLob(metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
|
||
const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [], oracleLobPreview };
|
||
const missingPrimaryKeys =
|
||
declaredPrimaryKeys.length === 0
|
||
? primaryKeys.filter((primaryKey) => !(databaseType === "oracle" && primaryKey === DBX_ROWID_COLUMN && metadataAnalysis.columns.some((column) => column.sourceKey === loaded.source.key && !column.sourceNameQuoted && column.sourceName?.toUpperCase() === "ROWID")))
|
||
: missingPrimaryKeysForSource(databaseType, primaryKeys, metadataAnalysis, loaded.source.key);
|
||
if (missingPrimaryKeys.length === 0) return unchanged;
|
||
const primaryKeySet = new Set(primaryKeys);
|
||
const hasWritableProjection = metadataAnalysis.selectStar ? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name)) : metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName));
|
||
if (!hasWritableProjection) return unchanged;
|
||
|
||
const rewritten = buildQueryWithHiddenPrimaryKeys({
|
||
sql,
|
||
databaseType,
|
||
primaryKeys: missingPrimaryKeys,
|
||
existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName),
|
||
sourceExpressions: missingPrimaryKeys.includes(DBX_ROWID_COLUMN) && (databaseType === "oracle" || databaseType === "xugu") ? { [DBX_ROWID_COLUMN]: databaseType === "oracle" ? "ROWIDTOCHAR(ROWID)" : "ROWID" } : undefined,
|
||
});
|
||
if (!rewritten) return unchanged;
|
||
queryExecutionLog("info", "hidden-primary-keys", {
|
||
traceId,
|
||
table: loaded.tableMeta.tableName,
|
||
keyCount: rewritten.projections.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
return { sql: rewritten.sql, metadataSql: rewritten.sql, hiddenPrimaryKeys: rewritten.projections, oracleLobPreview };
|
||
}
|
||
|
||
async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, executionDatabase: string, traceId: string, elapsed: () => string): Promise<EditableQueryExecutionPreparation> {
|
||
const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [], oracleLobPreview: false };
|
||
if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId) return unchanged;
|
||
|
||
try {
|
||
const editability = analyzeEditableQueryEditability(sql);
|
||
if (!editability.editable || !editability.analysis) return unchanged;
|
||
const analysis = editability.analysis;
|
||
const sources = editableQuerySources(analysis);
|
||
if (sources.length !== 1 || analysis.distinct) return unchanged;
|
||
const source = sources[0]!;
|
||
const wholeSourceProjected = projectsAllColumnsForSource(analysis, source.key);
|
||
const hasDirectSourceProjection = analysis.columns.some((column) => Boolean(column.sourceName) && (!column.sourceKey || column.sourceKey === source.key));
|
||
if (!wholeSourceProjected && !hasDirectSourceProjection) return unchanged;
|
||
// Whole-source projections already include declared primary keys. Only
|
||
// Oracle and Xugu need preflight metadata here to add their synthetic
|
||
// row key for a keyless base table.
|
||
if (databaseType !== "oracle" && databaseType !== "xugu" && wholeSourceProjected) return unchanged;
|
||
|
||
const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, databaseType, executionDatabase);
|
||
const cached = getCachedTableMetadata(target.request);
|
||
let loaded = cached ? loadedEditableSourceFromMetadata(target, cached.metadata) : undefined;
|
||
if (!cached && (databaseType === "oracle" || databaseType === "xugu")) {
|
||
// Oracle column discovery can be slow. A star projection over a table
|
||
// with a declared primary key already returns the complete row identity,
|
||
// so SQL can start while the full metadata needed for editing loads.
|
||
const fullMetadataPromise = loadTableMetadata({
|
||
...target.request,
|
||
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }),
|
||
});
|
||
void fullMetadataPromise.catch((error) => queryExecutionLog("warn", "metadata:table-prefetch:failed", { traceId, error, elapsed: elapsed() }));
|
||
const wholeSourceAutoCommit = projectsAllColumnsForSource(target.analysis, target.source.key) && tab.autoCommit !== false;
|
||
if (wholeSourceAutoCommit) {
|
||
const indexes = await waitForOracleQueryMetadataPreflight(loadTableIndexes(target.request));
|
||
if (indexes === ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT) {
|
||
queryExecutionLog("info", "metadata:preflight:timeout", {
|
||
traceId,
|
||
table: target.request.tableName,
|
||
budgetMs: ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS,
|
||
elapsed: elapsed(),
|
||
});
|
||
return unchanged;
|
||
}
|
||
if (primaryKeyIndex(indexes)) return unchanged;
|
||
}
|
||
loaded = loadedEditableSourceFromMetadata(target, (await fullMetadataPromise).metadata);
|
||
}
|
||
|
||
loaded ??= await loadEditableQuerySource(tab, analysis, source, conn, databaseType, executionDatabase, traceId, elapsed);
|
||
if (loaded.tableMeta.columns.length === 0) return unchanged;
|
||
if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged;
|
||
const columnPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
||
const primaryKeys = databaseType === "oracle" ? loaded.tableMeta.primaryKeys : editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType);
|
||
const syntheticRowId = (databaseType === "oracle" || databaseType === "xugu") && usesSyntheticRowIdKey(databaseType, primaryKeys, loaded.tableMeta.tableType);
|
||
// Base tables without a natural identifier use the same ROWID identity
|
||
// as table-data tabs (Oracle and Xugu). Confirm the object is a base
|
||
// table because selecting ROWID from a view can fail with ORA-01445.
|
||
if (syntheticRowId && !(await resolveOracleRowIdSafety(tab, loaded, databaseType))) return unchanged;
|
||
const declaredPrimaryKeys = databaseType === "oracle" && !syntheticRowId ? primaryKeys : columnPrimaryKeys;
|
||
return buildHiddenPrimaryKeyPreparation(tab, sql, databaseType, loaded, primaryKeys, declaredPrimaryKeys, traceId, elapsed);
|
||
} catch (error) {
|
||
// Metadata enrichment is optional. Query execution must retain its prior
|
||
// behavior when metadata is unavailable or the SQL cannot be rewritten.
|
||
queryExecutionLog("warn", "hidden-primary-keys:skip", { traceId, error, elapsed: elapsed() });
|
||
return unchanged;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve grouped result columns by projection ordinal. All databases use the
|
||
* mapping for comments. MySQL may additionally edit direct columns from one
|
||
* uniquely identifiable base table; aggregate expressions and columns from
|
||
* every other source remain read-only.
|
||
*/
|
||
async function resolveAggregationQueryMetadata(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise<QueryMetadataPatch | undefined> {
|
||
if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined;
|
||
const conn = useConnectionStore().getConfig(tab.connectionId);
|
||
const dbType = conn?.db_type || "";
|
||
const analysis = analyzeSelectStructureForDisplay(sql);
|
||
if (!analysis) return undefined;
|
||
const sources = editableQuerySources(analysis);
|
||
if (!sources.length) return undefined;
|
||
try {
|
||
// Resolve every source's metadata target up front, then load *columns
|
||
// only* (display payload — no index discovery) under a connection-scoped
|
||
// bounded-concurrency limiter. The shared table-column cache/in-flight
|
||
// coordinator deduplicates identical tables across concurrent callers,
|
||
// so this path issues no indexes requests and never loads full editable
|
||
// metadata.
|
||
const loadedSources: LoadedEditableSource[] = [];
|
||
const targets = sources.map((source) => resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase));
|
||
const limiterScope = `${GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX}${tab.connectionId}`;
|
||
await Promise.all(
|
||
targets.map((target) =>
|
||
groupedDisplayMetadataLimiter.run(limiterScope, "query-column-comments", async () => {
|
||
const loadedColumns = await loadTableColumns({
|
||
...target.request,
|
||
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-columns-trace", { sourceTraceId: traceId, ...event }),
|
||
});
|
||
loadedSources.push(loadedEditableSourceFromColumns(target, loadedColumns));
|
||
}),
|
||
),
|
||
);
|
||
const displayInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
|
||
const readOnlyPatch: QueryMetadataPatch = {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "aggregation",
|
||
tableMeta: undefined,
|
||
resultColumnComments: displayInfo.comments,
|
||
queryDisplaySourceColumns: displayInfo.mapping,
|
||
};
|
||
if (dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch;
|
||
// Mutation safety boundary: the FROM root must remain on the preserved
|
||
// side of the join tree, and GROUP BY must resolve to exactly that table's
|
||
// declared primary key. This makes every editable result row identify one
|
||
// physical root row even when joined rows are collapsed by aggregation.
|
||
if (analysis.distinct || analysis.hasHavingClause || analysis.hasWindowClause || analysis.hasRightJoinClause || !analysis.groupByColumns?.length) return readOnlyPatch;
|
||
|
||
const groupByRefs = resolveSourceColumnsByOrdinal(
|
||
dbType,
|
||
{ ...analysis, selectStar: false, columns: analysis.groupByColumns },
|
||
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
|
||
analysis.groupByColumns.length,
|
||
);
|
||
|
||
const candidates = loadedSources
|
||
.map((loaded) => {
|
||
const sourceColumns = displayInfo.mapping.map((ref) => {
|
||
if (ref?.sourceKey !== loaded.source.key) return undefined;
|
||
const column = loaded.tableMeta.columns.find((candidate) => candidate.name === ref.sourceColumn);
|
||
return column && !mysqlColumnIsGenerated(column) ? ref.sourceColumn : undefined;
|
||
});
|
||
const primaryKeySet = new Set(loaded.tableMeta.primaryKeys);
|
||
const hasCompletePrimaryKey = loaded.tableMeta.primaryKeys.length > 0 && loaded.tableMeta.primaryKeys.every((primaryKey) => sourceColumns.includes(primaryKey));
|
||
const editableSourceColumnCount = sourceColumns.filter((column) => column && !primaryKeySet.has(column)).length;
|
||
const hasExactPrimaryKeyGrouping = groupedByExactlyOneSourcePrimaryKey(loaded, groupByRefs);
|
||
return { ...loaded, sourceColumns, isRootSource: loaded.source.key === sources[0]!.key, hasCompletePrimaryKey, hasExactPrimaryKeyGrouping, editableSourceColumnCount };
|
||
})
|
||
.filter((loaded) => loaded.isRootSource && loaded.hasCompletePrimaryKey && loaded.hasExactPrimaryKeyGrouping && loaded.editableSourceColumnCount > 0 && !knownEditableQuerySourceTableType(tab, loaded)?.toUpperCase().includes("VIEW"));
|
||
|
||
// More than one writable source is ambiguous. Refuse the entire result
|
||
// instead of guessing which table an edit should mutate.
|
||
if (candidates.length !== 1) return readOnlyPatch;
|
||
|
||
const target = candidates[0]!;
|
||
return {
|
||
queryAnalysis: {
|
||
...target.analysis,
|
||
editableSourceKey: target.source.key,
|
||
allowInsert: false,
|
||
allowInsertDelete: false,
|
||
multiSource: sources.length > 1,
|
||
},
|
||
querySourceColumns: target.sourceColumns,
|
||
queryEditabilityReason: undefined,
|
||
tableMeta: target.tableMeta,
|
||
resultColumnComments: displayInfo.comments,
|
||
queryDisplaySourceColumns: displayInfo.mapping,
|
||
};
|
||
} catch (err) {
|
||
console.error("[DBX] ERROR fetching columns for grouped query metadata:", err);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise<QueryMetadataPatch | undefined> {
|
||
if (tab.mode !== "query") return;
|
||
if (!tab.result || !tab.result.columns.length) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: undefined,
|
||
tableMeta: undefined,
|
||
};
|
||
}
|
||
|
||
queryExecutionLog("info", "metadata:editability:start", { traceId, elapsed: elapsed?.() });
|
||
const editability = await api.analyzeEditableQueryEditability(sql);
|
||
queryExecutionLog("info", "metadata:editability:done", {
|
||
traceId,
|
||
editable: editability.editable,
|
||
reason: editability.editable ? undefined : editability.reason,
|
||
elapsed: elapsed?.(),
|
||
});
|
||
if (!editability.editable) {
|
||
const aggregationPatch = editability.reason === "aggregation" ? await resolveAggregationQueryMetadata(tab, sql, executionDatabase, traceId) : undefined;
|
||
if (aggregationPatch) return aggregationPatch;
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: editability.reason,
|
||
tableMeta: undefined,
|
||
};
|
||
}
|
||
const analysis = editability.analysis;
|
||
|
||
if (!tab.connectionId) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "metadata-unavailable",
|
||
tableMeta: undefined,
|
||
};
|
||
}
|
||
|
||
const connStore = useConnectionStore();
|
||
const conn = connStore.getConfig(tab.connectionId);
|
||
const dbType = conn?.db_type || "";
|
||
const sources = editableQuerySources(analysis);
|
||
const loadedSources: LoadedEditableSource[] = [];
|
||
try {
|
||
for (const source of sources) {
|
||
loadedSources.push(await loadEditableQuerySource(tab, analysis, source, conn, dbType, executionDatabase, traceId, elapsed));
|
||
}
|
||
|
||
const allSourceColumns = loadedSources.map((source) => ({ source: source.source, columns: source.tableMeta.columns }));
|
||
// Match DBeaver's safety model: a joined result is writable only when one
|
||
// source table has a complete row identifier and at least one writable column.
|
||
// A keyless source has no row identifier: allPrimaryKeysPresent is vacuously
|
||
// true for an empty key set, so joined results must exclude such sources.
|
||
const candidates = loadedSources
|
||
.map((loaded) => {
|
||
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
|
||
const primaryKeys = loaded.tableMeta.primaryKeys;
|
||
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key, dbType as DatabaseType, primaryKeys);
|
||
const primaryKeysPresent = primaryKeysPresentForSource(dbType, primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
|
||
const keylessAllowed = sources.length === 1 && canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded);
|
||
const primaryKeySet = new Set(primaryKeys);
|
||
const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column)).length;
|
||
return {
|
||
...loaded,
|
||
analysis: metadataAnalysis,
|
||
sourceColumns,
|
||
primaryKeysPresent,
|
||
keylessAllowed,
|
||
editableSourceColumnCount,
|
||
};
|
||
})
|
||
.filter((loaded) => ((loaded.primaryKeysPresent && loaded.tableMeta.primaryKeys.length > 0) || loaded.keylessAllowed) && !!loaded.sourceColumns && loaded.editableSourceColumnCount > 0);
|
||
|
||
if (loadedSources.length === 1) {
|
||
const loaded = loadedSources[0]!;
|
||
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
|
||
const syntheticRowIdProjection = hiddenPrimaryKeys.find((projection) => projection.sourceName.toUpperCase() === DBX_ROWID_COLUMN);
|
||
const primaryKeys = loaded.tableMeta.primaryKeys.length === 0 && syntheticRowIdProjection ? [DBX_ROWID_COLUMN] : loaded.tableMeta.primaryKeys;
|
||
const displaySourceInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
|
||
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key, dbType as DatabaseType, primaryKeys);
|
||
if (sourceColumns && syntheticRowIdProjection) {
|
||
const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase());
|
||
if (resultIndex >= 0) sourceColumns[resultIndex] = DBX_ROWID_COLUMN;
|
||
}
|
||
if (primaryKeys.length === 0 && !canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded)) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "no-primary-key",
|
||
tableMeta: loaded.tableMeta,
|
||
resultColumnComments: displaySourceInfo.comments,
|
||
queryDisplaySourceColumns: displaySourceInfo.mapping,
|
||
};
|
||
}
|
||
|
||
const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : primaryKeysPresentForSource(dbType, primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
|
||
if (!primaryKeysPresent) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "primary-key-not-returned",
|
||
tableMeta: loaded.tableMeta,
|
||
queryDisplaySourceColumns: displaySourceInfo.mapping,
|
||
};
|
||
}
|
||
|
||
if (!allEditableColumnsWriteable(metadataAnalysis, tab.result.columns)) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "aliased-columns",
|
||
tableMeta: loaded.tableMeta,
|
||
queryDisplaySourceColumns: displaySourceInfo.mapping,
|
||
};
|
||
}
|
||
|
||
return {
|
||
queryAnalysis: metadataAnalysis.distinct && canInsertIntoEditableQuerySource(tab, dbType as DatabaseType, loaded, sourceColumns) ? { ...metadataAnalysis, allowInsert: true } : metadataAnalysis,
|
||
querySourceColumns: sourceColumns,
|
||
queryEditabilityReason: undefined,
|
||
tableMeta: primaryKeys === loaded.tableMeta.primaryKeys ? loaded.tableMeta : { ...loaded.tableMeta, primaryKeys },
|
||
resultColumnComments: primaryKeys.length === 0 ? displaySourceInfo.comments : undefined,
|
||
queryDisplaySourceColumns: displaySourceInfo.mapping,
|
||
};
|
||
}
|
||
|
||
// Multi-source results cannot carry a single tableMeta, but every source
|
||
// table's metadata is already loaded. Surface per-ordinal column comments
|
||
// and a display-only result->source mapping so the data grid can still
|
||
// show comments for joined results (fixes #2129 / #6352).
|
||
const multiSourceInfo = loadedSources.length > 1 ? resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources) : undefined;
|
||
|
||
if (candidates.length === 0) {
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: loadedSources.some((loaded) => loaded.tableMeta.primaryKeys.length > 0) ? "primary-key-not-returned" : "no-primary-key",
|
||
tableMeta: undefined,
|
||
resultColumnComments: multiSourceInfo?.comments,
|
||
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
||
};
|
||
}
|
||
|
||
if (candidates.length > 1) {
|
||
const target = candidates[0]!;
|
||
return {
|
||
queryAnalysis: { ...target.analysis, multiSource: true, allowInsert: false, allowDelete: false, allowInsertDelete: false },
|
||
querySourceColumns: tab.result.columns.map((_, index) => {
|
||
const owners = candidates.filter((candidate) => candidate.sourceColumns?.[index] !== undefined);
|
||
return owners.length === 1 ? owners[0]!.sourceColumns![index] : undefined;
|
||
}),
|
||
queryWriteTargets: candidates.map((candidate) => ({ tableMeta: candidate.tableMeta, sourceColumns: candidate.sourceColumns! })),
|
||
queryEditabilityReason: undefined,
|
||
tableMeta: target.tableMeta,
|
||
resultColumnComments: multiSourceInfo?.comments,
|
||
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
||
};
|
||
}
|
||
|
||
const target = candidates[0]!;
|
||
const queryAnalysis = {
|
||
...target.analysis,
|
||
...(target.analysis.distinct && canInsertIntoEditableQuerySource(tab, dbType as DatabaseType, target, target.sourceColumns) ? { allowInsert: true } : {}),
|
||
// A DISTINCT result is still one-to-one with the selected source row
|
||
// when that source's complete primary key is present in the projection.
|
||
// The candidate filter above guarantees that identity before enabling
|
||
// the primary-key based delete SQL.
|
||
allowDelete: true,
|
||
allowInsertDelete: false,
|
||
multiSource: true,
|
||
};
|
||
return {
|
||
queryAnalysis,
|
||
querySourceColumns: target.sourceColumns,
|
||
queryEditabilityReason: undefined,
|
||
tableMeta: target.tableMeta,
|
||
resultColumnComments: multiSourceInfo?.comments,
|
||
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
||
};
|
||
} catch (err) {
|
||
console.error("[DBX] ERROR fetching columns for query metadata:", err);
|
||
return {
|
||
queryAnalysis: undefined,
|
||
querySourceColumns: undefined,
|
||
queryEditabilityReason: "metadata-unavailable",
|
||
tableMeta: undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []) {
|
||
void (async () => {
|
||
const tab = tabs.value.find((t) => t.id === tabId);
|
||
if (!tab || tab.result !== result) return;
|
||
queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() });
|
||
// Metadata requests outlive the displayed result when another query starts
|
||
// or a retained run is selected. Analyze this result, not the live tab.
|
||
const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys);
|
||
if (patch?.queryAnalysis && hasHiddenPhysicalRowKey(databaseType, hiddenPrimaryKeys)) {
|
||
patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false };
|
||
}
|
||
const current = tabs.value.find((t) => t.id === tabId);
|
||
if (patch && current?.result === result) {
|
||
applyQueryMetadataPatch(current, patch);
|
||
syncActiveResultRunFromDisplayed(current);
|
||
queryExecutionLog("info", "metadata:done", { traceId, elapsed: elapsed() });
|
||
} else {
|
||
queryExecutionLog("warn", "metadata:stale", { traceId, elapsed: elapsed() });
|
||
}
|
||
})();
|
||
}
|
||
|
||
function setQueryTotalRowCountIfCurrent(tabId: string, executionId: string, result: QueryResult, totalRowCount: number | undefined) {
|
||
const current = tabs.value.find((t) => t.id === tabId);
|
||
if (!current || (current.mode !== "query" && current.mode !== "data")) return;
|
||
if (current.executionId !== executionId && current.result !== result) return;
|
||
current.resultTotalRowCount = totalRowCount;
|
||
current.resultTotalRowCountLoading = false;
|
||
syncActiveResultRunFromDisplayed(current);
|
||
}
|
||
|
||
type TotalRowCountSqlTarget = { sql: string; schema?: string };
|
||
|
||
function countQueryTotalRowsInBackground(options: {
|
||
tabId: string;
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
catalog?: string;
|
||
countSql?: string;
|
||
countSqlTarget?: () => Promise<TotalRowCountSqlTarget | undefined>;
|
||
result: QueryResult;
|
||
pageLimit?: number;
|
||
pageOffset?: number;
|
||
useAgentResultSession?: boolean;
|
||
executionId: string;
|
||
traceId: string;
|
||
elapsed: () => string;
|
||
timeoutSecs: number;
|
||
txnSessionId?: string;
|
||
}) {
|
||
const resultRowCount = options.result.rows.length;
|
||
if (resultRowCount <= 0) {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
|
||
return;
|
||
}
|
||
const exactIncompletePageTotal = exactTotalFromIncompletePage(options.result, options.pageLimit, options.pageOffset, options.useAgentResultSession);
|
||
if (typeof exactIncompletePageTotal === "number") {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, exactIncompletePageTotal);
|
||
return;
|
||
}
|
||
|
||
// A full page was returned, so more rows may exist and determining the true
|
||
// total requires a potentially expensive COUNT(*) over the user's query.
|
||
// Only run it automatically when the user opted in; otherwise leave the
|
||
// total unknown and let them trigger it on demand from the result grid
|
||
// (matches DBeaver's default of not counting large result sets).
|
||
if (!useSettingsStore().editorSettings.autoCalculateTotalRows) {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
|
||
return;
|
||
}
|
||
|
||
const countExecutionId = `${options.executionId}:count`;
|
||
void (async () => {
|
||
let isolatedClientSessionId: string | undefined;
|
||
try {
|
||
const countTarget = options.countSql ? { sql: options.countSql, schema: options.schema } : await options.countSqlTarget?.();
|
||
if (!countTarget?.sql) {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
|
||
return;
|
||
}
|
||
queryExecutionLog("info", "count:start", { traceId: options.traceId, elapsed: options.elapsed() });
|
||
let countResult: QueryResult | undefined;
|
||
if (options.txnSessionId) {
|
||
const txnResults = await api.executeInManualTransaction(options.txnSessionId, countTarget.sql, options.database, countTarget.schema, 1);
|
||
countResult = txnResults.find((result) => result.columns.length > 0) ?? txnResults[txnResults.length - 1];
|
||
} else {
|
||
const connection = useConnectionStore().getConfig(options.connectionId);
|
||
const databaseType = effectiveDatabaseTypeForConnection(connection);
|
||
const usesQuerySession = sqlServerCountUsesLocalTempTable(databaseType, countTarget.sql);
|
||
const clientSessionId = usesQuerySession ? tabClientSessionId({ id: options.tabId }) : tabClientSessionId({ id: options.tabId }, "count");
|
||
if (!usesQuerySession) isolatedClientSessionId = clientSessionId;
|
||
countResult = await api.executeQuery(options.connectionId, options.database, countTarget.sql, countTarget.schema, countExecutionId, {
|
||
clientSessionId,
|
||
catalog: options.catalog,
|
||
timeoutSecs: options.timeoutSecs,
|
||
});
|
||
}
|
||
const total = Number(countResult?.rows?.[0]?.[0] ?? 0);
|
||
if (!Number.isFinite(total) || total < 0) {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
|
||
return;
|
||
}
|
||
// COUNT describes all matching rows; the configured result limit only
|
||
// constrains how many of them pagination may load and retain.
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, total);
|
||
queryExecutionLog("info", "count:done", {
|
||
traceId: options.traceId,
|
||
total,
|
||
elapsed: options.elapsed(),
|
||
});
|
||
} catch (error) {
|
||
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
|
||
queryExecutionLog("warn", "count:error", {
|
||
traceId: options.traceId,
|
||
elapsed: options.elapsed(),
|
||
error,
|
||
});
|
||
} finally {
|
||
if (isolatedClientSessionId) {
|
||
void closeClientSessionId(options.connectionId, options.database, isolatedClientSessionId, options.catalog, { tabId: options.tabId });
|
||
}
|
||
}
|
||
})();
|
||
}
|
||
|
||
async function countTabResultRows(id: string): Promise<number | undefined> {
|
||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||
if (!tab?.resultCountSql) return undefined;
|
||
|
||
const target = queryResultExecutionLocation(tab);
|
||
const connection = useConnectionStore().getConfig(target.connectionId);
|
||
const databaseType = effectiveDatabaseTypeForConnection(connection);
|
||
let countResult: QueryResult | undefined;
|
||
let isolatedClientSessionId: string | undefined;
|
||
|
||
try {
|
||
if (tab.autoCommit === false && tab.txnSessionId) {
|
||
const txnResults = await api.executeInManualTransaction(tab.txnSessionId, tab.resultCountSql, target.database, target.schema, 1);
|
||
countResult = txnResults.find((result) => result.columns.length > 0) ?? txnResults[txnResults.length - 1];
|
||
} else {
|
||
const usesQuerySession = sqlServerCountUsesLocalTempTable(databaseType, tab.resultCountSql);
|
||
const clientSessionId = usesQuerySession ? tabClientSessionId(tab) : tabClientSessionId(tab, "count");
|
||
if (!usesQuerySession) isolatedClientSessionId = clientSessionId;
|
||
countResult = await api.executeQuery(target.connectionId, target.database, tab.resultCountSql, target.schema, undefined, {
|
||
clientSessionId,
|
||
catalog: target.catalog,
|
||
maxRows: 1,
|
||
timeoutSecs: queryTimeoutSecsForConnection(connection, useSettingsStore().editorSettings.globalQueryTimeoutSecs),
|
||
});
|
||
}
|
||
|
||
const total = Number(countResult?.rows?.[0]?.[0]);
|
||
return Number.isFinite(total) && total >= 0 ? total : undefined;
|
||
} finally {
|
||
if (isolatedClientSessionId) {
|
||
void closeClientSessionId(target.connectionId, target.database, isolatedClientSessionId, target.catalog, { tabId: tab.id });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function executeTabSql(
|
||
id: string,
|
||
sql: string,
|
||
options?: {
|
||
resultBaseSql?: string;
|
||
resultSortedSql?: string | undefined;
|
||
querySort?: {
|
||
resultColumns: string[];
|
||
columnIndex: number;
|
||
column: string;
|
||
direction: "asc" | "desc";
|
||
};
|
||
pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string };
|
||
appendResult?: { maxRows: number };
|
||
/** Logical-result publication origin for the view-snapshot cache. */
|
||
publicationOrigin?: ResultPublicationOrigin;
|
||
mongoSafety?: MongoAggregateSafetyOptions;
|
||
preserveResultDuringExecution?: boolean;
|
||
preserveTotalRowCountDuringExecution?: boolean;
|
||
preserveActiveResultIndex?: boolean;
|
||
replaceActiveResultInGroup?: boolean;
|
||
retainDisplayedResult?: boolean;
|
||
skipRedisSafetyCheck?: boolean;
|
||
sourceOffset?: number;
|
||
sourceTraceId?: string;
|
||
skipEnsureConnected?: boolean;
|
||
openInNewResultTab?: boolean;
|
||
targetContext?: SqlExecutionTargetContext;
|
||
executionTarget?: MultiDbExecutionTarget;
|
||
onExecutionStarted?: () => void;
|
||
batchResume?: BatchSqlResumeOptions;
|
||
},
|
||
) {
|
||
assertUpdateAllowsInteraction();
|
||
const tab = findExecutionTab(id);
|
||
if (!tab || !sql.trim()) return;
|
||
if (pendingResultRunPreparations.has(tab)) return false;
|
||
|
||
const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true;
|
||
// Auto-saved results need two independent decisions: keep the currently
|
||
// displayed run visible while the request is pending, then capture the new
|
||
// response as another run. Previously `resultAutoSave` only made the latter
|
||
// decision after clearing the displayed payload, which caused the result
|
||
// toolbar and grid to briefly disappear before the next Run was added.
|
||
const captureAutoSavedResultRun = tab.mode === "query" && tab.resultAutoSave === true && (!!tab.activeResultRunId || !!tab.result);
|
||
let captureResultRun = openInNewResultTab || captureAutoSavedResultRun;
|
||
let resultRunToRestore: string | undefined;
|
||
let reuseResultRun = false;
|
||
if (!captureResultRun && tab.mode === "query" && !tab.resultAutoSave && tab.activeResultRunId) {
|
||
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId);
|
||
if (activeRun?.pinned) {
|
||
const reusableRun = tab.resultRuns?.find((run) => !run.pinned);
|
||
if (reusableRun) {
|
||
resultRunToRestore = reusableRun.id;
|
||
reuseResultRun = true;
|
||
} else {
|
||
captureResultRun = true;
|
||
}
|
||
}
|
||
}
|
||
if (captureResultRun && tab.activeResultRunId && !tab.result) resultRunToRestore = tab.activeResultRunId;
|
||
if (resultRunToRestore) {
|
||
// Reserve before disk I/O so another click cannot queue a second SQL.
|
||
const preparationId = uuid();
|
||
pendingResultRunPreparations.set(tab, preparationId);
|
||
tab.isExecuting = true;
|
||
tab.isCancelling = false;
|
||
tab.executionId = preparationId;
|
||
tab.executingResultRunId = null;
|
||
tab.queryExecutionStartedAt = Date.now();
|
||
const isCurrent = () => findExecutionTab(id) === tab && tab.executionId === preparationId && pendingResultRunPreparations.get(tab) === preparationId;
|
||
let prepared = false;
|
||
try {
|
||
const restored = await setActiveResultRun(id, resultRunToRestore, { isCurrent });
|
||
if (!isCurrent()) return false;
|
||
if (reuseResultRun) captureResultRun = !restored;
|
||
prepared = true;
|
||
} catch (error) {
|
||
if (!isCurrent()) return false;
|
||
throw error;
|
||
} finally {
|
||
if (pendingResultRunPreparations.get(tab) === preparationId) {
|
||
pendingResultRunPreparations.delete(tab);
|
||
if (!prepared && tab.executionId === preparationId) {
|
||
tab.isExecuting = false;
|
||
tab.isCancelling = false;
|
||
tab.executionId = undefined;
|
||
tab.executingResultRunId = undefined;
|
||
tab.queryExecutionStartedAt = undefined;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const executionId = uuid();
|
||
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
|
||
const traceId = executionId.slice(0, 8);
|
||
const startedAt = performance.now();
|
||
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
|
||
const batchResume = options?.batchResume;
|
||
const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch;
|
||
const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || captureAutoSavedResultRun || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun);
|
||
const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution;
|
||
tab.isExecuting = true;
|
||
tab.executingResultRunId = !captureResultRun && updateActiveResultRun ? tab.activeResultRunId : null;
|
||
options?.onExecutionStarted?.();
|
||
tab.isCancelling = false;
|
||
if (!tab.queryExecutionStartedAt) {
|
||
tab.queryExecutionStartedAt = Date.now();
|
||
}
|
||
tab.executionId = executionId;
|
||
const tableDataNativeSelectionBlockOwner = tab.mode === "data" ? {} : undefined;
|
||
if (tableDataNativeSelectionBlockOwner) beginDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
|
||
const previousDisplayedSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
|
||
tab.lastExecutedSql = sql;
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
if (captureResultRun && tab.result && !tab.activeResultRunId) {
|
||
captureDisplayedResultRun(tab, previousDisplayedSql);
|
||
}
|
||
if (captureResultRun && tab.activeResultRunId) {
|
||
pendingResultRunRestores.set(executionId, tab.activeResultRunId);
|
||
}
|
||
if (batchResume) {
|
||
tab.batchSqlExecution = prepareBatchSqlRecovery(batchResume.batch, executionId, batchResume.startStatementIndex);
|
||
liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
|
||
} else {
|
||
tab.batchSqlExecution = undefined;
|
||
liveBatchSqlExecutions.delete(tab);
|
||
}
|
||
if (!updateActiveResultRun) {
|
||
tab.activeResultRunId = undefined;
|
||
}
|
||
if (!options?.preserveTotalRowCountDuringExecution) {
|
||
tab.resultTotalRowCount = undefined;
|
||
}
|
||
tab.resultTotalRowCountLoading = false;
|
||
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
|
||
if (!preserveResultDuringExecution || !tab.result) {
|
||
clearResultPayload(tab, { preserveCacheSnapshot: captureResultRun && pendingResultRunRestores.has(executionId) });
|
||
}
|
||
queryExecutionLog("info", "start", {
|
||
traceId,
|
||
tabId: id,
|
||
mode: tab.mode,
|
||
sourceTraceId: options?.sourceTraceId,
|
||
sqlLength: sql.length,
|
||
});
|
||
const queryBaseSql = options?.resultBaseSql ?? sql;
|
||
let sqlToExecute = sql;
|
||
let resultSortedSql = options?.resultSortedSql;
|
||
let queryMetadataSql = queryBaseSql;
|
||
let hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [];
|
||
let useOracleLobPreview = false;
|
||
let pageSql: string | undefined;
|
||
let requestedPageLimit: number | undefined;
|
||
let pageLimit: number | undefined;
|
||
let pageOffset: number | undefined;
|
||
let countSql: string | undefined;
|
||
let exactQueryRowBound: number | undefined;
|
||
let useAgentResultSession = false;
|
||
let executionDispatched = false;
|
||
let producedResult = false;
|
||
const resumedExecutionTarget = batchResume?.batch.executionTarget;
|
||
const executionConnectionId = resumedExecutionTarget?.connectionId ?? options?.executionTarget?.connectionId ?? tab.connectionId;
|
||
try {
|
||
await waitForTabSessionReset(id);
|
||
const connStore = useConnectionStore();
|
||
const executionTarget = resumedExecutionTarget ?? options?.executionTarget;
|
||
const usesExternalExecutionTarget = !!executionTarget;
|
||
let conn = connStore.getConfig(executionConnectionId);
|
||
const parsedMongoCommands = conn?.db_type === "mongodb" ? splitMongoCommandRanges(sql) : undefined;
|
||
let mongoCommands = parsedMongoCommands ?? [];
|
||
const mongoNeedsConnection = mongoCommands.some(({ command }) => command.kind !== "use");
|
||
|
||
if (options?.skipEnsureConnected) {
|
||
queryExecutionLog("info", "ensure-connected:skip", { traceId, elapsed: elapsed(), reason: "caller" });
|
||
} else if (conn?.db_type === "mongodb" && mongoCommands.length > 0 && !mongoNeedsConnection) {
|
||
queryExecutionLog("info", "ensure-connected:skip", { traceId, elapsed: elapsed(), reason: "mongo-use-only" });
|
||
} else {
|
||
queryExecutionLog("info", "ensure-connected:start", { traceId, elapsed: elapsed() });
|
||
await connStore.ensureConnected(executionConnectionId);
|
||
queryExecutionLog("info", "ensure-connected:done", { traceId, elapsed: elapsed() });
|
||
}
|
||
conn = connStore.getConfig(executionConnectionId);
|
||
if (parsedMongoCommands === undefined && conn?.db_type === "mongodb") {
|
||
mongoCommands = splitMongoCommandRanges(sql);
|
||
}
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
if (tab.autoCommit === false && !supportsTransaction(conn?.db_type)) {
|
||
tab.autoCommit = true;
|
||
}
|
||
const targetContext = options?.targetContext;
|
||
if (targetContext?.scope === "namespace") {
|
||
throw new Error("Namespace execution targets require a registered execution adapter.");
|
||
}
|
||
const databaseTargetContext = targetContext?.scope === "catalog" || targetContext?.scope === "database" ? targetContext : undefined;
|
||
const executionCatalog = resumedExecutionTarget ? resumedExecutionTarget.catalog : targetContext ? (targetContext.scope === "catalog" ? targetContext.catalog : undefined) : (executionTarget?.catalog ?? (tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog));
|
||
const contextDatabase = databaseTargetContext?.database;
|
||
const targetDatabase = resumedExecutionTarget ? resumedExecutionTarget.database : targetContext?.scope === "connection" ? "" : (contextDatabase ?? executionTarget?.database ?? tab.database);
|
||
if (effectiveDbType === "opengauss") {
|
||
await connStore.ensureDatabaseCompatibilityMode(executionConnectionId, targetDatabase || conn?.database);
|
||
}
|
||
const targetSchema = resumedExecutionTarget ? resumedExecutionTarget.schema : targetContext?.scope === "connection" ? undefined : (databaseTargetContext?.schema ?? executionTarget?.schema ?? tab.schema);
|
||
const executionDatabase = dataTabExecutionDatabase(conn, targetDatabase, executionCatalog);
|
||
const sqlStatementParameterOptions = sqlStatementParameterOptionsForCompatibility(effectiveDbType, effectiveDbType === "opengauss" ? connStore.databaseCompatibilityMode(executionConnectionId, targetDatabase || conn?.database) : undefined);
|
||
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
|
||
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
|
||
if (!batchResume) {
|
||
const statementExecution =
|
||
tab.mode === "query"
|
||
? createBatchSqlExecution(
|
||
executionId,
|
||
tab.sql,
|
||
sql,
|
||
effectiveDbType,
|
||
options?.sourceOffset,
|
||
{
|
||
connectionId: executionConnectionId,
|
||
catalog: executionCatalog,
|
||
database: targetDatabase,
|
||
schema: targetSchema,
|
||
},
|
||
sqlStatementParameterOptions,
|
||
)
|
||
: undefined;
|
||
tab.batchSqlExecution = statementExecution && (tab.autoCommit !== false || statementExecution.total === 1) ? statementExecution : undefined;
|
||
if (tab.batchSqlExecution) liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
|
||
}
|
||
queryExecutionLog("info", "previous-session-close:start", { traceId, elapsed: elapsed() });
|
||
await previousResultSessionClose;
|
||
queryExecutionLog("info", "previous-session-close:done", { traceId, elapsed: elapsed() });
|
||
|
||
// Redis command execution — split multi-line input into individual commands
|
||
if (conn?.db_type === "redis") {
|
||
await connStore.ensureConnected(executionConnectionId);
|
||
let currentDb = Number(executionDatabase) || Number(tab.database) || 0;
|
||
const commands = sql
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0);
|
||
if (commands.length === 0) return false;
|
||
if (commands.length === 1 && isRedisMonitorCommand(commands[0])) {
|
||
const monitor = startRedisMonitor(
|
||
() => api.redisPubSubConnect(executionConnectionId, true),
|
||
(rows) => {
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId !== executionId) return;
|
||
current.result = markQueryResultRowsRaw(annotateQueryResultSource({ columns: ["MONITOR"], rows: rows.map((message) => [message]), affected_rows: 0, execution_time_ms: performance.now() - startedAt }, "MONITOR"));
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
current.queryEditabilityReason = undefined;
|
||
current.queryWriteTargets = undefined;
|
||
current.tableMeta = undefined;
|
||
current.resultBaseSql = "MONITOR";
|
||
current.redisMonitorActive = true;
|
||
current.queryAnalysis = undefined;
|
||
current.querySourceColumns = undefined;
|
||
current.resultColumnComments = undefined;
|
||
current.queryDisplaySourceColumns = undefined;
|
||
current.mongoEditTarget = undefined;
|
||
if (!producedResult) {
|
||
publishResultGeneration(current, "execute");
|
||
syncDisplayedResultRun(current, "MONITOR", captureResultRun);
|
||
}
|
||
producedResult = true;
|
||
touchResult(current);
|
||
},
|
||
);
|
||
redisMonitors.set(executionId, monitor.stop);
|
||
try {
|
||
await monitor.done;
|
||
} finally {
|
||
redisMonitors.delete(executionId);
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) current.redisMonitorActive = false;
|
||
}
|
||
return producedResult;
|
||
}
|
||
queryExecutionLog("info", "redis:start", { traceId, db: currentDb, commandCount: commands.length, sqlLength: sql.length });
|
||
|
||
const allResults: QueryResult[] = [];
|
||
const commandRanges = executableStatementRanges(sql, "redis");
|
||
const skipSafety = options?.skipRedisSafetyCheck;
|
||
let hadMutatingCommand = false;
|
||
for (const [commandIndex, command] of commands.entries()) {
|
||
const commandRange = commandRanges[commandIndex];
|
||
const sourceRange = commandRange && options?.sourceOffset !== undefined ? { from: options.sourceOffset + commandRange.from, to: options.sourceOffset + commandRange.to } : undefined;
|
||
try {
|
||
const result = await api.redisExecuteCommand(executionConnectionId, currentDb, command, skipSafety);
|
||
allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, command), command, undefined, undefined, sourceRange)));
|
||
// Track db switches from SELECT N so later commands in the same batch run on the right db.
|
||
currentDb = nextRedisCommandDb(currentDb, command, result.value);
|
||
// Write commands (SET/DEL/...) mutate the key set — drop the cached key-name completion
|
||
// for the db this command ran on so the next autocomplete fetch reflects the new keys.
|
||
if (isRedisMutatingCommand(command)) {
|
||
hadMutatingCommand = true;
|
||
connStore.invalidateCompletionCache(executionConnectionId, String(currentDb));
|
||
}
|
||
} catch (e: any) {
|
||
allResults.push(annotateQueryResultSource(toErrorResult(e), command, undefined, undefined, sourceRange));
|
||
}
|
||
}
|
||
queryExecutionLog("info", "redis:done", { traceId, commandCount: commands.length, elapsed: elapsed() });
|
||
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||
if (allResults.length > 1) {
|
||
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
|
||
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
|
||
current.results = allResults;
|
||
current.activeResultIndex = resultIndex;
|
||
current.result = allResults[resultIndex];
|
||
} else {
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
current.result = allResults[0];
|
||
}
|
||
// Redis command batches always replace the visible result.
|
||
publishResultGeneration(current, "execute");
|
||
producedResult = current.result !== undefined;
|
||
touchResult(current);
|
||
current.queryAnalysis = undefined;
|
||
current.querySourceColumns = undefined;
|
||
current.queryWriteTargets = undefined;
|
||
current.resultColumnComments = undefined;
|
||
current.queryDisplaySourceColumns = undefined;
|
||
current.queryEditabilityReason = undefined;
|
||
current.mongoEditTarget = undefined;
|
||
current.tableMeta = undefined;
|
||
current.resultBaseSql = options?.resultBaseSql ?? sql;
|
||
current.resultSortedSql = options?.resultSortedSql;
|
||
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql, captureResultRun);
|
||
// Reflect db switches from SELECT N in the tab so the toolbar dropdown, tab title and
|
||
// sidebar stay in sync with the command's effective db.
|
||
if (!usesExternalExecutionTarget && current.database !== String(currentDb)) {
|
||
current.database = String(currentDb);
|
||
}
|
||
}
|
||
// Refresh the sidebar db key counts (INFO keyspace) when at least one command in
|
||
// this batch mutated the key set, so `dbN (count)` stays accurate without a manual
|
||
// refresh. Fire-and-forget: never block result display.
|
||
if (hadMutatingCommand) {
|
||
void connStore.refreshRedisDbKeyCounts(executionConnectionId);
|
||
}
|
||
return producedResult;
|
||
}
|
||
|
||
if (conn?.db_type === "mongodb" && mongoCommands.length === 0 && sql.trim()) {
|
||
// Avoid falling through to the SQL executor, which only returns the generic
|
||
// "Use MongoDB-specific commands" rejection and hides parse/syntax details.
|
||
throw new Error(describeMongoCommandParseFailure(sql));
|
||
}
|
||
|
||
if (mongoCommands.length > 0) {
|
||
queryExecutionLog("info", "mongo:start", { traceId, commandCount: mongoCommands.length, sqlLength: sql.length });
|
||
|
||
const allResults: QueryResult[] = [];
|
||
const mongoResultMaxRows = effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows);
|
||
// Track the effective db as we walk the batch so later commands observe
|
||
// earlier `use ...` statements in the same editor selection.
|
||
let currentDatabase = executionTarget?.database ?? tab.database;
|
||
let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined;
|
||
let mongoFindPageState: { pageLimit: number; pageOffset: number; total: number; totalIsExact: boolean } | undefined;
|
||
|
||
for (const parsedCommand of mongoCommands) {
|
||
let mongoCommand = parsedCommand.command;
|
||
const sourceStatement = parsedCommand.text;
|
||
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to };
|
||
const commandStartedAt = performance.now();
|
||
const annotateMongoResult = (result: QueryResult): QueryResult => {
|
||
const annotated = annotateQueryResultSource(result, sourceStatement, undefined, undefined, sourceRange);
|
||
if ("collection" in mongoCommand) {
|
||
annotated.sourceLabel = currentDatabase ? `${currentDatabase}.${mongoCommand.collection}` : mongoCommand.collection;
|
||
}
|
||
return annotated;
|
||
};
|
||
try {
|
||
// The frontend parser remains responsible for editor ranges, while
|
||
// dbx-core is authoritative for command semantics at execution time.
|
||
mongoCommand = await api.mongoParseShellCommand(sourceStatement);
|
||
switch (mongoCommand.kind) {
|
||
case "find": {
|
||
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||
const pagePlan = planMongoFindPagination(sourceStatement, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
|
||
if (!pagePlan) throw new Error(describeMongoCommandParseFailure(sourceStatement));
|
||
// A stale request can point past an explicit .limit() bound. Keep
|
||
// the backend call bounded so limit(0) cannot become unbounded.
|
||
const result = await api.mongoFindDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, pagePlan.requestSkip, Math.max(1, pagePlan.requestLimit), mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, executionId);
|
||
const documents = pagePlan.requestLimit === 0 ? [] : result.documents;
|
||
const extendedDocuments = pagePlan.requestLimit === 0 ? [] : result.extended_documents;
|
||
const totalIsExact = result.total_is_exact !== false;
|
||
const reportedTotal = mongoFindLogicalTotal(result.total, pagePlan);
|
||
const loadedLowerBound = pagePlan.pageOffset + documents.length;
|
||
const total = totalIsExact ? reportedTotal : Math.max(reportedTotal, loadedLowerBound);
|
||
const hasMore = totalIsExact ? loadedLowerBound < total : pagePlan.requestLimit > 0 && documents.length >= pagePlan.requestLimit && (pagePlan.logicalLimit === undefined || loadedLowerBound < pagePlan.logicalLimit);
|
||
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(documents, performance.now() - commandStartedAt, total, extendedDocuments, totalIsExact)));
|
||
queryResult.truncated = hasMore;
|
||
queryResult.has_more = hasMore;
|
||
allResults.push(queryResult);
|
||
if (mongoCommands.length === 1) {
|
||
mongoFindPageState = { pageLimit: pagePlan.pageLimit, pageOffset: pagePlan.pageOffset, total, totalIsExact };
|
||
}
|
||
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
|
||
queryExecutionLog("info", "mongo-find:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
rowCount: result.documents.length,
|
||
total: result.total,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "findOne": {
|
||
queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||
const result = await api.mongoFindOne(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId);
|
||
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)));
|
||
allResults.push(queryResult);
|
||
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
|
||
queryExecutionLog("info", "mongo-find-one:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
rowCount: result.documents.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "version": {
|
||
queryExecutionLog("info", "mongo-version:start", { traceId, database: currentDatabase });
|
||
const version = await api.mongoServerVersion(executionConnectionId, currentDatabase, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoVersionToQueryResult(version, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-version:done", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
version,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "showDatabases": {
|
||
queryExecutionLog("info", "mongo-show-databases:start", { traceId });
|
||
const result = await api.mongoRunCommand(executionConnectionId, "admin", '{"listDatabases":1}', executionId);
|
||
const queryResult = mongoDatabasesToQueryResult(result.documents, performance.now() - commandStartedAt, agentProtocolQueryResultMaxRows(mongoResultMaxRows));
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(queryResult)));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-show-databases:done", {
|
||
traceId,
|
||
databaseCount: queryResult.affected_rows,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "countDocuments": {
|
||
queryExecutionLog("info", "mongo-count:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||
const total = await api.mongoCountDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCountToQueryResult(total, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-count:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
total,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "aggregate": {
|
||
if (options?.mongoSafety) {
|
||
const safety = evaluateMongoAggregateSafety(mongoCommand, options.mongoSafety);
|
||
if (!safety.allowed) throw new Error(safety.reason);
|
||
}
|
||
queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||
const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize);
|
||
const result = await api.mongoAggregateDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-aggregate:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
rowCount: result.documents.length,
|
||
total: result.total,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "distinct": {
|
||
queryExecutionLog("info", "mongo-distinct:start", { traceId, collection: mongoCommand.collection, database: currentDatabase, field: mongoCommand.field });
|
||
const result = await api.mongoDistinct(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.field, mongoCommand.filter, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDistinctToQueryResult(mongoCommand.field, result.documents, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-distinct:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
field: mongoCommand.field,
|
||
valueCount: result.documents.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "getIndexes": {
|
||
queryExecutionLog("info", "mongo-indexes:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||
const indexes = await api.listIndexes(executionConnectionId, currentDatabase, "", mongoCommand.collection);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoIndexesToQueryResult(indexes, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-indexes:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
indexCount: indexes.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "collectionStats": {
|
||
queryExecutionLog("info", "mongo-collection-stats:start", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
metric: mongoCommand.metric,
|
||
database: currentDatabase,
|
||
});
|
||
const stats = await api.mongoCollectionStats(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.scale, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCollectionStatsToQueryResult(mongoCommand.metric, stats as unknown as Record<string, unknown>, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-collection-stats:done", {
|
||
traceId,
|
||
collection: mongoCommand.collection,
|
||
metric: mongoCommand.metric,
|
||
database: currentDatabase,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "findOneAndUpdate":
|
||
case "findOneAndReplace":
|
||
case "findOneAndDelete": {
|
||
if (options?.mongoSafety) {
|
||
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
|
||
if (!safety.allowed) throw new Error(safety.reason);
|
||
}
|
||
queryExecutionLog("info", "mongo-find-and-modify:start", {
|
||
traceId,
|
||
kind: mongoCommand.kind,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
});
|
||
const result =
|
||
mongoCommand.kind === "findOneAndUpdate"
|
||
? await api.mongoFindOneAndUpdate(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options)
|
||
: mongoCommand.kind === "findOneAndReplace"
|
||
? await api.mongoFindOneAndReplace(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options)
|
||
: await api.mongoFindOneAndDelete(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-find-and-modify:done", {
|
||
traceId,
|
||
kind: mongoCommand.kind,
|
||
collection: mongoCommand.collection,
|
||
database: currentDatabase,
|
||
rowCount: result.documents.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "runCommand": {
|
||
if (options?.mongoSafety) {
|
||
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
|
||
if (!safety.allowed) throw new Error(safety.reason);
|
||
}
|
||
queryExecutionLog("info", "mongo-run-command:start", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
});
|
||
const result = await api.mongoRunCommand(executionConnectionId, currentDatabase, mongoCommand.commandJson, executionId);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-run-command:done", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
rowCount: result.documents.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "insert":
|
||
case "update":
|
||
case "delete":
|
||
case "createIndex":
|
||
case "createUser":
|
||
case "dropIndex":
|
||
case "dropIndexes":
|
||
case "dropCollection": {
|
||
if (options?.mongoSafety) {
|
||
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
|
||
if (!safety.allowed) throw new Error(safety.reason);
|
||
}
|
||
queryExecutionLog("info", "mongo-write:start", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
kind: mongoCommand.kind,
|
||
...(mongoCommand.kind === "createUser" ? {} : { collection: mongoCommand.collection }),
|
||
});
|
||
mongoEditTarget = undefined;
|
||
if (mongoCommand.kind === "insert") {
|
||
const result = await api.mongoInsertDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.docsJson);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
|
||
} else if (mongoCommand.kind === "update") {
|
||
const result = await api.mongoUpdateDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many, mongoCommand.options);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
|
||
} else if (mongoCommand.kind === "createIndex") {
|
||
const result = await api.mongoCreateIndex(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt))));
|
||
} else if (mongoCommand.kind === "createUser") {
|
||
const result = await api.mongoCreateUser(executionConnectionId, currentDatabase, mongoCommand.userJson, mongoCommand.writeConcernJson);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
|
||
} else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") {
|
||
try {
|
||
const result = await api.mongoDropIndexes(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt, result.failures))));
|
||
} finally {
|
||
await refreshLoadedMongoIndexesAfterMutation(executionConnectionId, currentDatabase, mongoCommand.collection, traceId);
|
||
}
|
||
} else if (mongoCommand.kind === "dropCollection") {
|
||
await api.mongoDropCollection(executionConnectionId, currentDatabase, mongoCommand.collection);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt))));
|
||
} else {
|
||
const result = await api.mongoDeleteDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.many);
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
|
||
}
|
||
queryExecutionLog("info", "mongo-write:done", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
kind: mongoCommand.kind,
|
||
...(mongoCommand.kind === "createUser" ? {} : { collection: mongoCommand.collection }),
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
case "use": {
|
||
currentDatabase = mongoCommand.database;
|
||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoUseToQueryResult(currentDatabase, performance.now() - commandStartedAt))));
|
||
mongoEditTarget = undefined;
|
||
queryExecutionLog("info", "mongo-use:done", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
elapsed: elapsed(),
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
// Surface per-command failures inline and continue collecting results
|
||
// for the rest of the batch, matching the grouped-result UX.
|
||
allResults.push(annotateMongoResult(toErrorResult(error)));
|
||
mongoEditTarget = undefined;
|
||
}
|
||
}
|
||
|
||
queryExecutionLog("info", "mongo:done", {
|
||
traceId,
|
||
database: currentDatabase,
|
||
commandCount: mongoCommands.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
reconcileBatchSqlResults(current, executionId, allResults);
|
||
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||
const activeGroupIndex = current.activeResultIndex;
|
||
const activeGroupResults = current.results;
|
||
const findPageState = mongoFindPageState;
|
||
const shouldAppendResult = !!findPageState && !!options?.appendResult && !!current.result && allResults.length === 1;
|
||
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && allResults.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
|
||
if (shouldAppendResult) {
|
||
if (findPageState!.pageOffset !== current.result!.rows.length) {
|
||
throw new Error("Ignoring a stale MongoDB result segment whose offset no longer matches the loaded rows");
|
||
}
|
||
const appendedResult = appendQueryResultSegment(current.result!, allResults[0]!, options!.appendResult!.maxRows);
|
||
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
|
||
current.results = activeGroupResults.slice();
|
||
current.results[activeGroupIndex] = appendedResult;
|
||
}
|
||
current.result = appendedResult;
|
||
} else if (shouldReplaceActiveResultInGroup) {
|
||
current.results = activeGroupResults.slice();
|
||
current.results[activeGroupIndex] = allResults[0];
|
||
current.result = allResults[0];
|
||
} else if (allResults.length > 1) {
|
||
// Open grouped output on the first non-error result when possible so
|
||
// mixed success/error batches land on the most useful table first.
|
||
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
|
||
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
|
||
current.results = allResults;
|
||
current.activeResultIndex = resultIndex;
|
||
current.result = allResults[resultIndex];
|
||
} else {
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
current.result = allResults[0];
|
||
}
|
||
publishResultGeneration(current, shouldAppendResult ? "append" : "execute");
|
||
producedResult = current.result !== undefined;
|
||
touchResult(current);
|
||
current.queryAnalysis = undefined;
|
||
current.querySourceColumns = undefined;
|
||
current.queryWriteTargets = undefined;
|
||
current.resultColumnComments = undefined;
|
||
current.queryDisplaySourceColumns = undefined;
|
||
current.queryEditabilityReason = undefined;
|
||
current.mongoEditTarget = mongoCommands.length === 1 ? mongoEditTarget : undefined;
|
||
current.tableMeta = undefined;
|
||
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? options?.resultBaseSql ?? sql) : (options?.resultBaseSql ?? sql);
|
||
current.resultSortedSql = options?.resultSortedSql;
|
||
current.resultPageSql = undefined;
|
||
current.resultPageLimit = mongoFindPageState?.pageLimit;
|
||
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : mongoFindPageState?.pageOffset;
|
||
current.resultCountSql = undefined;
|
||
current.resultSessionId = undefined;
|
||
current.resultClientSessionId = undefined;
|
||
current.resultTotalRowCount = mongoFindPageState?.totalIsExact ? mongoFindPageState.total : undefined;
|
||
current.resultTotalRowCountLoading = false;
|
||
syncDisplayedResultRun(current, current.resultBaseSql ?? options?.resultBaseSql ?? sql, captureResultRun);
|
||
if (!usesExternalExecutionTarget && current.database !== currentDatabase) current.database = currentDatabase;
|
||
}
|
||
return producedResult;
|
||
}
|
||
|
||
const elasticsearchRequests = elasticsearchRestRequestRanges(sqlToExecute, effectiveDbType);
|
||
if (elasticsearchRequests.length > 0) {
|
||
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:start]", {
|
||
traceId,
|
||
requestCount: elasticsearchRequests.length,
|
||
sql,
|
||
});
|
||
const allResults: QueryResult[] = [];
|
||
const continueOnError = continueOnBatchError;
|
||
for (const request of elasticsearchRequests) {
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId !== executionId) break;
|
||
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + request.from, to: options.sourceOffset + request.to };
|
||
try {
|
||
const result = await api.executeQuery(executionConnectionId, executionDatabase, request.sql, undefined, executionId, {
|
||
timeoutSecs: queryTimeoutSecs,
|
||
});
|
||
allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(result, request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange)));
|
||
if (elasticsearchHttpErrorStatus(result) !== undefined && !continueOnError) break;
|
||
} catch (error) {
|
||
const latest = findExecutionTab(id);
|
||
if (latest?.executionId !== executionId) break;
|
||
allResults.push(annotateQueryResultSource(toErrorResult(error), request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange));
|
||
if (!continueOnError) break;
|
||
}
|
||
}
|
||
|
||
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:done]", {
|
||
traceId,
|
||
requestCount: elasticsearchRequests.length,
|
||
resultCount: allResults.length,
|
||
elapsed: elapsed(),
|
||
});
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId && captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||
if (current?.executionId === executionId && allResults.length > 0) {
|
||
clearResultNavigationState(current);
|
||
const errorResultIndex = allResults.findIndex((result) => isQueryExecutionErrorResult(result) || elasticsearchHttpErrorStatus(result) !== undefined);
|
||
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
|
||
current.results = allResults.length > 1 ? allResults : undefined;
|
||
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
|
||
current.result = allResults[resultIndex];
|
||
// Elasticsearch batch requests replace the visible result.
|
||
publishResultGeneration(current, "execute");
|
||
producedResult = current.result !== undefined;
|
||
touchResult(current);
|
||
current.queryAnalysis = undefined;
|
||
current.querySourceColumns = undefined;
|
||
current.queryWriteTargets = undefined;
|
||
current.resultColumnComments = undefined;
|
||
current.queryDisplaySourceColumns = undefined;
|
||
current.queryEditabilityReason = undefined;
|
||
current.mongoEditTarget = undefined;
|
||
current.tableMeta = undefined;
|
||
current.resultBaseSql = options?.resultBaseSql ?? sql;
|
||
current.resultSortedSql = undefined;
|
||
syncDisplayedResultRun(current, current.resultBaseSql, captureResultRun);
|
||
}
|
||
return producedResult;
|
||
}
|
||
|
||
const queryResultMaxRows = tab.mode === "query" ? effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows) : undefined;
|
||
|
||
if (tab.mode === "query") {
|
||
const prepared = await prepareEditableQueryExecution(tab, sqlToExecute, conn, effectiveDbType, executionDatabase, traceId, elapsed);
|
||
sqlToExecute = prepared.sql;
|
||
// Database sorting executes a generated wrapper around the user's query.
|
||
// Keep editability metadata anchored to the original query so the wrapper
|
||
// does not turn an otherwise editable result into a complex read-only one.
|
||
queryMetadataSql = options?.resultSortedSql && !options?.querySort ? queryBaseSql : prepared.metadataSql;
|
||
hiddenPrimaryKeys = prepared.hiddenPrimaryKeys;
|
||
useOracleLobPreview = prepared.oracleLobPreview;
|
||
if (options?.querySort) {
|
||
const sorted = await api.buildSortedQuerySql({
|
||
originalSql: sqlToExecute,
|
||
databaseType: effectiveDbType,
|
||
resultColumns: [...options.querySort.resultColumns, ...hiddenPrimaryKeys.map((projection) => projection.alias)],
|
||
columnIndex: options.querySort.columnIndex,
|
||
column: options.querySort.column,
|
||
direction: options.querySort.direction,
|
||
});
|
||
if (!sorted.ok || !sorted.sql) throw new Error("Unable to build sorted query SQL");
|
||
sqlToExecute = sorted.sql;
|
||
resultSortedSql = sorted.sql;
|
||
}
|
||
const requestedPagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 };
|
||
requestedPageLimit = requestedPagination.limit;
|
||
const pagination = limitQueryPagination(requestedPagination, queryResultMaxRows);
|
||
const sqlBeforePagination = sqlToExecute;
|
||
const sqlServerUseScript = effectiveDbType === "sqlserver" && sqlToExecute === queryBaseSql ? sqlServerLeadingUseScript(sqlToExecute) : undefined;
|
||
const plan = await api.prepareQueryPaginationExecutionPlan({
|
||
sql: sqlServerUseScript?.querySql ?? sqlToExecute,
|
||
queryBaseSql: sqlServerUseScript?.querySql ?? queryBaseSql,
|
||
databaseType: effectiveDbType,
|
||
pagination,
|
||
useAgentCursor,
|
||
firstPageUsesActualSql: hiddenPrimaryKeys.length > 0,
|
||
});
|
||
const canPaginateSqlServerUseScript = !!sqlServerUseScript && !!plan.pageSql && typeof plan.pageLimit === "number" && typeof plan.pageOffset === "number";
|
||
if (sqlServerUseScript && !canPaginateSqlServerUseScript) {
|
||
sqlToExecute = sqlBeforePagination;
|
||
} else {
|
||
sqlToExecute = sqlServerUseScript ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.sqlToExecute) : plan.sqlToExecute;
|
||
pageSql = sqlServerUseScript && plan.pageSql ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.pageSql) : plan.pageSql;
|
||
pageLimit = plan.pageLimit;
|
||
pageOffset = plan.pageOffset;
|
||
countSql = sqlServerUseScript && plan.countSql ? replaceSqlServerLeadingUseQuery(queryBaseSql, sqlServerUseScript, plan.countSql) : plan.countSql;
|
||
exactQueryRowBound = plan.exactQueryRowBound;
|
||
useAgentResultSession = plan.useAgentResultSession;
|
||
}
|
||
const hasBoundedPagination = typeof pageLimit === "number" && typeof pageOffset === "number";
|
||
if (options?.appendResult && !hasBoundedPagination && !useAgentResultSession) {
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId && current.result) {
|
||
current.result.has_more = false;
|
||
const activeResultIndex = current.activeResultIndex;
|
||
if (Array.isArray(current.results) && typeof activeResultIndex === "number" && activeResultIndex >= 0 && activeResultIndex < current.results.length) {
|
||
current.results[activeResultIndex]!.has_more = false;
|
||
}
|
||
touchResult(current);
|
||
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
|
||
}
|
||
queryExecutionLog("info", "append-result:pagination-unsupported", { traceId, elapsed: elapsed() });
|
||
return false;
|
||
}
|
||
} else if (tab.mode === "data") {
|
||
const requestedPagination = {
|
||
limit: options?.pagination?.limit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize),
|
||
offset: options?.pagination?.offset ?? 0,
|
||
};
|
||
requestedPageLimit = requestedPagination.limit;
|
||
const pagination = limitQueryPagination(requestedPagination, queryResultMaxRows);
|
||
pageLimit = pagination.limit;
|
||
pageOffset = pagination.offset;
|
||
useAgentResultSession = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy";
|
||
}
|
||
|
||
const executionSchema = connectionQueryExecutionSchema(conn, targetDatabase, targetSchema, tab.mode === "data");
|
||
// Jumping to a non-zero offset without a live cursor session: the plan
|
||
// could not rewrite the SQL for these engines, so a plain execution
|
||
// would return the first page again (#8993).
|
||
const isOffsetJumpPage = typeof pageOffset === "number" && pageOffset > 0 && !options?.pagination?.sessionId;
|
||
const frontendTimeoutSecs = frontendQueryTimeoutSecsForSql(sqlToExecute, effectiveDbType, queryTimeoutSecs, sqlStatementParameterOptions);
|
||
const sourceLabelDatabase = targetDatabase || conn?.database;
|
||
const executionClientSessionId = options?.pagination?.clientSessionId ?? (tab.mode === "query" || tab.mode === "data" ? tabClientSessionId(tab) : undefined);
|
||
const currentBeforeDispatch = findExecutionTab(id);
|
||
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling) {
|
||
queryExecutionLog("info", "dispatch:skipped-cancelled", { traceId, elapsed: elapsed() });
|
||
return false;
|
||
}
|
||
|
||
const executeWithoutManualTransaction = (): Promise<QueryResult[]> => {
|
||
queryExecutionLog("info", "execute-multi:start", { traceId, elapsed: elapsed() });
|
||
// Query and data tabs use a tab-scoped pool so repeated executions keep
|
||
// connection-local state and avoid MySQL pool resets on every refresh.
|
||
const dataTabMeta = tab.mode === "data" ? tableMetaForDataTab(tab) : undefined;
|
||
const useTableDataPreview = canUseTableDataLargeValuePreview(effectiveDbType, dataTabMeta?.columns ?? [], dataTabMeta?.primaryKeys ?? []);
|
||
const useJdbcDriverRowOffset = tab.mode === "data" && conn?.db_type === "jdbc" && effectiveDbType === "iris";
|
||
const executionOptions = {
|
||
...(typeof pageLimit === "number"
|
||
? useAgentResultSession
|
||
? {
|
||
// Agent cursors apply maxRows cumulatively across fetched pages.
|
||
maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows),
|
||
fetchSize: pageLimit,
|
||
pageSize: pageLimit,
|
||
resultSessionId: options?.pagination?.sessionId,
|
||
}
|
||
: { maxRows: pageLimit, fetchSize: pageLimit }
|
||
: { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }),
|
||
...(useJdbcDriverRowOffset && typeof pageOffset === "number" && pageOffset > 0 ? { rowOffset: pageOffset } : {}),
|
||
...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}),
|
||
...(tab.mode === "data" && (effectiveDbType === "mysql" || effectiveDbType === "postgres")
|
||
? {
|
||
maxResultBytes: TABLE_DATA_RESULT_MAX_BYTES,
|
||
resultKeyColumns: dataTabMeta?.primaryKeys ?? [],
|
||
tableDataPreview: useTableDataPreview,
|
||
}
|
||
: {}),
|
||
...(useOracleLobPreview ? { tableDataPreview: true } : {}),
|
||
timeoutSecs: queryTimeoutSecs,
|
||
catalog: executionCatalog,
|
||
continueOnError: continueOnBatchError,
|
||
};
|
||
queryExecutionLog("info", "execute-multi:invoke", {
|
||
traceId,
|
||
elapsed: elapsed(),
|
||
executionSchema,
|
||
optionKeys: Object.keys(executionOptions),
|
||
clientSession: Boolean(executionClientSessionId),
|
||
});
|
||
executionDispatched = true;
|
||
if (useAgentResultSession && tab.mode === "query" && typeof pageOffset === "number" && pageOffset > 0 && !options?.pagination?.sessionId && !(tab.batchSqlExecution && tab.batchSqlExecution.total > 1)) {
|
||
return (async () => {
|
||
let sessionId: string | undefined;
|
||
let skipped = 0;
|
||
while (true) {
|
||
const pageResults = await api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, {
|
||
...executionOptions,
|
||
resultSessionId: sessionId,
|
||
});
|
||
const page = pageResults[0];
|
||
if (!page) return pageResults;
|
||
if (skipped + page.rows.length > pageOffset) {
|
||
const start = pageOffset - skipped;
|
||
const limit = typeof pageLimit === "number" ? pageLimit : page.rows.length - start;
|
||
return [{ ...page, rows: page.rows.slice(start, start + limit) }];
|
||
}
|
||
skipped += page.rows.length;
|
||
if (!page.has_more || !page.session_id) {
|
||
if (page.has_more) {
|
||
// The cursor session ended before the requested offset;
|
||
// returning the short page would show the wrong rows.
|
||
throw new Error("Result session ended before the requested page offset");
|
||
}
|
||
return [{ ...page, rows: [] }];
|
||
}
|
||
sessionId = page.session_id;
|
||
}
|
||
})();
|
||
}
|
||
return tab.batchSqlExecution && tab.batchSqlExecution.total > 1
|
||
? api.executeMultiWithProgress(
|
||
executionConnectionId,
|
||
executionDatabase,
|
||
sqlToExecute,
|
||
(progress) => {
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
applyBatchSqlProgress(current, progress, continueOnBatchError, batchResume?.startStatementIndex ?? 0);
|
||
}
|
||
},
|
||
executionSchema,
|
||
{ ...executionOptions, executionId },
|
||
)
|
||
: api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
|
||
};
|
||
|
||
let executionPromise: Promise<QueryResult[]>;
|
||
if (tab.autoCommit === false) {
|
||
let useLegacyReadFallback = false;
|
||
if (!tab.txnSessionId) {
|
||
queryExecutionLog("info", "begin-manual-txn:start", { traceId, elapsed: elapsed() });
|
||
try {
|
||
tab.txnSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
|
||
queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
|
||
} catch (error) {
|
||
const risk = classifySqlRisk(sqlToExecute, { dialect: effectiveDbType }).risk;
|
||
if (!isUnsupportedManualTransactionMethod(error) || risk !== "read") throw error;
|
||
tab.autoCommit = true;
|
||
clearManualTransactionSession(tab);
|
||
useLegacyReadFallback = true;
|
||
queryExecutionLog("warn", "begin-manual-txn:legacy-read-fallback", {
|
||
traceId,
|
||
elapsed: elapsed(),
|
||
databaseType: effectiveDbType,
|
||
});
|
||
}
|
||
}
|
||
if (useLegacyReadFallback) {
|
||
executionPromise = executeWithoutManualTransaction();
|
||
} else {
|
||
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
|
||
executionDispatched = true;
|
||
// Only an initial manual execution classifies the user SQL (Oracle-only).
|
||
// A later cursor-page fetch must neither set nor clear the sticky bit.
|
||
const isInitialOracleManualExecution = effectiveDbType === "oracle" && !options?.pagination?.sessionId;
|
||
const classificationSql = isInitialOracleManualExecution ? queryBaseSql : undefined;
|
||
let manualTransactionRecoveryAttempted = false;
|
||
executionPromise = (async () => {
|
||
const txnSessionId = tab.txnSessionId;
|
||
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
|
||
// Offset jumps inside a manual transaction keep the legacy
|
||
// single-shot call: the session-consume loop only runs in the
|
||
// auto-commit path, and opening a cursor session here would
|
||
// strand it after returning the first page.
|
||
const executeInTransaction = (sessionId: string) =>
|
||
useAgentResultSession && !isOffsetJumpPage
|
||
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId, classificationSql)
|
||
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, undefined, undefined, classificationSql);
|
||
try {
|
||
return await executeInTransaction(txnSessionId);
|
||
} catch (error) {
|
||
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
|
||
manualTransactionRecoveryAttempted = true;
|
||
// The expired session was discarded by the backend; the replacement
|
||
// session starts fresh, so the old sticky state resets with it.
|
||
clearOracleTxnPossiblyDirty(tab);
|
||
tab.txnSessionId = undefined;
|
||
tab.txnAutoRolledBack = true;
|
||
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
|
||
const refreshedSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
|
||
tab.txnSessionId = refreshedSessionId;
|
||
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
|
||
return executeInTransaction(refreshedSessionId);
|
||
}
|
||
})();
|
||
}
|
||
} else {
|
||
executionPromise = executeWithoutManualTransaction();
|
||
}
|
||
const annotatedResults = annotateQueryResultSources(
|
||
markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))),
|
||
queryBaseSql,
|
||
sourceLabelDatabase,
|
||
effectiveDbType,
|
||
options?.sourceOffset,
|
||
sqlStatementParameterOptions,
|
||
);
|
||
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
|
||
reconcileBatchSqlResults(tab, executionId, results);
|
||
// Oracle-only sticky state aggregation. Only the initial manual execution
|
||
// participates: a later cursor-page fetch (pagination.sessionId present)
|
||
// must neither set nor clear the bit, and the Core no-op (empty script)
|
||
// must neither set nor clear it. Otherwise any result that is not proven
|
||
// read-only dirties the session monotonically.
|
||
if (tab.autoCommit === false && effectiveDbType === "oracle" && !options?.pagination?.sessionId && tab.txnSessionId) {
|
||
const rawResults = annotatedResults.results;
|
||
const isCoreNoOp = rawResults.length > 0 && rawResults.every((result) => result.manual_transaction_no_statement === true);
|
||
if (!isCoreNoOp && rawResults.some((result) => result.manual_transaction_proven_read_only !== true)) {
|
||
tab.oracleTxnPossiblyDirty = true;
|
||
}
|
||
}
|
||
const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0;
|
||
const successfulSapHanaSchemaChanges = effectiveDbType === "saphana" ? results.filter((result) => result.execution_error !== true && isSapHanaSetSchemaStatement(result.sourceStatement)).length : 0;
|
||
const sqlServerUseDatabase = effectiveDbType === "sqlserver" ? annotatedResults.sqlServerUseDatabase : undefined;
|
||
if (hiddenPrimaryKeys.length > 0 && results.length === 1) {
|
||
const hiddenIndexes = hiddenResultColumnIndexes(results[0]!.columns, hiddenPrimaryKeys);
|
||
if (hiddenIndexes.length > 0) results[0]!.hidden_column_indexes = hiddenIndexes;
|
||
if (hiddenIndexes.length !== hiddenPrimaryKeys.length) queryMetadataSql = queryBaseSql;
|
||
} else if (hiddenPrimaryKeys.length > 0) {
|
||
queryMetadataSql = queryBaseSql;
|
||
}
|
||
queryExecutionLog("info", "execute-multi:done", {
|
||
traceId,
|
||
resultCount: results.length,
|
||
rowCounts: results.map((result) => result.rows.length),
|
||
columnCounts: results.map((result) => result.columns.length),
|
||
elapsed: elapsed(),
|
||
});
|
||
let resolvedSapHanaSchema: string | undefined;
|
||
if (successfulSapHanaSchemaChanges > 0 && findExecutionTab(id)?.executionId === executionId) {
|
||
try {
|
||
const schemaResult = await api.executeQuery(executionConnectionId, executionDatabase, "SELECT CURRENT_SCHEMA FROM DUMMY", undefined, executionId, {
|
||
clientSessionId: tabClientSessionId(tab),
|
||
timeoutSecs: queryTimeoutSecs,
|
||
});
|
||
resolvedSapHanaSchema = sapHanaCurrentSchemaFromResult(schemaResult);
|
||
} catch (error) {
|
||
console.warn("[DBX] Failed to resolve SAP HANA CURRENT_SCHEMA", error);
|
||
}
|
||
}
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||
if (successfulOracleSchemaChanges > 0) {
|
||
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
|
||
}
|
||
if (resolvedSapHanaSchema && !usesExternalExecutionTarget) {
|
||
current.schema = resolvedSapHanaSchema;
|
||
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulSapHanaSchemaChanges;
|
||
}
|
||
if (sqlServerUseDatabase && !usesExternalExecutionTarget && current.database !== sqlServerUseDatabase) {
|
||
rollbackTabTransaction(current);
|
||
void closeClientConnectionSession(current);
|
||
current.database = sqlServerUseDatabase;
|
||
current.schema = undefined;
|
||
}
|
||
const activeGroupIndex = current.activeResultIndex;
|
||
const activeGroupResults = current.results;
|
||
const shouldAppendResult = !!options?.appendResult && !!current.result;
|
||
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
|
||
const retainedPaginationResult = options?.retainDisplayedResult === true && results.length === 1 && !isQueryExecutionErrorResult(results[0]!) ? results[0] : undefined;
|
||
if (retainedPaginationResult) {
|
||
// Cursor-only fetches advance the backend session without publishing
|
||
// intermediate rows or page offsets to the visible result grid.
|
||
current.resultSessionId = retainedPaginationResult.session_id ?? undefined;
|
||
current.resultClientSessionId = current.resultSessionId ? executionClientSessionId : undefined;
|
||
producedResult = true;
|
||
return producedResult;
|
||
}
|
||
if (batchResume) {
|
||
const mergedResults = mergeBatchQueryResults(batchResume.previousResults, results);
|
||
const preferredResult = results.find((result) => isQueryExecutionErrorResult(result)) ?? results[results.length - 1] ?? mergedResults[mergedResults.length - 1];
|
||
const resultIndex = preferredResult ? mergedResults.indexOf(preferredResult) : 0;
|
||
current.results = mergedResults.length > 1 ? mergedResults : undefined;
|
||
current.activeResultIndex = mergedResults.length > 1 ? Math.max(0, resultIndex) : undefined;
|
||
current.result = mergedResults[Math.max(0, resultIndex)];
|
||
} else if (shouldAppendResult) {
|
||
if (results.length !== 1) throw new Error("Expected one result while loading the next segment");
|
||
if (options.pagination?.offset !== current.result!.rows.length) {
|
||
throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows");
|
||
}
|
||
const appendedResult = appendQueryResultSegment(current.result!, results[0]!, options.appendResult!.maxRows);
|
||
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
|
||
current.results = activeGroupResults.slice();
|
||
current.results[activeGroupIndex] = appendedResult;
|
||
}
|
||
current.result = appendedResult;
|
||
} else if (shouldReplaceActiveResultInGroup) {
|
||
current.results = activeGroupResults.slice();
|
||
current.results[activeGroupIndex] = results[0];
|
||
current.result = results[0];
|
||
} else if (results.length > 1) {
|
||
const errorResultIndex = results.findIndex((result) => isQueryExecutionErrorResult(result));
|
||
const activeResultIndex = results.findIndex((result) => result.columns.length > 0);
|
||
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : (preservedResultIndex(results, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0));
|
||
current.results = results;
|
||
current.activeResultIndex = resultIndex;
|
||
current.result = results[resultIndex];
|
||
} else {
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
current.result = results[0];
|
||
}
|
||
// Logical-result identity for the view-snapshot cache. An append extends
|
||
// the same dataset; every other branch above replaces it.
|
||
publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));
|
||
producedResult = current.result !== undefined;
|
||
current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
|
||
current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
|
||
current.resultSortedSql = resultSortedSql;
|
||
// Appended rows form one logical result starting at the original page.
|
||
// Keep the base page state so later table refresh/cache recovery does
|
||
// not re-execute only the most recently fetched tail segment.
|
||
current.resultPageSql = shouldAppendResult ? (current.resultPageSql ?? pageSql) : pageSql;
|
||
const displayPageLimit = typeof pageLimit === "number" ? (requestedPageLimit ?? pageLimit) : undefined;
|
||
current.resultPageLimit = shouldAppendResult ? (current.resultPageLimit ?? displayPageLimit) : displayPageLimit;
|
||
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : pageOffset;
|
||
current.resultCountSql = countSql;
|
||
current.resultSessionId = current.result?.session_id ?? undefined;
|
||
current.resultClientSessionId = current.resultSessionId ? executionClientSessionId : undefined;
|
||
if (!options?.preserveTotalRowCountDuringExecution) {
|
||
current.resultTotalRowCount = undefined;
|
||
}
|
||
const resultRowCount = current.result?.rows.length ?? 0;
|
||
const resultLimitReached = !!current.result && queryResultLimitReached(pageOffset, resultRowCount, queryResultMaxRows);
|
||
if (resultLimitReached && current.result) {
|
||
current.result.has_more = false;
|
||
current.result.truncated = true;
|
||
current.resultTotalRowCount = queryResultMaxRows;
|
||
}
|
||
const paginationPageResult = shouldAppendResult ? results[0] : current.result;
|
||
const exactIncompletePageTotal = paginationPageResult ? exactTotalFromIncompletePage(paginationPageResult, pageLimit, pageOffset, useAgentResultSession) : undefined;
|
||
const totalKnownFromIncompletePage = typeof exactIncompletePageTotal === "number";
|
||
let totalRowCountResolved = false;
|
||
if (current.mode === "query" && current.result && !isQueryExecutionErrorResult(current.result) && typeof exactQueryRowBound === "number") {
|
||
const boundedTotal = capQueryResultTotal(exactQueryRowBound, queryResultMaxRows);
|
||
current.resultTotalRowCount = Math.min(boundedTotal, exactIncompletePageTotal ?? boundedTotal);
|
||
current.resultTotalRowCountLoading = false;
|
||
totalRowCountResolved = true;
|
||
}
|
||
const dataCountTarget =
|
||
current.mode === "data"
|
||
? (() => {
|
||
const tableMeta = tableMetaForDataTab(current);
|
||
if (!tableMeta?.tableName) return undefined;
|
||
return {
|
||
databaseType: effectiveDbType,
|
||
identifierQuote: useConnectionStore().connectionIdentifierQuote?.(current.connectionId),
|
||
catalog: tableMeta.catalog,
|
||
database: tableMeta.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
whereInput: current.whereInput?.trim() || undefined,
|
||
countHint: effectiveDbType === "gaussdb" ? gaussdbCountQueryDopHint(useConnectionStore().getConfig(current.connectionId)) : undefined,
|
||
};
|
||
})()
|
||
: undefined;
|
||
const canAutoCalculateTotalRows =
|
||
!options?.appendResult &&
|
||
!!current.result &&
|
||
resultRowCount > 0 &&
|
||
!resultLimitReached &&
|
||
!totalKnownFromIncompletePage &&
|
||
!totalRowCountResolved &&
|
||
settingsStore.editorSettings.autoCalculateTotalRows &&
|
||
((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
|
||
current.resultTotalRowCountLoading = canAutoCalculateTotalRows;
|
||
// Server-side pagination without a countSql: the backend (currently
|
||
// the Elasticsearch driver) already reports the true match total via
|
||
// affected_rows. Use it directly so the result-grid can compute the
|
||
// page count without issuing a separate COUNT query.
|
||
if (!totalRowCountResolved && current.result && current.result.total_is_exact !== false && current.mode === "query" && typeof pageLimit === "number" && !countSql && typeof current.result.affected_rows === "number" && current.result.affected_rows > current.result.rows.length) {
|
||
current.resultTotalRowCount = current.result.affected_rows;
|
||
current.resultTotalRowCountLoading = false;
|
||
totalRowCountResolved = true;
|
||
}
|
||
touchResult(current);
|
||
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
|
||
if (!options?.appendResult && !resultLimitReached && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
|
||
countQueryTotalRowsInBackground({
|
||
tabId: id,
|
||
connectionId: executionConnectionId,
|
||
database: executionDatabase,
|
||
schema: executionTarget?.schema ?? current.schema,
|
||
catalog: executionCatalog,
|
||
countSql,
|
||
countSqlTarget: dataCountTarget
|
||
? async () => ({
|
||
sql: await api.buildDataGridCountSql(dataCountTarget),
|
||
schema: undefined,
|
||
})
|
||
: undefined,
|
||
result: current.result,
|
||
pageLimit,
|
||
pageOffset,
|
||
useAgentResultSession,
|
||
executionId,
|
||
traceId,
|
||
elapsed,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
txnSessionId: current.autoCommit === false ? current.txnSessionId : undefined,
|
||
});
|
||
}
|
||
queryExecutionLog("info", "result:assigned", {
|
||
traceId,
|
||
activeResultIndex: current.activeResultIndex,
|
||
rowCount: current.result?.rows.length ?? 0,
|
||
columnCount: current.result?.columns.length ?? 0,
|
||
backendMs: current.result?.execution_time_ms,
|
||
elapsed: elapsed(),
|
||
});
|
||
if (current.mode === "query" && current.result) {
|
||
analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys);
|
||
}
|
||
} else {
|
||
queryExecutionLog("warn", "stale-result", {
|
||
traceId,
|
||
currentExecutionId: current?.executionId,
|
||
elapsed: elapsed(),
|
||
});
|
||
}
|
||
} catch (e: any) {
|
||
queryExecutionLog("error", "error", { traceId, elapsed: elapsed(), error: e });
|
||
// Sync connection state if the error indicates a lost connection
|
||
useConnectionStore().recordConnectionLostError(executionConnectionId ?? tab.connectionId, e);
|
||
// Handle manual transaction auto-rollback (idle timeout only for the banner;
|
||
// other statement failures still clear the session without the 5-minute notice).
|
||
if (tab.autoCommit === false) {
|
||
const errMsg: string = e?.message ?? String(e);
|
||
const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚");
|
||
if (idleTimeout) {
|
||
// Backend session was removed and rolled back after idle expiry: clear
|
||
// the sticky dirty state together with the session.
|
||
clearOracleTxnPossiblyDirty(tab);
|
||
tab.txnSessionId = undefined;
|
||
tab.txnAutoRolledBack = true;
|
||
} else if (/rolled.?back/i.test(errMsg) || /transaction session not found/i.test(errMsg) || /agent runtime terminated/i.test(errMsg)) {
|
||
// Statement failure that disposed the manual session: the `rolled back`
|
||
// message fragment is a frontend cleanup compatibility contract.
|
||
clearOracleTxnPossiblyDirty(tab);
|
||
tab.txnSessionId = undefined;
|
||
tab.txnAutoRolledBack = false;
|
||
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)) === "oracle") {
|
||
// Frontend timeout/cancel or mid-script failure: the statement may still
|
||
// have executed server-side while the manual session survives, so keep
|
||
// the sticky dirty state fail-closed instead of a clean toolbar on a
|
||
// dirty session. Cursor-page fetches stay excluded like the aggregation.
|
||
tab.oracleTxnPossiblyDirty = true;
|
||
}
|
||
}
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
failBatchSqlExecution(current, executionId, e, current.isCancelling === true);
|
||
const restoredRetainedResult = captureResultRun && (current.isCancelling || !executionDispatched) && restorePendingResultRun(current, executionId);
|
||
if (restoredRetainedResult) {
|
||
queryExecutionLog("info", "retained-result:restored-after-abort", { traceId, elapsed: elapsed() });
|
||
return false;
|
||
} else if (options?.appendResult && current.result) {
|
||
// A failed background segment must not replace the visible result or
|
||
// silently invalidate pending edits. The next explicit refresh can retry.
|
||
queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() });
|
||
return false;
|
||
}
|
||
const errorResult = toErrorResult(e);
|
||
const activeGroupIndex = current.activeResultIndex;
|
||
const activeGroupResults = current.results;
|
||
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
|
||
if (shouldReplaceActiveResultInGroup) {
|
||
current.results = activeGroupResults.slice();
|
||
current.results[activeGroupIndex] = errorResult;
|
||
current.result = errorResult;
|
||
} else {
|
||
current.result = errorResult;
|
||
current.results = undefined;
|
||
current.activeResultIndex = undefined;
|
||
}
|
||
current.queryAnalysis = undefined;
|
||
current.querySourceColumns = undefined;
|
||
current.queryWriteTargets = undefined;
|
||
current.resultColumnComments = undefined;
|
||
current.queryDisplaySourceColumns = undefined;
|
||
current.queryEditabilityReason = undefined;
|
||
current.mongoEditTarget = undefined;
|
||
if (current.mode !== "data") current.tableMeta = undefined;
|
||
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
|
||
current.resultSortedSql = resultSortedSql;
|
||
current.resultPageSql = pageSql;
|
||
current.resultPageLimit = typeof pageLimit === "number" ? (requestedPageLimit ?? pageLimit) : undefined;
|
||
current.resultPageOffset = pageOffset;
|
||
current.resultCountSql = countSql;
|
||
current.resultSessionId = undefined;
|
||
current.resultClientSessionId = undefined;
|
||
current.resultTotalRowCount = undefined;
|
||
current.resultTotalRowCountLoading = false;
|
||
touchResult(current);
|
||
producedResult = true;
|
||
// An error result replaces the dataset the view snapshot was taken on.
|
||
publishResultGeneration(current, "execute");
|
||
// When a pinned result requires a new run, errors must use that same
|
||
// run instead of being replaced by the retained pinned result below.
|
||
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
|
||
}
|
||
} finally {
|
||
if (tableDataNativeSelectionBlockOwner) finishDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
|
||
const current = findExecutionTab(id);
|
||
if (current?.executionId === executionId) {
|
||
const liveBatch = liveBatchSqlExecutions.get(current);
|
||
if (liveBatch?.executionId === executionId) current.batchSqlExecution = liveBatch;
|
||
finishBatchSqlExecution(current, executionId, current.isCancelling === true);
|
||
if (current.activeResultRunId && current.result) syncActiveResultRunFromDisplayed(current);
|
||
if (captureResultRun && !current.activeResultRunId) {
|
||
restorePendingResultRun(current, executionId);
|
||
} else {
|
||
pendingResultRunRestores.delete(executionId);
|
||
}
|
||
current.isExecuting = false;
|
||
current.isCancelling = false;
|
||
current.queryExecutionStartedAt = undefined;
|
||
current.executionId = undefined;
|
||
current.executingResultRunId = undefined;
|
||
clearLiveBatchSqlExecution(current, executionId);
|
||
queryExecutionLog("info", "finish", { traceId, elapsed: elapsed() });
|
||
} else {
|
||
pendingResultRunRestores.delete(executionId);
|
||
if (current) clearLiveBatchSqlExecution(current, executionId);
|
||
queryExecutionLog("warn", "finish-stale", {
|
||
traceId,
|
||
currentExecutionId: current?.executionId,
|
||
elapsed: elapsed(),
|
||
});
|
||
}
|
||
}
|
||
scheduleResultCacheTrim();
|
||
return producedResult;
|
||
}
|
||
|
||
function dismissBatchSqlRecovery(id: string) {
|
||
const tab = findExecutionTab(id);
|
||
if (!tab?.batchSqlExecution || !batchSqlRecoveryState(tab)) return false;
|
||
tab.batchSqlExecution.recoveryDismissed = true;
|
||
return true;
|
||
}
|
||
|
||
async function resumeBatchSql(id: string, action: BatchSqlRecoveryAction) {
|
||
const tab = findExecutionTab(id);
|
||
const recovery = tab ? batchSqlRecoveryState(tab) : undefined;
|
||
const batch = tab?.batchSqlExecution;
|
||
if (!tab || !batch || !recovery) return false;
|
||
|
||
const startStatementIndex = action === "retry" ? recovery.failedStatementIndex : recovery.failedStatementIndex + 1;
|
||
const resumed = batchSqlRecoverySql(batch, startStatementIndex);
|
||
if (!resumed) return false;
|
||
|
||
const previousResults = tab.results?.slice() ?? (tab.result ? [tab.result] : []);
|
||
return await executeTabSql(id, resumed.sql, {
|
||
sourceOffset: resumed.sourceOffset,
|
||
preserveResultDuringExecution: true,
|
||
batchResume: {
|
||
batch: cloneBatchSqlExecution(batch)!,
|
||
previousResults,
|
||
startStatementIndex,
|
||
continueOnError: action === "skip-all",
|
||
},
|
||
});
|
||
}
|
||
|
||
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
|
||
assertUpdateAllowsInteraction();
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab) return { ok: false as const, reason: "empty" as const };
|
||
const conn = useConnectionStore().getConfig(tab.connectionId);
|
||
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
|
||
const executionId = uuid();
|
||
|
||
tab.isExplaining = true;
|
||
tab.explainExecutionId = executionId;
|
||
tab.explainPlan = undefined;
|
||
tab.explainTableResult = undefined;
|
||
tab.explainError = undefined;
|
||
tab.explainTableError = undefined;
|
||
tab.explainSql = undefined;
|
||
tab.explainTableSql = undefined;
|
||
tab.lastExplainedSql = sql;
|
||
|
||
try {
|
||
await waitForTabSessionReset(id);
|
||
} catch (e: any) {
|
||
// Do not start an explain with a session whose schema reset did not complete.
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
tab.explainError = String(e?.message || e);
|
||
return { ok: false as const, reason: tab.explainError };
|
||
}
|
||
|
||
// DM and Oracle agents expose native text plans. DM also supports autotrace.
|
||
if (databaseType === "dameng" || databaseType === "oracle") {
|
||
let explainSql = sql;
|
||
if (databaseType === "oracle") {
|
||
const built = await buildExplainSql(databaseType, sql);
|
||
if (!built.ok) {
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
tab.explainPlan = undefined;
|
||
tab.explainError = built.reason;
|
||
return built;
|
||
}
|
||
explainSql = built.sql;
|
||
}
|
||
|
||
// Autotrace executes the SQL, so keep its stricter safety check.
|
||
if (databaseType === "dameng" && explainMode === "autotrace") {
|
||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||
const cleaned = sql
|
||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||
.replace(/--.*$/gm, " ")
|
||
.replace(/#.*$/gm, " ");
|
||
if (cleaned.split(";").some((stmt) => DANGER_RE.test(stmt))) {
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
return { ok: false as const, reason: "unsafe" as const };
|
||
}
|
||
}
|
||
try {
|
||
const mode = databaseType === "dameng" && explainMode === "autotrace" ? "autotrace" : "explain";
|
||
const planText = (await api.getExplainInfo(tab.connectionId, tab.database, tab.schema, sql, mode)) as string | undefined;
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
if (planText && planText.length > 0) {
|
||
current.explainPlan = databaseType === "oracle" ? parseOracleExplainText(planText) : parseDamengExplainText(planText);
|
||
current.explainSql = explainSql;
|
||
current.explainError = undefined;
|
||
} else {
|
||
current.explainPlan = undefined;
|
||
current.explainError = "No explain plan returned";
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.explainPlan = undefined;
|
||
// Backend rejections contain the real ORA/Agent diagnostic; only successful empty responses use the generic empty-plan message.
|
||
current.explainError = formatError(e);
|
||
}
|
||
} finally {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
}
|
||
}
|
||
return { ok: true as const, sql: explainSql };
|
||
}
|
||
|
||
if (databaseType === "mysql") {
|
||
let tableBuilt: BuildExplainSqlResult;
|
||
let jsonBuilt: BuildExplainSqlResult;
|
||
try {
|
||
[tableBuilt, jsonBuilt] = await Promise.all([buildExplainSql(databaseType, sql, "standard"), buildExplainSql(databaseType, sql, "json")]);
|
||
} catch (e: any) {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
current.explainError = String(e?.message || e);
|
||
}
|
||
return { ok: true as const, sql: "" };
|
||
}
|
||
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
|
||
return { ok: true as const, sql: jsonBuilt.ok ? jsonBuilt.sql : "" };
|
||
}
|
||
if (!tableBuilt.ok || !jsonBuilt.ok) {
|
||
const failed = !tableBuilt.ok ? tableBuilt : jsonBuilt;
|
||
const reason = !tableBuilt.ok ? tableBuilt.reason : !jsonBuilt.ok ? jsonBuilt.reason : "unsupported";
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
current.explainError = reason;
|
||
}
|
||
return failed;
|
||
}
|
||
|
||
let tableSql = tableBuilt.sql;
|
||
let jsonSupportedByServer: boolean | undefined;
|
||
tab.explainTableSql = tableSql;
|
||
tab.explainSql = undefined;
|
||
// Keep the two EXPLAIN statements on the same one-connection MySQL session.
|
||
const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
|
||
tab.explainClientSessionId = clientSessionId;
|
||
try {
|
||
let tableResult: QueryResult | undefined;
|
||
let tableError: unknown;
|
||
try {
|
||
tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, {
|
||
clientSessionId,
|
||
catalog: tab.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
});
|
||
} catch (error: unknown) {
|
||
const compatibility = mysqlExplainCompatibilityHint(error, tableSql);
|
||
jsonSupportedByServer = compatibility?.supportsJson;
|
||
if (compatibility?.fallbackSql && tabs.value.find((t) => t.id === id)?.explainExecutionId === executionId) {
|
||
tableSql = compatibility.fallbackSql;
|
||
tab.explainTableSql = tableSql;
|
||
try {
|
||
tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, {
|
||
clientSessionId,
|
||
catalog: tab.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
});
|
||
} catch (fallbackError: unknown) {
|
||
tableError = fallbackError;
|
||
}
|
||
} else {
|
||
tableError = error;
|
||
}
|
||
}
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
if (tableResult) {
|
||
current.explainTableResult = markQueryResultRowsRaw(tableResult);
|
||
current.explainTableError = undefined;
|
||
} else if (tableError !== undefined) {
|
||
current.explainTableResult = undefined;
|
||
current.explainTableError = formatError(tableError);
|
||
}
|
||
}
|
||
|
||
// A canceled or superseded standard request must not start a fallback or JSON request.
|
||
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
|
||
return { ok: true as const, sql: tableSql };
|
||
}
|
||
|
||
// ADB MySQL advertises its accepted formats in the first error; avoid a second known-invalid request.
|
||
if (jsonSupportedByServer === false) {
|
||
const latest = tabs.value.find((t) => t.id === id);
|
||
if (latest?.explainExecutionId === executionId) {
|
||
latest.explainPlan = undefined;
|
||
latest.explainError = latest.explainTableResult ? undefined : latest.explainTableError;
|
||
}
|
||
return { ok: true as const, sql: tableSql };
|
||
}
|
||
|
||
try {
|
||
const latest = tabs.value.find((t) => t.id === id);
|
||
if (latest?.explainExecutionId === executionId) latest.explainSql = jsonBuilt.sql;
|
||
const jsonResult = await api.executeQuery(tab.connectionId, tab.database, jsonBuilt.sql, tab.schema, executionId, {
|
||
clientSessionId,
|
||
catalog: tab.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
});
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.explainPlan = parseExplainResult("mysql", jsonResult);
|
||
current.explainError = undefined;
|
||
}
|
||
} catch (e: any) {
|
||
const latest = tabs.value.find((t) => t.id === id);
|
||
if (latest?.explainExecutionId === executionId) {
|
||
latest.explainPlan = undefined;
|
||
// Keep a usable tabular plan visible when the server explicitly rejects JSON.
|
||
const compatibility = mysqlExplainCompatibilityHint(e, jsonBuilt.sql);
|
||
latest.explainError = compatibility?.supportsJson === false && latest.explainTableResult ? undefined : formatError(e);
|
||
}
|
||
}
|
||
} finally {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
}
|
||
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
|
||
void closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
|
||
}
|
||
return { ok: true as const, sql: tab.explainSql ?? tableSql };
|
||
}
|
||
|
||
if (databaseType === "sqlserver") {
|
||
// SQL Server reuses the autotrace toggle to ask for the actual execution plan:
|
||
// STATISTICS XML runs the statement and adds runtime counters to the same
|
||
// ShowPlanXML document, while SHOWPLAN_XML only estimates.
|
||
const actualPlan = explainMode === "autotrace";
|
||
const planCaptureOn = actualPlan ? "SET STATISTICS XML ON;" : "SET SHOWPLAN_XML ON;";
|
||
const planCaptureOff = actualPlan ? "SET STATISTICS XML OFF;" : "SET SHOWPLAN_XML OFF;";
|
||
let built: BuildExplainSqlResult;
|
||
try {
|
||
built = actualPlan ? await buildExplainSql(databaseType, sql, "json", true) : await buildExplainSql(databaseType, sql);
|
||
} catch (e: any) {
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
tab.explainError = String(e?.message || e);
|
||
return { ok: true as const, sql: "" };
|
||
}
|
||
if (!built.ok) {
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
tab.explainError = built.reason;
|
||
return built;
|
||
}
|
||
|
||
tab.explainSql = built.sql;
|
||
const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
|
||
tab.explainClientSessionId = clientSessionId;
|
||
let planCaptureEnabled = false;
|
||
try {
|
||
await api.executeQuery(tab.connectionId, tab.database, planCaptureOn, tab.schema, executionId, {
|
||
clientSessionId,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
executionMode: "simple",
|
||
});
|
||
planCaptureEnabled = true;
|
||
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
|
||
return { ok: true as const, sql: built.sql };
|
||
}
|
||
|
||
const results = await api.executeMulti(tab.connectionId, tab.database, sql, tab.schema, executionId, {
|
||
clientSessionId,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
executionMode: "simple",
|
||
});
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
const outcome = sqlServerExplainResult(results);
|
||
if (outcome.error !== undefined) {
|
||
current.explainPlan = undefined;
|
||
current.explainError = outcome.error;
|
||
} else if (outcome.result) {
|
||
current.explainPlan = parseExplainResult("sqlserver", outcome.result);
|
||
current.explainError = undefined;
|
||
} else {
|
||
current.explainPlan = undefined;
|
||
current.explainError = t("explain.empty");
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.explainPlan = undefined;
|
||
current.explainError = String(e?.message || e);
|
||
}
|
||
} finally {
|
||
if (planCaptureEnabled) {
|
||
try {
|
||
await api.executeQuery(tab.connectionId, tab.database, planCaptureOff, tab.schema, undefined, {
|
||
clientSessionId,
|
||
timeoutSecs: queryTimeoutSecs > 0 ? Math.min(queryTimeoutSecs, 5) : 5,
|
||
executionMode: "simple",
|
||
});
|
||
} catch (error) {
|
||
console.warn("[DBX][sqlserver-explain:cleanup:error]", { tabId: tab.id, error });
|
||
}
|
||
}
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
}
|
||
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
|
||
await closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
|
||
}
|
||
return { ok: true as const, sql: built.sql };
|
||
}
|
||
|
||
const postgresAnalyze = databaseType === "postgres" && explainMode === "autotrace";
|
||
const built = postgresAnalyze ? await buildExplainSql(databaseType, sql, "json", true) : await buildExplainSql(databaseType, sql);
|
||
if (!built.ok) {
|
||
tab.explainPlan = undefined;
|
||
tab.explainError = built.reason;
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
return built;
|
||
}
|
||
|
||
tab.explainSql = built.sql;
|
||
const clientSessionId = postgresAnalyze ? `${tabClientSessionId(tab, "explain")}:${executionId}` : tabClientSessionId(tab, "explain");
|
||
if (postgresAnalyze) tab.explainClientSessionId = clientSessionId;
|
||
try {
|
||
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
|
||
clientSessionId,
|
||
catalog: tab.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
executionMode: postgresAnalyze ? "postgres_read_only_transaction" : undefined,
|
||
});
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.explainPlan = parseExplainResult(databaseType as ExplainPlanDatabaseType, result);
|
||
current.explainError = undefined;
|
||
}
|
||
} catch (e: any) {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.explainPlan = undefined;
|
||
current.explainError = String(e?.message || e);
|
||
}
|
||
} finally {
|
||
const current = tabs.value.find((t) => t.id === id);
|
||
if (current?.explainExecutionId === executionId) {
|
||
current.isExplaining = false;
|
||
current.explainExecutionId = undefined;
|
||
}
|
||
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
|
||
const closePromise = closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
|
||
if (postgresAnalyze) await closePromise;
|
||
else void closePromise;
|
||
}
|
||
return { ok: true as const, sql: built.sql };
|
||
}
|
||
|
||
async function cancelTabExecution(id: string) {
|
||
const tab = findExecutionTab(id);
|
||
if (!tab || !canCancelQueryExecution(tab)) return false;
|
||
|
||
const executionId = tab.executionId;
|
||
if (!executionId) return false;
|
||
const stopMonitor = redisMonitors.get(executionId);
|
||
if (stopMonitor) {
|
||
tab.isCancelling = true;
|
||
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
|
||
stopMonitor();
|
||
return true;
|
||
}
|
||
tab.isCancelling = true;
|
||
// 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止"
|
||
// (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义)
|
||
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
|
||
if (pendingResultRunPreparations.get(tab) === executionId) {
|
||
// No SQL was dispatched; invalidate the disk read without a backend cancel.
|
||
pendingResultRunPreparations.delete(tab);
|
||
tab.isExecuting = false;
|
||
tab.isCancelling = false;
|
||
tab.executionId = undefined;
|
||
tab.executingResultRunId = undefined;
|
||
tab.queryExecutionStartedAt = undefined;
|
||
return true;
|
||
}
|
||
const cancellationStartedAt = performance.now();
|
||
try {
|
||
const canceled = await withCancelQueryTimeout(api.cancelQuery(executionId));
|
||
if (canceled) {
|
||
clearAcknowledgedCancelIfStillRunning(id, executionId);
|
||
}
|
||
if (!canceled) {
|
||
const current = findExecutionTab(id);
|
||
if (current && current.executionId === executionId) {
|
||
finishBatchSqlExecution(current, executionId, false);
|
||
restorePendingResultRun(current, executionId);
|
||
current.isExecuting = false;
|
||
current.isCancelling = false;
|
||
current.executionId = undefined;
|
||
current.executingResultRunId = undefined;
|
||
current.queryExecutionStartedAt = undefined;
|
||
clearLiveBatchSqlExecution(current, executionId);
|
||
}
|
||
}
|
||
return canceled;
|
||
} catch (e: any) {
|
||
// Sync connection state if the error indicates a lost connection
|
||
if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e);
|
||
const current = findExecutionTab(id);
|
||
if (current && current.executionId === executionId) {
|
||
failBatchSqlExecution(current, executionId, e, false);
|
||
finishBatchSqlExecution(current, executionId, false);
|
||
if (restorePendingResultRun(current, executionId)) {
|
||
current.isExecuting = false;
|
||
current.isCancelling = false;
|
||
current.queryExecutionStartedAt = undefined;
|
||
current.executionId = undefined;
|
||
current.executingResultRunId = undefined;
|
||
} else {
|
||
// 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示,
|
||
// 估算值也会继续按旧的 results 计算
|
||
setErrorResult(id, e);
|
||
}
|
||
clearLiveBatchSqlExecution(current, executionId);
|
||
}
|
||
return false;
|
||
} finally {
|
||
recordQueryCancellationLatency(performance.now() - cancellationStartedAt);
|
||
}
|
||
}
|
||
|
||
async function cancelTabExplain(id: string) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.isExplaining || !tab.explainExecutionId) return false;
|
||
|
||
const executionId = tab.explainExecutionId;
|
||
// Invalidate locally before the remote cancellation call so no later stage can start.
|
||
tab.isExplaining = false;
|
||
tab.explainExecutionId = undefined;
|
||
try {
|
||
return await api.cancelQuery(executionId);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function setActiveResultIndex(id: string, index: number) {
|
||
const tab = findExecutionTab(id);
|
||
if (!tab?.results || index < 0 || index >= tab.results.length) return;
|
||
tab.activeResultIndex = index;
|
||
tab.result = tab.results[index];
|
||
tab.resultLocalSortOriginalRows = undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||
tab.resultSortColumn = undefined;
|
||
tab.resultSortColumnIndex = undefined;
|
||
tab.resultSortDirection = undefined;
|
||
tab.resultSortMode = undefined;
|
||
tab.resultSortedSql = undefined;
|
||
tab.resultTotalRowCount = undefined;
|
||
tab.resultTotalRowCountLoading = false;
|
||
// results 数组未变,估算值与当前激活的 result 无关,可直接复用
|
||
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
|
||
tab.queryAnalysis = undefined;
|
||
tab.querySourceColumns = undefined;
|
||
tab.queryWriteTargets = undefined;
|
||
tab.resultColumnComments = undefined;
|
||
tab.queryDisplaySourceColumns = undefined;
|
||
tab.queryEditabilityReason = undefined;
|
||
tab.mongoEditTarget = undefined;
|
||
syncActiveResultRunFromDisplayed(tab);
|
||
const sourceStatement = tab.result?.sourceStatement;
|
||
if (tab.mode === "query" && sourceStatement && splitMongoCommandRanges(sourceStatement).length === 0) {
|
||
const metadataStartedAt = performance.now();
|
||
const connection = useConnectionStore().getConfig(tab.connectionId);
|
||
const executionDatabase = dataTabExecutionDatabase(connection, tab.database, tab.catalog);
|
||
analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection));
|
||
}
|
||
}
|
||
|
||
function notifyConnectionMayBeLost() {
|
||
const stuck = tabs.value.filter((t) => t.isExecuting);
|
||
if (stuck.length > 0) {
|
||
const connStore = useConnectionStore();
|
||
stuck.forEach((tab) => {
|
||
const error = new Error(t("editor.connectionMayBeLost"));
|
||
setErrorResult(tab.id, error);
|
||
connStore.markConnectionLost(tab.connectionId, error);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function trimResultCache() {
|
||
const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results));
|
||
const evictionIds = new Set(
|
||
selectInactiveResultEvictions(
|
||
inactive.map((tab) => ({
|
||
id: tab.id,
|
||
estimatedBytes: tab.resultEstimatedBytes ?? estimateQueryResultsBytes(tab.result, tab.results),
|
||
accessedAt: tab.resultAccessedAt ?? 0,
|
||
})),
|
||
MAX_CACHED_RESULT_BYTES,
|
||
MAX_CACHED_RESULTS,
|
||
),
|
||
);
|
||
const toEvict = inactive.filter((tab) => evictionIds.has(tab.id));
|
||
if (toEvict.length > 0) {
|
||
await Promise.all(toEvict.map((t) => evictCachedResult(t)));
|
||
}
|
||
}
|
||
|
||
function scheduleResultCacheTrim() {
|
||
resultCacheTrimRequested = true;
|
||
if (resultCacheTrimScheduled || resultCacheTrimRunning) return;
|
||
resultCacheTrimScheduled = true;
|
||
|
||
const run = () => {
|
||
resultCacheTrimScheduled = false;
|
||
void runRequestedResultCacheTrim();
|
||
};
|
||
|
||
// Eviction serializes large result payloads; schedule it after the result
|
||
// assignment so the grid can paint before cache maintenance starts.
|
||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||
window.requestIdleCallback(run, { timeout: 1500 });
|
||
} else {
|
||
setTimeout(run, 0);
|
||
}
|
||
}
|
||
|
||
async function runRequestedResultCacheTrim() {
|
||
if (resultCacheTrimRunning) return;
|
||
resultCacheTrimRunning = true;
|
||
try {
|
||
while (resultCacheTrimRequested) {
|
||
resultCacheTrimRequested = false;
|
||
await trimResultCache();
|
||
}
|
||
} finally {
|
||
resultCacheTrimRunning = false;
|
||
if (resultCacheTrimRequested) scheduleResultCacheTrim();
|
||
}
|
||
}
|
||
|
||
function rememberActiveTab(id: string | null) {
|
||
if (!id || !tabs.value.some((tab) => tab.id === id)) return;
|
||
activeTabHistory.value = [...activeTabHistory.value.filter((tabId) => tabId !== id), id];
|
||
}
|
||
|
||
watch(
|
||
activeTabId,
|
||
(id) => {
|
||
rememberActiveTab(id);
|
||
touchResult(
|
||
tabs.value.find((tab) => tab.id === id),
|
||
Date.now(),
|
||
{ reuseEstimatedBytes: true },
|
||
);
|
||
},
|
||
{ flush: "sync" },
|
||
);
|
||
|
||
watch(
|
||
activeTabId,
|
||
(id) => {
|
||
if (tabs.value.length > 0 && (!id || !tabs.value.some((tab) => tab.id === id))) {
|
||
activeTabId.value = tabs.value[0].id;
|
||
}
|
||
},
|
||
{ flush: "sync" },
|
||
);
|
||
|
||
function restoreCachedResultPayload(tab: QueryTab, snapshot: Awaited<ReturnType<typeof readTabResultSnapshot>>) {
|
||
if (!snapshot) return false;
|
||
const results = snapshot.results ? markQueryResultsRowsRaw(snapshot.results) : undefined;
|
||
const activeIndex = snapshot.activeResultIndex ?? 0;
|
||
tab.results = results;
|
||
tab.activeResultIndex = snapshot.activeResultIndex;
|
||
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
|
||
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
|
||
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
|
||
tab.resultLocalSortOriginalLargeValueCells = snapshot.resultLocalSortOriginalLargeValueCells?.map((cell) => ({ ...cell }));
|
||
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = snapshot.resultLocalSortOriginalMongoCopyDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoCopyDocuments) : undefined;
|
||
// 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象,
|
||
// 置空让 projectResultRun 按需重算
|
||
tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns;
|
||
tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId;
|
||
// Disk restore is the same logical result: keep the captured view identity.
|
||
tab.resultViewGeneration = snapshot.resultViewGeneration ?? tab.resultViewGeneration;
|
||
if (!tab.result && !tab.results && !tab.resultRuns) return false;
|
||
|
||
tab.queryAnalysis = snapshot.queryAnalysis;
|
||
tab.querySourceColumns = snapshot.querySourceColumns;
|
||
tab.queryWriteTargets = snapshot.queryWriteTargets;
|
||
tab.resultColumnComments = snapshot.resultColumnComments;
|
||
tab.queryDisplaySourceColumns = snapshot.queryDisplaySourceColumns;
|
||
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
|
||
tab.mongoEditTarget = snapshot.mongoEditTarget;
|
||
// Data tab 的结果快照可能早于最近一次结构变更。已持有真实元数据时,
|
||
// 不允许旧快照回滚列名或主键;若恢复后仍没有真实列,重新挂起编辑门控。
|
||
if (tab.mode === "data" && tab.tableMeta?.columns.length) {
|
||
// 保留当前真实元数据
|
||
} else {
|
||
tab.tableMeta = snapshot.tableMeta;
|
||
}
|
||
if (tab.mode === "data" && !tab.tableMeta?.columns.length) {
|
||
tab.tableMetaPending = true;
|
||
}
|
||
tab.resultPageSql = snapshot.resultPageSql;
|
||
tab.resultPageLimit = snapshot.resultPageLimit;
|
||
tab.resultPageOffset = snapshot.resultPageOffset;
|
||
tab.resultCountSql = snapshot.resultCountSql;
|
||
tab.resultTotalRowCount = snapshot.resultTotalRowCount;
|
||
tab.resultTotalRowCountLoading = false;
|
||
tab.resultSessionId = undefined;
|
||
tab.resultClientSessionId = undefined;
|
||
tab.resultEvicted = undefined;
|
||
tab.resultCacheState = "memory";
|
||
touchResult(tab);
|
||
return true;
|
||
}
|
||
|
||
async function hydrateResultRunsForArchive(tab: QueryTab, snapshot: NonNullable<ReturnType<typeof buildTabResultSnapshot>>) {
|
||
if (!snapshot.resultRuns?.length) return snapshot;
|
||
const resultRuns = await Promise.all(
|
||
snapshot.resultRuns.map(async (run) => {
|
||
if (resultRunHasPayload(run)) return run;
|
||
const cacheKey = run.resultCacheKey ?? tab.resultRuns?.find((item) => item.id === run.id)?.resultCacheKey;
|
||
if (!cacheKey) return run;
|
||
const cached = await readTabResultSnapshot(cacheKey);
|
||
return cached?.resultRuns?.find((item) => item.id === run.id) ?? run;
|
||
}),
|
||
);
|
||
return { ...snapshot, resultRuns };
|
||
}
|
||
|
||
async function resultArchiveSnapshotForTab(tab: QueryTab) {
|
||
let snapshot = buildTabResultSnapshot(tab);
|
||
if (tab.resultCacheKey && (!snapshot || tab.resultEvicted || !resultSnapshotHasPayload(snapshot))) {
|
||
snapshot = (await readTabResultSnapshot(tab.resultCacheKey)) ?? snapshot;
|
||
}
|
||
if (snapshot) snapshot = await hydrateResultRunsForArchive(tab, snapshot);
|
||
return snapshot && resultSnapshotHasPayload(snapshot) ? snapshot : undefined;
|
||
}
|
||
|
||
async function exportResultArchive(id: string): Promise<Uint8Array | undefined> {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || tab.mode !== "query") return undefined;
|
||
const snapshot = await resultArchiveSnapshotForTab(tab);
|
||
if (!snapshot) return undefined;
|
||
return encodeQueryResultArchive(tab, snapshot);
|
||
}
|
||
|
||
function openResultArchiveTab(archive: DecodedQueryResultArchive): string | undefined {
|
||
const id = uuid();
|
||
const title = archive.tab.title.trim() || t("tabs.importedResultArchive");
|
||
const dbType = useConnectionStore().getConfig(archive.tab.connectionId)?.db_type;
|
||
const tab: QueryTab = {
|
||
id,
|
||
title,
|
||
customTitle: true,
|
||
connectionId: archive.tab.connectionId,
|
||
database: archive.tab.database,
|
||
schema: archive.tab.schema,
|
||
sql: archive.tab.sql,
|
||
originalSql: archive.tab.sql,
|
||
lastExecutedSql: archive.tab.lastExecutedSql,
|
||
resultBaseSql: archive.tab.resultBaseSql,
|
||
resultSortedSql: archive.tab.resultSortedSql,
|
||
isExecuting: false,
|
||
isCancelling: false,
|
||
isExplaining: false,
|
||
mode: "query",
|
||
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
|
||
};
|
||
if (!restoreCachedResultPayload(tab, archive.snapshot)) return undefined;
|
||
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId) ?? tab.resultRuns?.[0];
|
||
if (activeRun) projectResultRun(tab, activeRun);
|
||
return registerOpenTab(tab);
|
||
}
|
||
|
||
async function importResultArchive(bytes: Uint8Array | ArrayBuffer): Promise<string | undefined> {
|
||
const archive = await decodeQueryResultArchive(bytes);
|
||
if (!archive) return undefined;
|
||
return openResultArchiveTab(archive);
|
||
}
|
||
|
||
async function reloadEvictedTab(id: string, { reexecuteOnMissing = false }: { reexecuteOnMissing?: boolean } = {}) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab || !tab.resultEvicted) return;
|
||
if (tab.resultCacheKey) {
|
||
const restored = restoreCachedResultPayload(tab, await readTabResultSnapshot(tab.resultCacheKey));
|
||
if (restored) return;
|
||
tab.resultCacheState = "missing";
|
||
if (!reexecuteOnMissing) return;
|
||
}
|
||
tab.resultEvicted = false;
|
||
const sql = tab.lastExecutedSql ?? tab.sql;
|
||
if (!sql?.trim()) return;
|
||
await executeTabSql(tab.id, sql, {
|
||
resultBaseSql: tab.resultBaseSql ?? sql,
|
||
resultSortedSql: tab.resultSortedSql,
|
||
pagination:
|
||
tab.mode === "data"
|
||
? {
|
||
limit: tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize),
|
||
offset: tab.resultPageOffset ?? 0,
|
||
}
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
async function fetchTabResultForExport(id: string, onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.result) return undefined;
|
||
|
||
if (tab.mode === "data") {
|
||
const connStore = useConnectionStore();
|
||
await connStore.ensureConnected(tab.connectionId);
|
||
const conn = connStore.getConfig(tab.connectionId);
|
||
const tableMeta = tableMetaForDataTab(tab);
|
||
if (!tableMeta?.tableName) return tab.result;
|
||
|
||
// Use the already-computed total row count as a progress estimate so the
|
||
// export dialog shows a moving bar instead of a stuck 0 while paginating.
|
||
const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null;
|
||
const pageLimit = TABLE_DATA_EXPORT_PAGE_SIZE;
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId);
|
||
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
|
||
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
|
||
const orderBy = tab.orderByInput?.trim() || sortOrder;
|
||
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
|
||
const executionDatabase = dataTabExecutionDatabase(conn, tab.database, tableMeta.catalog);
|
||
const rows: QueryResult["rows"] = [];
|
||
let columns: string[] = [];
|
||
let executionTimeMs = 0;
|
||
let offset = 0;
|
||
const clientSessionId = tabClientSessionId(tab, "export");
|
||
const exportExecutionId = uuid();
|
||
const useAgentCursor = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy";
|
||
let sessionId: string | undefined;
|
||
|
||
try {
|
||
while (true) {
|
||
const sql = await api.buildTableSelectSql({
|
||
databaseType: effectiveDbType,
|
||
driverProfile: conn?.driver_profile,
|
||
identifierQuote,
|
||
database: tableMeta.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
tableType: tableMeta.tableType,
|
||
catalog: tableMeta.catalog,
|
||
columns: tableMeta.columns.map((column) => column.name),
|
||
primaryKeys,
|
||
whereInput: tab.whereInput,
|
||
orderBy,
|
||
limit: pageLimit,
|
||
offset,
|
||
});
|
||
const results = await api.executeMulti(
|
||
tab.connectionId,
|
||
executionDatabase,
|
||
sql,
|
||
undefined,
|
||
exportExecutionId,
|
||
useAgentCursor
|
||
? {
|
||
maxRows: 2_147_483_647,
|
||
fetchSize: pageLimit,
|
||
pageSize: pageLimit,
|
||
resultSessionId: sessionId,
|
||
clientSessionId,
|
||
catalog: tableMeta.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
}
|
||
: {
|
||
maxRows: pageLimit,
|
||
fetchSize: pageLimit,
|
||
clientSessionId,
|
||
catalog: tableMeta.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
},
|
||
);
|
||
const result = results[0];
|
||
if (!result) break;
|
||
if (columns.length === 0) columns = result.columns;
|
||
rows.push(...result.rows);
|
||
executionTimeMs += result.execution_time_ms ?? 0;
|
||
onProgress?.({ rowsExported: rows.length, totalRows });
|
||
sessionId = result.session_id ?? undefined;
|
||
const shouldFetchNextPage = useAgentCursor ? result.has_more === true : result.rows.length >= pageLimit;
|
||
if (!shouldFetchNextPage) break;
|
||
offset += result.rows.length;
|
||
}
|
||
} finally {
|
||
if (sessionId) void api.closeQuerySession(tab.connectionId, executionDatabase, sessionId, clientSessionId, tableMeta.catalog);
|
||
void closeClientSessionId(tab.connectionId, executionDatabase, clientSessionId, tableMeta.catalog, { tabId: tab.id });
|
||
}
|
||
|
||
return {
|
||
columns: columns.length ? columns : tab.result.columns,
|
||
rows,
|
||
affected_rows: 0,
|
||
execution_time_ms: executionTimeMs,
|
||
truncated: false,
|
||
has_more: false,
|
||
};
|
||
}
|
||
|
||
if (tab.mode !== "query") return tab.result;
|
||
|
||
const sql = queryResultExecutionSql(tab);
|
||
if (!sql.trim()) return tab.result;
|
||
|
||
const location = queryResultExecutionLocation(tab);
|
||
const connStore = useConnectionStore();
|
||
await connStore.ensureConnected(location.connectionId);
|
||
const conn = connStore.getConfig(location.connectionId);
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
const executionDatabase = dataTabExecutionDatabase(conn, location.database, location.catalog);
|
||
// main 引入全局查询超时:queryTimeoutSecsForConnection 现需传入全局默认值;
|
||
// settingsStore 取 defineStore 顶层声明的实例(本函数无局部覆盖)。
|
||
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
|
||
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
|
||
const queryBaseSql = queryResultBaseSql(tab);
|
||
const exportSettings = useSettingsStore().editorSettings;
|
||
const exportRowLimit = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : Number.POSITIVE_INFINITY;
|
||
|
||
if (effectiveDbType === "mongodb") {
|
||
let mongoCommand;
|
||
try {
|
||
mongoCommand = await api.mongoParseShellCommand(sql);
|
||
} catch {
|
||
throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||
}
|
||
if (mongoCommand.kind !== "find") throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||
|
||
const pageLimit = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
|
||
const documents: unknown[] = [];
|
||
let copyDocuments: unknown[] | undefined = [];
|
||
let pageOffset = 0;
|
||
let totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
|
||
const exportStartedAt = performance.now();
|
||
const exportExecutionId = uuid();
|
||
|
||
while (documents.length < exportRowLimit) {
|
||
const remaining = exportRowLimit - documents.length;
|
||
const plan = planMongoFindPagination(sql, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
|
||
if (!plan) throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||
if (plan.requestLimit === 0) break;
|
||
|
||
const result = await api.mongoFindDocuments(location.connectionId, location.database, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
|
||
const pageDocuments = result.documents.slice(0, plan.requestLimit);
|
||
documents.push(...pageDocuments);
|
||
|
||
if (copyDocuments) {
|
||
if (result.extended_documents?.length === result.documents.length) {
|
||
copyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length));
|
||
} else {
|
||
copyDocuments = undefined;
|
||
}
|
||
}
|
||
|
||
if (result.total_is_exact !== false) {
|
||
totalRows = Math.min(mongoFindLogicalTotal(result.total, plan), exportRowLimit);
|
||
}
|
||
onProgress?.({ rowsExported: documents.length, totalRows });
|
||
|
||
pageOffset += pageDocuments.length;
|
||
const reachedLogicalLimit = plan.logicalLimit !== undefined && pageOffset >= plan.logicalLimit;
|
||
const reachedExactTotal = result.total_is_exact !== false && pageOffset >= mongoFindLogicalTotal(result.total, plan);
|
||
if (pageDocuments.length === 0 || pageDocuments.length < plan.requestLimit || reachedLogicalLimit || reachedExactTotal) break;
|
||
}
|
||
|
||
const result = mongoDocumentsToQueryResult(documents, performance.now() - exportStartedAt, totalRows ?? documents.length, copyDocuments, totalRows !== null);
|
||
if (result.columns.length === 0) {
|
||
result.columns = tab.result.columns;
|
||
result.column_types = tab.result.column_types;
|
||
}
|
||
result.affected_rows = documents.length;
|
||
result.truncated = false;
|
||
result.has_more = false;
|
||
return result;
|
||
}
|
||
|
||
const agentExportMaxRows = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : 2_147_483_647;
|
||
// Use the already-computed total row count as a progress estimate so the
|
||
// export dialog shows a moving bar instead of a stuck 0 while paginating.
|
||
const totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
|
||
const pageLimit = Math.max(tab.resultPageLimit ?? 0, TABLE_DATA_EXPORT_PAGE_SIZE);
|
||
const rows: QueryResult["rows"] = [];
|
||
let columns: string[] = [];
|
||
let executionTimeMs = 0;
|
||
let offset = 0;
|
||
let sessionId: string | undefined;
|
||
const clientSessionId = tabClientSessionId(tab, "export");
|
||
const exportExecutionId = uuid();
|
||
|
||
try {
|
||
while (rows.length < exportRowLimit) {
|
||
const remaining = exportRowLimit - rows.length;
|
||
const effectivePageLimit = Math.min(pageLimit, remaining);
|
||
const plan = await api.prepareQueryPaginationExecutionPlan({
|
||
sql,
|
||
queryBaseSql,
|
||
databaseType: effectiveDbType,
|
||
pagination: { limit: effectivePageLimit, offset, sessionId },
|
||
useAgentCursor,
|
||
firstPageUsesActualSql: true,
|
||
});
|
||
if (typeof plan.pageLimit !== "number" || typeof plan.pageOffset !== "number") return tab.result;
|
||
const executionOptions = plan.useAgentResultSession
|
||
? {
|
||
maxRows: agentExportMaxRows,
|
||
fetchSize: plan.pageLimit,
|
||
pageSize: plan.pageLimit,
|
||
resultSessionId: sessionId,
|
||
clientSessionId,
|
||
catalog: location.catalog,
|
||
timeoutSecs: queryTimeoutSecs,
|
||
}
|
||
: { maxRows: plan.pageLimit, fetchSize: plan.pageLimit, clientSessionId, catalog: location.catalog, timeoutSecs: queryTimeoutSecs };
|
||
const results = await api.executeMulti(location.connectionId, executionDatabase, plan.sqlToExecute, location.schema, exportExecutionId, executionOptions);
|
||
const result = results[0];
|
||
if (!result) break;
|
||
if (columns.length === 0) columns = result.columns;
|
||
rows.push(...result.rows);
|
||
executionTimeMs += result.execution_time_ms ?? 0;
|
||
onProgress?.({ rowsExported: rows.length, totalRows });
|
||
sessionId = result.session_id ?? undefined;
|
||
const shouldFetchNextPage = plan.useAgentResultSession ? result.has_more === true : result.rows.length >= plan.pageLimit;
|
||
if (!shouldFetchNextPage || rows.length >= exportRowLimit) break;
|
||
offset += result.rows.length;
|
||
}
|
||
} finally {
|
||
if (sessionId) void api.closeQuerySession(location.connectionId, executionDatabase, sessionId, clientSessionId, location.catalog);
|
||
void closeClientSessionId(location.connectionId, executionDatabase, clientSessionId, location.catalog, { tabId: tab.id });
|
||
}
|
||
|
||
return {
|
||
columns: columns.length ? columns : tab.result.columns,
|
||
rows,
|
||
affected_rows: 0,
|
||
execution_time_ms: executionTimeMs,
|
||
truncated: false,
|
||
has_more: false,
|
||
};
|
||
}
|
||
|
||
async function buildQueryResultExportRequest(id: string, options: BuildQueryResultExportRequestOptions) {
|
||
const tab = tabs.value.find((t) => t.id === id);
|
||
if (!tab?.result || tab.mode !== "query") return undefined;
|
||
|
||
const sql = queryResultExecutionSql(tab);
|
||
if (!sql.trim()) return undefined;
|
||
|
||
const location = queryResultExecutionLocation(tab);
|
||
const connStore = useConnectionStore();
|
||
await connStore.ensureConnected(location.connectionId);
|
||
const conn = connStore.getConfig(location.connectionId);
|
||
const settings = useSettingsStore().editorSettings;
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
if (!effectiveDbType) return undefined;
|
||
if (effectiveDbType === "mongodb") return undefined;
|
||
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
|
||
const queryBaseSql = queryResultBaseSql(tab);
|
||
const resultStatementIndex = tab.result.statement_index;
|
||
const batchSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
|
||
const batchStatements = effectiveDbType === "postgres" && tab.result.truncated === true && Number.isInteger(resultStatementIndex) && resultStatementIndex! > 0 ? splitSqlStatementRanges(batchSql, effectiveDbType) : [];
|
||
const setupSql = batchStatements[resultStatementIndex!]?.sql === tab.result.sourceStatement ? batchStatements.slice(0, resultStatementIndex).map((statement) => statement.sql) : undefined;
|
||
const rowLimit = settings.exportRowLimitEnabled ? settings.exportRowLimit : null;
|
||
const totalRows = typeof tab.resultTotalRowCount === "number" ? (rowLimit === null ? tab.resultTotalRowCount : Math.min(tab.resultTotalRowCount, rowLimit)) : null;
|
||
const clientSessionId = `${tabClientSessionId(tab, "export")}:${options.exportId}`;
|
||
|
||
return {
|
||
exportId: options.exportId,
|
||
connectionId: location.connectionId,
|
||
database: dataTabExecutionDatabase(conn, location.database, location.catalog),
|
||
schema: location.schema,
|
||
catalog: location.catalog,
|
||
sql,
|
||
queryBaseSql,
|
||
setupSql,
|
||
databaseType: effectiveDbType,
|
||
useAgentCursor,
|
||
filePath: options.filePath,
|
||
format: options.format,
|
||
...(options.format === "sql" && options.insertMode ? { insertMode: options.insertMode } : {}),
|
||
includeSqlSheet: options.format === "xlsx" && options.includeSqlSheet === true,
|
||
pageSize: settings.exportBatchSize,
|
||
rowLimit,
|
||
totalRows,
|
||
timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs),
|
||
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
|
||
csvQuoteMode: settings.csvQuoteMode,
|
||
clientSessionId,
|
||
executionId: uuid(),
|
||
exportTableName: options.exportTableName,
|
||
exportColumnTypes: options.exportColumnTypes,
|
||
numericColumnRightAlign: settings.numericColumnRightAlign,
|
||
identifierQuote: connStore.connectionIdentifierQuote(location.connectionId),
|
||
};
|
||
}
|
||
|
||
async function exportQuerySqlDirect(id: string, sql: string, format: "csv" | "xlsx" | "txt", filePath: string, columnComments?: (string | null)[]) {
|
||
const tab = tabs.value.find((item) => item.id === id);
|
||
if (!tab || tab.mode !== "query" || !sql.trim()) return;
|
||
|
||
const connStore = useConnectionStore();
|
||
await connStore.ensureConnected(tab.connectionId);
|
||
const conn = connStore.getConfig(tab.connectionId);
|
||
const settings = useSettingsStore().editorSettings;
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
|
||
if (!effectiveDbType) return;
|
||
|
||
const exportId = uuid();
|
||
const request: api.QueryResultExportRequest = {
|
||
exportId,
|
||
connectionId: tab.connectionId,
|
||
database: tab.database,
|
||
schema: tab.schema,
|
||
sql,
|
||
queryBaseSql: sql,
|
||
databaseType: effectiveDbType,
|
||
useAgentCursor: usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile),
|
||
filePath,
|
||
format,
|
||
pageSize: settings.exportBatchSize,
|
||
rowLimit: settings.exportRowLimitEnabled ? settings.exportRowLimit : null,
|
||
totalRows: null,
|
||
timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs),
|
||
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
|
||
csvQuoteMode: settings.csvQuoteMode,
|
||
clientSessionId: `${tabClientSessionId(tab, "export")}:${exportId}`,
|
||
executionId: uuid(),
|
||
numericColumnRightAlign: settings.numericColumnRightAlign,
|
||
columnComments,
|
||
identifierQuote: connStore.connectionIdentifierQuote(tab.connectionId),
|
||
};
|
||
|
||
const tracker = useExportTracker();
|
||
tracker.addTask("Query Result", format, filePath, request.exportId);
|
||
tracker.registerTaskCancelHandler(request.exportId, () => api.cancelQueryResultExport(request.exportId, request.executionId));
|
||
|
||
void (async () => {
|
||
try {
|
||
await api.startQueryResultExport(request, (progress) => tracker.updateTableExportTask(request.exportId, progress));
|
||
} catch (error: any) {
|
||
const task = tracker.tasks.value.find((item) => item.exportId === request.exportId);
|
||
if (task) {
|
||
task.status = "Error";
|
||
task.errorMessage = error?.message || String(error);
|
||
}
|
||
} finally {
|
||
tracker.unregisterTaskCancelHandler(request.exportId);
|
||
}
|
||
})();
|
||
}
|
||
|
||
return {
|
||
tabs,
|
||
activeTabId,
|
||
groups,
|
||
focusedGroupId,
|
||
orientation,
|
||
sizes,
|
||
isOpenTabsLoaded,
|
||
recentTabIds,
|
||
initOpenTabs,
|
||
prepareDetachedTab,
|
||
adoptDetachedTab,
|
||
removeTabAfterDetachedReady,
|
||
flushDetachedTabPersistence,
|
||
showCloseConfirm,
|
||
pendingCloseTabId,
|
||
closeConfirmContext,
|
||
closeConfirmDirtyTabIds,
|
||
hasDirtyTabs,
|
||
requiresAppCloseDraftPersist,
|
||
isConfirmingAppClose,
|
||
createTab,
|
||
openObjectSourceTab,
|
||
openObjectSourceTabPending,
|
||
retryObjectSourceTab,
|
||
showExecutedQueryResults,
|
||
focusGroup,
|
||
activateTab,
|
||
activateTabInGroup,
|
||
splitTabRight,
|
||
splitTabDown,
|
||
setOrientation,
|
||
moveTabToGroup,
|
||
unsplitTab,
|
||
switchTab,
|
||
closeTab,
|
||
forceClosePendingTab,
|
||
forceCloseAllPendingTabs,
|
||
cancelClosePendingTab,
|
||
flushPendingPersist,
|
||
saveAndClosePendingTab,
|
||
suspendCloseConfirm,
|
||
resumeCloseConfirm,
|
||
completePendingCloseAfterSaveAll,
|
||
isTabDirty,
|
||
markTabClean,
|
||
applyExternalSqlFileSnapshot,
|
||
markExternalSqlFileSaved,
|
||
updateExternalSqlFileVersion,
|
||
ignoreExternalSqlFileVersion,
|
||
acknowledgeExternalSqlFileMissing,
|
||
relocateExternalSqlFilePath,
|
||
markExternalSqlFileMissingForPath,
|
||
discardTabChanges,
|
||
requestAppCloseConfirmation,
|
||
closeOtherTabs,
|
||
closeOtherTabsInGroup,
|
||
closeAllTabsInGroup,
|
||
closeTabsByIds,
|
||
closeRightTabs,
|
||
closeOtherRegularTabs,
|
||
closeRegularTabs,
|
||
closeOtherFixedTabs,
|
||
closeFixedTabs,
|
||
closeAllTabs,
|
||
duplicateTab,
|
||
closeConnectionTabs,
|
||
closeDatabaseTabs,
|
||
closeDroppedTableObjectTabs,
|
||
refreshDataTab,
|
||
refreshDataTabsForTable,
|
||
releaseConnectionTabs,
|
||
releaseDatabaseTabs,
|
||
staleConnectionDataTabMetadata,
|
||
isDatabaseOpen,
|
||
openDatabaseKeys,
|
||
rollbackConnectionTransactions,
|
||
rollbackDatabaseTransactions,
|
||
updateSql,
|
||
updateDataGridLocalColumnFilters,
|
||
updateDataGridHiddenColumnKeys,
|
||
updateEditorViewport,
|
||
updateTabUiState,
|
||
updateTabPageUiState,
|
||
updateTabPageResult,
|
||
updateEditorSelection,
|
||
flushEditorState,
|
||
updateObjectBrowserViewport,
|
||
updateObjectBrowserSearch,
|
||
updateObjectBrowserFilter,
|
||
updateNacosConfigEditorViewport,
|
||
setAutoCommit,
|
||
markManualTransactionDirty,
|
||
commitTransaction,
|
||
rollbackTransaction,
|
||
renameTab,
|
||
openDatabaseBrowser,
|
||
openDriverProfileWorkspace,
|
||
openObjectBrowser,
|
||
openMongoGridFs,
|
||
openMongoBucket,
|
||
openUserAdmin,
|
||
openProcessList,
|
||
openSqlServerActivityTrace,
|
||
openMysqlDashboard,
|
||
openPostgresDashboard,
|
||
openXuguDashboard,
|
||
openNacosDashboard,
|
||
openDamengUsers,
|
||
openDamengRoles,
|
||
openDamengJobAdmin,
|
||
openMqAdmin,
|
||
openMqttAdmin,
|
||
openNacosAdmin,
|
||
clearNacosNavigationTarget,
|
||
openPluginWorkbench,
|
||
openPluginFilesystem,
|
||
openPluginConnection,
|
||
openTableStructure,
|
||
linkSavedSql,
|
||
linkExternalSqlPath,
|
||
openExternalSqlFile,
|
||
openSavedSql,
|
||
hydrateSavedSqlTabs,
|
||
togglePinnedTab,
|
||
reorderTab,
|
||
updateDatabase,
|
||
updateCatalog,
|
||
updateSchema,
|
||
updateConnection,
|
||
setTableMeta,
|
||
clearInvalidDataTabSort,
|
||
invalidateTableStructure,
|
||
tableStructureRefreshVersion,
|
||
setObjectSource,
|
||
setExecuting,
|
||
setExecutingWithId,
|
||
setErrorResult,
|
||
invalidateResultEstimateForPayload,
|
||
toggleResultAutoSave,
|
||
setActiveResultRun,
|
||
toggleResultRunPinned,
|
||
unpinAllResultRuns,
|
||
closeOtherResultRuns,
|
||
closeResultRunsToLeft,
|
||
closeResultRunsToRight,
|
||
removeResultRun,
|
||
closeQueryResult,
|
||
clearQueryResults,
|
||
setActiveResultIndex,
|
||
executeCurrentTab,
|
||
executeCurrentSql,
|
||
executeTabSql,
|
||
dismissBatchSqlRecovery,
|
||
resumeBatchSql,
|
||
activeResultExecutionTarget,
|
||
getExecutionTab,
|
||
createMultiDbExecutionWorker,
|
||
captureMultiDbExecutionWorkerResult,
|
||
removeMultiDbExecutionWorker,
|
||
cancelMultiDbExecutionScope,
|
||
sortTabResultLocally,
|
||
explainTabSql,
|
||
cancelTabExecution,
|
||
cancelTabExplain,
|
||
reloadEvictedTab,
|
||
exportResultArchive,
|
||
importResultArchive,
|
||
fetchTabResultForExport,
|
||
countTabResultRows,
|
||
buildQueryResultExportRequest,
|
||
exportQuerySqlDirect,
|
||
getResourceLifecycleDiagnostics: () => resourceLifecycleDiagnostics(tabs.value),
|
||
notifyConnectionMayBeLost,
|
||
};
|
||
});
|