feat(editor): show complete table DDL on hover
This commit is contained in:
1 parent
b7e3b2ee57
commit
2cbbaaef4c
6 files changed
+1166
-21
No files matched your search
@@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, sha
|
|||||||
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||||
import { Transaction } from "@codemirror/state";
|
import { Transaction, StateEffect } from "@codemirror/state";
|
||||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||||
import { search as cmSearch } from "@codemirror/search";
|
import { search as cmSearch } from "@codemirror/search";
|
||||||
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||||
@@ -46,6 +46,7 @@ import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMon
|
|||||||
import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
|
import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
|
||||||
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
|
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
|
||||||
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||||
|
import { buildHoverTableSql, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
|
||||||
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
|
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
|
||||||
import {
|
import {
|
||||||
DBX_TABLE_REFERENCE_MIME,
|
DBX_TABLE_REFERENCE_MIME,
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
type QueryEditorTableReferenceDropDetail,
|
type QueryEditorTableReferenceDropDetail,
|
||||||
type QueryEditorTableReferencePayload,
|
type QueryEditorTableReferencePayload,
|
||||||
} from "@/lib/editor/queryEditorTableDrop";
|
} from "@/lib/editor/queryEditorTableDrop";
|
||||||
|
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||||
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
||||||
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
||||||
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
|
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
|
||||||
@@ -76,6 +78,7 @@ import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
|||||||
import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||||
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||||
|
import { loadTableMetadata, type TableMetadataLoadResult } from "@/lib/metadata/tableMetadataCache";
|
||||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||||
@@ -94,7 +97,7 @@ import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
|
|||||||
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
|
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
|
||||||
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion";
|
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion";
|
||||||
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||||
import type { CompletionAssistantObjectKind, DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
@@ -150,6 +153,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const editorRef = ref<HTMLDivElement>();
|
const editorRef = ref<HTMLDivElement>();
|
||||||
const view = shallowRef<EditorViewType | null>(null);
|
const view = shallowRef<EditorViewType | null>(null);
|
||||||
|
const contextMenuOpen = ref(false);
|
||||||
|
let contextMenuPointerCleanup: (() => void) | null = null;
|
||||||
let viewportEmitFrame: number | null = null;
|
let viewportEmitFrame: number | null = null;
|
||||||
let viewportRestoreFrame: number | null = null;
|
let viewportRestoreFrame: number | null = null;
|
||||||
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||||
@@ -306,6 +311,7 @@ interface EditorGestureEvent extends Event {
|
|||||||
let editorViewModule: typeof import("@codemirror/view") | null = null;
|
let editorViewModule: typeof import("@codemirror/view") | null = null;
|
||||||
let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null;
|
let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null;
|
||||||
let codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null = null;
|
let codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null = null;
|
||||||
|
let hoverCloseEffect: StateEffect<unknown> | null = null;
|
||||||
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
||||||
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
|
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
|
||||||
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
|
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
|
||||||
@@ -407,6 +413,10 @@ const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
|
|||||||
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
||||||
const loadedColumnsByTable = new Set<string>();
|
const loadedColumnsByTable = new Set<string>();
|
||||||
|
|
||||||
|
// Hover tooltip uses the shared table metadata cache (loadTableMetadata)
|
||||||
|
// which provides TTL, invalidation, and in-flight deduplication.
|
||||||
|
let hoverSqlHighlighter: SqlHighlighter | null = null;
|
||||||
|
|
||||||
function sqlCompletionDialectOptions() {
|
function sqlCompletionDialectOptions() {
|
||||||
return {
|
return {
|
||||||
databaseType: props.databaseType,
|
databaseType: props.databaseType,
|
||||||
@@ -1717,7 +1727,7 @@ async function ensureForeignKeysForTables(tables: Array<{ name: string; database
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHoverDom(title: string, detail: string, rows: string[] = []) {
|
function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []) {
|
||||||
const dom = document.createElement("div");
|
const dom = document.createElement("div");
|
||||||
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
|
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
|
||||||
|
|
||||||
@@ -1731,6 +1741,24 @@ function createHoverDom(title: string, detail: string, rows: string[] = []) {
|
|||||||
detailNode.textContent = detail;
|
detailNode.textContent = detail;
|
||||||
dom.appendChild(detailNode);
|
dom.appendChild(detailNode);
|
||||||
|
|
||||||
|
if (sqlContent) {
|
||||||
|
const separator = document.createElement("div");
|
||||||
|
separator.className = "mt-2 border-t border-border/60";
|
||||||
|
dom.appendChild(separator);
|
||||||
|
|
||||||
|
const sqlContainer = document.createElement("div");
|
||||||
|
sqlContainer.className = "mt-1.5 max-h-64 overflow-y-auto text-[11px] leading-5 whitespace-pre font-mono";
|
||||||
|
|
||||||
|
if (hoverSqlHighlighter) {
|
||||||
|
sqlContainer.innerHTML = hoverSqlHighlighter(sqlContent, isDark.value ? "dark" : "light");
|
||||||
|
} else {
|
||||||
|
sqlContainer.className += " text-muted-foreground";
|
||||||
|
sqlContainer.textContent = sqlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.appendChild(sqlContainer);
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const rowNode = document.createElement("div");
|
const rowNode = document.createElement("div");
|
||||||
rowNode.className = "mt-1 font-mono text-muted-foreground";
|
rowNode.className = "mt-1 font-mono text-muted-foreground";
|
||||||
@@ -1777,7 +1805,7 @@ function createSignatureDom(signature: ReturnType<typeof getSqlFunctionSignature
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
|
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
|
||||||
if (!props.connectionId || props.database == null) return null;
|
if (!props.connectionId || props.database == null || contextMenuOpen.value) return null;
|
||||||
|
|
||||||
const sql = currentView.state.doc.toString();
|
const sql = currentView.state.doc.toString();
|
||||||
const range = identifierRangeAt(sql, pos);
|
const range = identifierRangeAt(sql, pos);
|
||||||
@@ -1787,31 +1815,114 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||||||
const parts = splitQualifiedIdentifier(identifier);
|
const parts = splitQualifiedIdentifier(identifier);
|
||||||
const name = parts[parts.length - 1] ?? identifier;
|
const name = parts[parts.length - 1] ?? identifier;
|
||||||
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
||||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions()) : null;
|
let semanticModel: ReturnType<typeof buildSqlSemanticModel> | null = null;
|
||||||
|
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
|
||||||
|
try {
|
||||||
|
semanticModel = buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions());
|
||||||
|
} catch (error) {
|
||||||
|
semanticModel = null;
|
||||||
|
console.warn(`[DBX] Failed to build semantic model for hover tooltip:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
|
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
|
||||||
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
|
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
|
||||||
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
|
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
|
||||||
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
|
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
|
||||||
|
|
||||||
|
const hoverTarget = completionMetadataTarget({
|
||||||
|
name: tableLookupName,
|
||||||
|
catalog: props.catalog,
|
||||||
|
database: semanticTarget?.database,
|
||||||
|
schema: semanticTarget?.schema,
|
||||||
|
});
|
||||||
|
if (!hoverTarget) return null;
|
||||||
|
const hoverScope: HoverTableScope = {
|
||||||
|
catalog: hoverTarget.catalog,
|
||||||
|
database: hoverTarget.database,
|
||||||
|
schema: hoverTarget.schema,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cachedTables.length === 0) {
|
let hoverTables = cachedTables.filter((table) => hoverTableMatchesScope(table, hoverScope));
|
||||||
cachedTables = usesLocalOnlyCompletionMetadata()
|
if (hoverTables.length === 0) {
|
||||||
? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, props.catalog)
|
const loadedTables = usesLocalOnlyCompletionMetadata()
|
||||||
: await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog);
|
? connectionStore.lookupLocalCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, hoverScope.catalog)
|
||||||
|
: await connectionStore.listCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, false, hoverScope.schema, hoverScope.catalog);
|
||||||
|
hoverTables = scopeHoverTables(loadedTables, hoverScope);
|
||||||
|
cachedTables = mergeCompletionTables(cachedTables, hoverTables);
|
||||||
}
|
}
|
||||||
|
|
||||||
let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
|
let table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
|
||||||
if (!table && !usesLocalOnlyCompletionMetadata()) {
|
if (!table && !usesLocalOnlyCompletionMetadata()) {
|
||||||
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema, props.catalog);
|
const loadedTables = await connectionStore.listCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, false, hoverScope.schema, hoverScope.catalog);
|
||||||
cachedTables = mergeCompletionTables(cachedTables, hoverTables);
|
const remoteHoverTables = scopeHoverTables(loadedTables, hoverScope);
|
||||||
|
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
|
||||||
|
cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables);
|
||||||
table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
|
table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
|
||||||
}
|
}
|
||||||
if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
|
if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
|
||||||
|
const hoverDatabase = hoverScope.database;
|
||||||
|
const hoverSchema = hoverScope.schema ?? table.schema ?? "";
|
||||||
|
const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join(".");
|
||||||
|
let sqlContent: string | undefined;
|
||||||
|
let metadataLoadFailed = false;
|
||||||
|
|
||||||
|
// Primary path: the backend's raw getTableDdl (SHOW CREATE TABLE, pg_ddl,
|
||||||
|
// build_sqlserver_ddl, ...) is authoritative. Parse it into structured
|
||||||
|
// fields and rebuild with vertical field alignment (name, type, extra,
|
||||||
|
// default, nullable, comment), stripping charset/COLLATE noise.
|
||||||
|
try {
|
||||||
|
const rawDdl = await api.getTableDdl(props.connectionId, hoverDatabase, hoverSchema, table.name, undefined, hoverScope.catalog);
|
||||||
|
if (rawDdl && rawDdl.trim()) {
|
||||||
|
sqlContent = reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback path: rebuild the DDL from cached table metadata when the
|
||||||
|
// backend DDL is unavailable (empty result or request failure).
|
||||||
|
if (!sqlContent) {
|
||||||
|
let fullColumns: ColumnInfo[] = [];
|
||||||
|
let fullIndexes: IndexInfo[] = [];
|
||||||
|
let tableComment: string | undefined;
|
||||||
|
try {
|
||||||
|
const result: TableMetadataLoadResult = await loadTableMetadata({
|
||||||
|
connectionId: props.connectionId,
|
||||||
|
database: hoverDatabase,
|
||||||
|
schema: hoverSchema,
|
||||||
|
tableName: table.name,
|
||||||
|
databaseType: props.databaseType ?? "",
|
||||||
|
catalog: hoverScope.catalog,
|
||||||
|
});
|
||||||
|
fullColumns = result.metadata.columns;
|
||||||
|
fullIndexes = result.metadata.indexes;
|
||||||
|
} catch (error) {
|
||||||
|
metadataLoadFailed = true;
|
||||||
|
console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||||
|
}
|
||||||
|
if (!metadataLoadFailed) {
|
||||||
|
try {
|
||||||
|
const commentResult = await api.getTableComment(props.connectionId, hoverDatabase, hoverSchema, table.name, hoverScope.catalog);
|
||||||
|
if (commentResult) tableComment = commentResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fullColumns.length > 0) {
|
||||||
|
sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment);
|
||||||
|
metadataLoadFailed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-check after async metadata load — the context menu may have opened
|
||||||
|
// while the DDL request was in flight, and we must not display a hover
|
||||||
|
// tooltip on top of an open context menu.
|
||||||
|
if (contextMenuOpen.value) return null;
|
||||||
return {
|
return {
|
||||||
pos: range.from,
|
pos: range.from,
|
||||||
end: range.to,
|
end: range.to,
|
||||||
create: () => ({
|
create: () => ({
|
||||||
dom: createHoverDom(table.name, sqlObjectHoverDetail(table)),
|
dom: createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1837,7 +1948,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||||||
pos: range.from,
|
pos: range.from,
|
||||||
end: range.to,
|
end: range.to,
|
||||||
create: () => ({
|
create: () => ({
|
||||||
dom: createHoverDom(column.name, column.dataType || "column", [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
|
dom: createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3246,8 +3357,20 @@ function refreshCompletionCache() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!editorRef.value) return;
|
if (!editorRef.value) return;
|
||||||
|
|
||||||
|
// Pre-load SQL highlighter for hover tooltips (non-blocking)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { createShikiSqlHighlighter } = await import("@/lib/sql/sqlHighlighter");
|
||||||
|
hoverSqlHighlighter = await createShikiSqlHighlighter({
|
||||||
|
appearance: () => (isDark.value ? "dark" : "light"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Highlighter unavailable; hover falls back to plain text
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin },
|
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin },
|
||||||
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
|
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
|
||||||
langSql,
|
langSql,
|
||||||
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField },
|
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField },
|
||||||
@@ -3260,6 +3383,7 @@ onMounted(async () => {
|
|||||||
keymap,
|
keymap,
|
||||||
rectangularSelection,
|
rectangularSelection,
|
||||||
} as typeof import("@codemirror/view");
|
} as typeof import("@codemirror/view");
|
||||||
|
hoverCloseEffect = closeHoverTooltips;
|
||||||
codeMirrorPrec = Prec;
|
codeMirrorPrec = Prec;
|
||||||
codeMirrorEditorSelection = EditorSelection;
|
codeMirrorEditorSelection = EditorSelection;
|
||||||
codeMirrorSnippetCompletion = snippetCompletion;
|
codeMirrorSnippetCompletion = snippetCompletion;
|
||||||
@@ -3988,6 +4112,21 @@ onMounted(async () => {
|
|||||||
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
|
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register context-menu scroll listener on the actual EditorView scrollDOM
|
||||||
|
// (deferred until after creation so view.value is non-null).
|
||||||
|
const scrollDOM = view.value.scrollDOM;
|
||||||
|
const onEditorScroll = () => {
|
||||||
|
if (contextMenuOpen.value) {
|
||||||
|
contextMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scrollDOM.addEventListener("scroll", onEditorScroll);
|
||||||
|
contextMenuPointerCleanup = () => {
|
||||||
|
scrollDOM.removeEventListener("scroll", onEditorScroll);
|
||||||
|
contextMenuPointerCleanup = null;
|
||||||
|
};
|
||||||
|
|
||||||
restoreEditorViewport();
|
restoreEditorViewport();
|
||||||
syncContextMenuState(view.value);
|
syncContextMenuState(view.value);
|
||||||
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
||||||
@@ -4254,6 +4393,7 @@ onBeforeUnmount(() => {
|
|||||||
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
|
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
|
||||||
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
|
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
|
||||||
window.removeEventListener("blur", clearTableNavigationHover);
|
window.removeEventListener("blur", clearTableNavigationHover);
|
||||||
|
contextMenuPointerCleanup?.();
|
||||||
zoomCommitScheduler.dispose();
|
zoomCommitScheduler.dispose();
|
||||||
view.value?.destroy();
|
view.value?.destroy();
|
||||||
});
|
});
|
||||||
@@ -4384,6 +4524,11 @@ function scrollCursorIntoView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeHoverOnContextMenu() {
|
||||||
|
if (!view.value || !hoverCloseEffect) return;
|
||||||
|
view.value.dispatch({ effects: hoverCloseEffect });
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
openSearch,
|
openSearch,
|
||||||
openReplace,
|
openReplace,
|
||||||
@@ -4398,15 +4543,19 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full overflow-hidden relative" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
<div class="h-full w-full overflow-hidden relative" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
||||||
<CustomContextMenu :items="contextMenuItems" v-slot="{ onContextMenu }">
|
<CustomContextMenu :items="contextMenuItems" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
|
||||||
<div
|
<div
|
||||||
ref="editorRef"
|
ref="editorRef"
|
||||||
data-query-editor-root
|
data-query-editor-root
|
||||||
class="h-full w-full overflow-hidden"
|
class="h-full w-full overflow-hidden"
|
||||||
@contextmenu="
|
@contextmenu="
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (view) syncContextMenuStateAtEvent(view, e);
|
if (view) {
|
||||||
|
syncContextMenuStateAtEvent(view, e);
|
||||||
|
closeHoverOnContextMenu();
|
||||||
|
}
|
||||||
onContextMenu(e);
|
onContextMenu(e);
|
||||||
|
contextMenuOpen = true;
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const props = defineProps<{
|
|||||||
items: ContextMenuItemsSource;
|
items: ContextMenuItemsSource;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -52,9 +52,10 @@ function close() {
|
|||||||
subAnchorRect = null;
|
subAnchorRect = null;
|
||||||
activeItems.value = [];
|
activeItems.value = [];
|
||||||
show.value = false;
|
show.value = false;
|
||||||
|
emit("close");
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ close });
|
defineExpose({ close, menuRef, subRef });
|
||||||
|
|
||||||
function onPointerDownOutside(e: PointerEvent) {
|
function onPointerDownOutside(e: PointerEvent) {
|
||||||
// Only respond to primary (left) button presses. This avoids a macOS
|
// Only respond to primary (left) button presses. This avoids a macOS
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildHoverTableSql, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql";
|
||||||
|
import type { ColumnInfo, IndexInfo } from "@/types/database";
|
||||||
|
|
||||||
|
type ColumnOverride = Partial<ColumnInfo> & { name: string; data_type: string };
|
||||||
|
type IndexOverride = Partial<IndexInfo> & { name: string };
|
||||||
|
|
||||||
|
function col(overrides: ColumnOverride): ColumnInfo {
|
||||||
|
return {
|
||||||
|
name: overrides.name,
|
||||||
|
data_type: overrides.data_type,
|
||||||
|
is_nullable: true,
|
||||||
|
is_primary_key: false,
|
||||||
|
column_default: null,
|
||||||
|
extra: null,
|
||||||
|
comment: null,
|
||||||
|
character_maximum_length: null,
|
||||||
|
numeric_precision: null,
|
||||||
|
numeric_scale: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idx(overrides: IndexOverride): IndexInfo {
|
||||||
|
return {
|
||||||
|
name: overrides.name,
|
||||||
|
columns: [],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildHoverTableSql", () => {
|
||||||
|
it("single column, no PK, no indexes", () => {
|
||||||
|
const sql = buildHoverTableSql('"mydb"."public"."users"', [col({ name: "email", data_type: "text", is_nullable: false })], []);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "mydb"."public"."users" (
|
||||||
|
"email" text not null
|
||||||
|
);"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single column with PK", () => {
|
||||||
|
const sql = buildHoverTableSql('"mydb"."public"."users"', [col({ name: "id", data_type: "integer", is_nullable: false, is_primary_key: true })], [idx({ name: "pk_users", is_primary: true, columns: ["id"] })]);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "mydb"."public"."users" (
|
||||||
|
"id" integer not null,
|
||||||
|
primary key ("id")
|
||||||
|
);"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composite primary key", () => {
|
||||||
|
const sql = buildHoverTableSql(
|
||||||
|
'"public"."order_items"',
|
||||||
|
[col({ name: "order_id", data_type: "bigint", is_nullable: false }), col({ name: "line_item", data_type: "integer", is_nullable: false }), col({ name: "sku", data_type: "varchar(50)" })],
|
||||||
|
[idx({ name: "pk_order_items", is_primary: true, columns: ["order_id", "line_item"] })],
|
||||||
|
);
|
||||||
|
// The PRIMARY KEY table constraint is on a separate line, joined by comma.
|
||||||
|
// No comma on the last column — the join handles it.
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "public"."order_items" (
|
||||||
|
"order_id" bigint not null,
|
||||||
|
"line_item" integer not null,
|
||||||
|
"sku" varchar(50) null,
|
||||||
|
primary key ("order_id", "line_item")
|
||||||
|
);"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no primary key (multi-column)", () => {
|
||||||
|
const sql = buildHoverTableSql('"public"."audit_log"', [col({ name: "event", data_type: "text", is_nullable: false }), col({ name: "created_at", data_type: "timestamptz", column_default: "now()" })], []);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "public"."audit_log" (
|
||||||
|
"event" text not null,
|
||||||
|
"created_at" timestamptz default now() null
|
||||||
|
);"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SQL Server varchar(max) and datetime2(7) — trust backend data_type", () => {
|
||||||
|
const sql = buildHoverTableSql(
|
||||||
|
'"dbo"."orders"',
|
||||||
|
[col({ name: "notes", data_type: "varchar(max)", is_nullable: true }), col({ name: "created_at", data_type: "datetime2(7)", column_default: "getdate()" }), col({ name: "status", data_type: "varchar(20)", is_nullable: false })],
|
||||||
|
[idx({ name: "ix_orders_created", is_primary: false, columns: ["created_at"] })],
|
||||||
|
);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "dbo"."orders" (
|
||||||
|
"notes" varchar(max) null,
|
||||||
|
"created_at" datetime2(7) default getdate() null,
|
||||||
|
"status" varchar(20) not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index "ix_orders_created"
|
||||||
|
on "dbo"."orders" ("created_at");"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts backend parameterized data_type over decomposed fields", () => {
|
||||||
|
// Even though character_maximum_length is set, the backend's data_type
|
||||||
|
// already contains (255), so we must trust it and NOT append again.
|
||||||
|
const sql = buildHoverTableSql('"public"."t"', [col({ name: "name", data_type: "character varying(255)", character_maximum_length: 255 })], []);
|
||||||
|
expect(sql).toContain("character varying(255)");
|
||||||
|
expect(sql).not.toContain("character varying(255)(255)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SQL Server (max) from character_maximum_length=-1", () => {
|
||||||
|
const sql = buildHoverTableSql('"dbo"."t"', [col({ name: "payload", data_type: "nvarchar", character_maximum_length: -1 })], []);
|
||||||
|
expect(sql).toContain("nvarchar(max)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes table comment", () => {
|
||||||
|
const sql = buildHoverTableSql('"public"."users"', [col({ name: "id", data_type: "serial", is_nullable: false })], [idx({ name: "pk_users", is_primary: true, columns: ["id"] })], "User accounts");
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "public"."users" (
|
||||||
|
"id" serial not null,
|
||||||
|
primary key ("id")
|
||||||
|
) comment 'User accounts';"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extra column attributes (auto_increment)", () => {
|
||||||
|
const sql = buildHoverTableSql('"public"."t"', [col({ name: "id", data_type: "int", is_nullable: false, extra: "auto_increment" })], [idx({ name: "pk_t", is_primary: true, columns: ["id"] })]);
|
||||||
|
expect(sql).toContain("auto_increment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-primary indexes are emitted after DDL", () => {
|
||||||
|
const sql = buildHoverTableSql('"public"."t"', [col({ name: "a", data_type: "int" }), col({ name: "b", data_type: "int" }), col({ name: "c", data_type: "int" })], [idx({ name: "ix_t_b", columns: ["b"] }), idx({ name: "ix_t_c", is_unique: true, columns: ["c"] })]);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "public"."t" (
|
||||||
|
"a" int null,
|
||||||
|
"b" int null,
|
||||||
|
"c" int null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index "ix_t_b"
|
||||||
|
on "public"."t" ("b");
|
||||||
|
create unique index "ix_t_c"
|
||||||
|
on "public"."t" ("c");"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty columns produces minimal DDL", () => {
|
||||||
|
const sql = buildHoverTableSql('"t"', [], []);
|
||||||
|
expect(sql).toBe('create table "t" (\n\n);');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeHoverDdl", () => {
|
||||||
|
it("removes column-level CHARACTER SET and COLLATE from MySQL DDL", () => {
|
||||||
|
const input = `CREATE TABLE \`users\` (
|
||||||
|
\`id\` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
\`email\` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
\`name\` varchar(100) CHARACTER SET utf8mb4 NOT NULL,
|
||||||
|
\`bio\` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe(`CREATE TABLE \`users\` (
|
||||||
|
\`id\` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
\`email\` varchar(255) NOT NULL,
|
||||||
|
\`name\` varchar(100) NOT NULL,
|
||||||
|
\`bio\` text,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes table-level CHARSET and COLLATE", () => {
|
||||||
|
const input = "CREATE TABLE t (id int) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe("CREATE TABLE t (id int) ENGINE=InnoDB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes standalone table-level CHARACTER SET", () => {
|
||||||
|
const input = "CREATE TABLE t (id int) CHARACTER SET utf8 ENGINE=InnoDB";
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe("CREATE TABLE t (id int) ENGINE=InnoDB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not alter DDL without charset/COLLATE clauses", () => {
|
||||||
|
const input = `CREATE TABLE "orders" (
|
||||||
|
"id" bigint NOT NULL,
|
||||||
|
"total" numeric(10,2),
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);`;
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes COLLATE without preceding CHARACTER SET on column", () => {
|
||||||
|
const input = "CREATE TABLE t (name varchar(255) COLLATE utf8mb4_bin NOT NULL);";
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe("CREATE TABLE t (name varchar(255) NOT NULL);");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles MariaDB table-level CHARACTER SET syntax", () => {
|
||||||
|
const input = `CREATE TABLE t (id int) ENGINE=InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci`;
|
||||||
|
expect(sanitizeHoverDdl(input)).toBe("CREATE TABLE t (id int) ENGINE=InnoDB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("column-level PK fallback when no primary index is present", () => {
|
||||||
|
const sql = buildHoverTableSql('"public"."t"', [col({ name: "id", data_type: "int", is_nullable: false, is_primary_key: true }), col({ name: "label", data_type: "text" })], []);
|
||||||
|
expect(sql).toMatchInlineSnapshot(`
|
||||||
|
"create table "public"."t" (
|
||||||
|
"id" int not null,
|
||||||
|
"label" text null,
|
||||||
|
primary key ("id")
|
||||||
|
);"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reformatHoverDdl", () => {
|
||||||
|
it("preserves sanitized raw MySQL DDL when table options are present", () => {
|
||||||
|
const raw = `CREATE TABLE \`users\` (
|
||||||
|
\`id\` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
\`email\` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT 'Email',
|
||||||
|
\`bio\` text CHARACTER SET utf8mb4,
|
||||||
|
\`created_at\` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`ux_users_email\` (\`email\`),
|
||||||
|
KEY \`ix_users_created\` (\`created_at\` DESC)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=42 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'`;
|
||||||
|
expect(reformatHoverDdl(raw)).toBe(sanitizeHoverDdl(raw));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Postgres companion statements verbatim", () => {
|
||||||
|
const raw = `CREATE TABLE "public"."orders" (
|
||||||
|
"id" bigint NOT NULL DEFAULT nextval('orders_id_seq'::regclass),
|
||||||
|
"total" numeric(10,2),
|
||||||
|
"note" character varying(255),
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE "public"."orders" IS 'Order headers';
|
||||||
|
COMMENT ON COLUMN "public"."orders"."total" IS 'Amount';
|
||||||
|
CREATE INDEX "ix_orders_total" ON "public"."orders" ("total");`;
|
||||||
|
expect(reformatHoverDdl(raw)).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves PostgreSQL index USING, INCLUDE, and WHERE clauses", () => {
|
||||||
|
const raw = `CREATE TABLE "public"."orders" (
|
||||||
|
"id" bigint NOT NULL,
|
||||||
|
"email" text,
|
||||||
|
"deleted_at" timestamptz
|
||||||
|
);
|
||||||
|
CREATE INDEX "ix_orders_email" ON "public"."orders" USING btree ("email") INCLUDE ("id") WHERE "deleted_at" IS NULL;`;
|
||||||
|
expect(reformatHoverDdl(raw)).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves partition and distribution table clauses", () => {
|
||||||
|
const raw = `CREATE TABLE analytics.events (
|
||||||
|
event_date date NOT NULL,
|
||||||
|
tenant_id bigint NOT NULL
|
||||||
|
) PARTITION BY RANGE (event_date)
|
||||||
|
DISTRIBUTED BY HASH (tenant_id);`;
|
||||||
|
expect(reformatHoverDdl(raw)).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the provided qualified name override for the table and indexes", () => {
|
||||||
|
const raw = "CREATE TABLE `t` (`a` int NOT NULL, KEY `ix_a` (`a`))";
|
||||||
|
const sql = reformatHoverDdl(raw, '"mydb"."t"');
|
||||||
|
expect(sql).toContain('create table "mydb"."t" (');
|
||||||
|
expect(sql).toContain('on "mydb"."t" ("a");');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves foreign key and check constraints verbatim", () => {
|
||||||
|
const raw = `CREATE TABLE t (
|
||||||
|
id int NOT NULL,
|
||||||
|
parent_id int,
|
||||||
|
CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES t (id),
|
||||||
|
CHECK (id > 0)
|
||||||
|
)`;
|
||||||
|
const sql = reformatHoverDdl(raw);
|
||||||
|
expect(sql).toContain("CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES t (id)");
|
||||||
|
expect(sql).toContain("CHECK (id > 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps defaults containing commas inside parentheses intact", () => {
|
||||||
|
const raw = "CREATE TABLE t (a numeric(10,2) DEFAULT round(1.234, 2) NOT NULL, b int)";
|
||||||
|
const sql = reformatHoverDdl(raw);
|
||||||
|
expect(sql).toContain("default round(1.234, 2)");
|
||||||
|
expect(sql).toContain("numeric(10,2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to sanitized raw DDL when the statement is not CREATE TABLE", () => {
|
||||||
|
const raw = "CREATE VIEW v AS SELECT 1 ENGINE=x DEFAULT CHARSET=utf8mb4";
|
||||||
|
expect(reformatHoverDdl(raw)).toBe("CREATE VIEW v AS SELECT 1 ENGINE=x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through unrecognized companion statements", () => {
|
||||||
|
const raw = `CREATE TABLE t (a int);\nALTER TABLE t OWNER TO app;`;
|
||||||
|
expect(reformatHoverDdl(raw)).toBe(raw);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hover table scope", () => {
|
||||||
|
it("annotates loaded tables with their catalog, database, and schema", () => {
|
||||||
|
expect(scopeHoverTables([{ name: "orders" }], { catalog: "hive", database: "sales", schema: "public" })).toEqual([{ name: "orders", catalog: "hive", database: "sales", schema: "public" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bare-name cache hit from another database or schema", () => {
|
||||||
|
const target = { catalog: "hive", database: "sales", schema: "public" };
|
||||||
|
expect(hoverTableMatchesScope({ name: "orders", catalog: "hive", database: "archive", schema: "public" }, target)).toBe(false);
|
||||||
|
expect(hoverTableMatchesScope({ name: "orders", catalog: "hive", database: "sales", schema: "audit" }, target)).toBe(false);
|
||||||
|
expect(hoverTableMatchesScope({ name: "orders", catalog: "iceberg", database: "sales", schema: "public" }, target)).toBe(false);
|
||||||
|
expect(hoverTableMatchesScope({ name: "orders", catalog: "hive", database: "sales", schema: "public" }, target)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trust an unscoped cached table", () => {
|
||||||
|
expect(hoverTableMatchesScope({ name: "orders" }, { database: "sales", schema: "public" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
import type { ColumnInfo, IndexInfo } from "@/types/database";
|
||||||
|
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||||
|
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
|
||||||
|
|
||||||
|
export interface HoverTableScope {
|
||||||
|
catalog?: string;
|
||||||
|
database: string;
|
||||||
|
schema?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedScopePart(value: string | null | undefined): string | undefined {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeHoverTables(tables: SqlCompletionTable[], scope: HoverTableScope): SqlCompletionTable[] {
|
||||||
|
return tables.map((table) => ({
|
||||||
|
...table,
|
||||||
|
catalog: table.catalog ?? scope.catalog,
|
||||||
|
database: table.database ?? scope.database,
|
||||||
|
schema: table.schema ?? scope.schema,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hoverTableMatchesScope(table: SqlCompletionTable, scope: HoverTableScope): boolean {
|
||||||
|
if (normalizedScopePart(table.database) !== normalizedScopePart(scope.database)) return false;
|
||||||
|
if (normalizedScopePart(scope.catalog) && normalizedScopePart(table.catalog) !== normalizedScopePart(scope.catalog)) return false;
|
||||||
|
if (normalizedScopePart(scope.schema) && normalizedScopePart(table.schema) !== normalizedScopePart(scope.schema)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a column's data type for display in the hover DDL.
|
||||||
|
*
|
||||||
|
* - If the backend already reports a parameterized type (e.g. `varchar(255)`,
|
||||||
|
* `datetime2(7)`), trust it as-is.
|
||||||
|
* - For character types with `character_maximum_length`, append the length.
|
||||||
|
* SQL Server maps `-1` to `(max)`.
|
||||||
|
* - For numeric types with `numeric_precision` / `numeric_scale`, append the
|
||||||
|
* precision/scale.
|
||||||
|
* - Otherwise return the raw `data_type` from the backend unchanged.
|
||||||
|
*/
|
||||||
|
function formatColumnType(c: ColumnInfo): string {
|
||||||
|
const dataType = c.data_type.trim();
|
||||||
|
// Backend already reports a parameterized type — trust it.
|
||||||
|
if (/\([^()]*\)$/.test(dataType)) return dataType;
|
||||||
|
// SQL Server character_maximum_length of -1 means (max)
|
||||||
|
if (c.character_maximum_length != null && c.numeric_scale == null) {
|
||||||
|
if (c.character_maximum_length === -1) return `${dataType}(max)`;
|
||||||
|
return `${dataType}(${c.character_maximum_length})`;
|
||||||
|
}
|
||||||
|
if (c.numeric_precision != null && c.numeric_scale != null) {
|
||||||
|
if (c.numeric_scale === 0) return `${dataType}(${c.numeric_precision})`;
|
||||||
|
return `${dataType}(${c.numeric_precision},${c.numeric_scale})`;
|
||||||
|
}
|
||||||
|
return dataType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a column default value in SQL form.
|
||||||
|
*
|
||||||
|
* String literals are kept as-is, numeric values and SQL keywords are
|
||||||
|
* unquoted, and other bare values are wrapped in single quotes.
|
||||||
|
* Only called when the backend `column_default` is not null.
|
||||||
|
*/
|
||||||
|
function formatDefaultValue(value: string): string {
|
||||||
|
// Already quoted string literal — keep as-is.
|
||||||
|
if (/^'/.test(value)) return value;
|
||||||
|
// Numeric values — no quotes.
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
||||||
|
// Expression default (wrapped in parentheses) — keep as-is.
|
||||||
|
if (/^\(/.test(value)) return value;
|
||||||
|
// SQL keywords (NULL, TRUE, FALSE, etc.) — no quotes.
|
||||||
|
if (isSqlKeyword(value)) return value;
|
||||||
|
// Well-known SQL functions commonly used as defaults — no quotes.
|
||||||
|
if (/^(current_timestamp|current_date|current_time|localtimestamp|localtime|now|getdate|random|gen_random_uuid|uuid)\s*(?:\(.*\))?$/i.test(value)) return value;
|
||||||
|
// Other bare string values — wrap in quotes.
|
||||||
|
return `'${value.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip CHARACTER SET / COLLATE clauses from a raw backend DDL for hover display.
|
||||||
|
*
|
||||||
|
* MySQL `SHOW CREATE TABLE` appends `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||||
|
* to each VARCHAR/CHAR column and `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
||||||
|
* to table options. These are redundant in a quick-look tooltip and add visual noise.
|
||||||
|
*
|
||||||
|
* Only affects the hover display — never modifies the source DDL on the backend.
|
||||||
|
*/
|
||||||
|
export function sanitizeHoverDdl(ddl: string): string {
|
||||||
|
// Table-level patterns run before column-level to consume DEFAULT first,
|
||||||
|
// preventing MariaDB `DEFAULT CHARACTER SET utf8` from leaving an orphan DEFAULT.
|
||||||
|
return (
|
||||||
|
ddl
|
||||||
|
// Table-level: `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`
|
||||||
|
.replace(/\s+DEFAULT\s+CHARSET\s*=\s*\w+(?:\s+COLLATE\s*=\s*\w+)?/gi, "")
|
||||||
|
// Table-level: `DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci` (MariaDB)
|
||||||
|
.replace(/\s+DEFAULT\s+CHARACTER\s+SET\s+\w+(?:\s+COLLATE\s+\w+)?/gi, "")
|
||||||
|
// Standalone `CHARSET=utf8mb4`, `CHARACTER SET utf8mb4`, or
|
||||||
|
// `CHARACTER SET utf8mb4 COLLATE utf8_general_ci` (table- or column-level)
|
||||||
|
.replace(/\s+CHARACTER\s+SET\s+\w+(?:\s+COLLATE\s+\w+)?/gi, "")
|
||||||
|
.replace(/\s+CHARSET\s*=\s*\w+/gi, "")
|
||||||
|
// Standalone `COLLATE=utf8mb4_general_ci` or `COLLATE utf8mb4_general_ci`
|
||||||
|
.replace(/\s+COLLATE(?:\s*=\s*|\s+)\w+/gi, "")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display fields of a single column line, prior to vertical alignment.
|
||||||
|
*/
|
||||||
|
interface ColumnFieldParts {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: string; // "not null" or "null"
|
||||||
|
defaultClause: string; // "default <value>" or empty
|
||||||
|
extra: string; // extra attributes (auto_increment, etc.) or empty
|
||||||
|
commentClause: string; // "comment '...'" or empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render column rows with each field (name, type, extra, default, nullable,
|
||||||
|
* comment) vertically aligned across all column lines. Shared by both the
|
||||||
|
* metadata-based builder and the raw-DDL reformatter so both hover paths
|
||||||
|
* produce the same visual layout.
|
||||||
|
*/
|
||||||
|
function alignColumnRows(rows: ColumnFieldParts[]): string[] {
|
||||||
|
const maxNameWidth = Math.max(...rows.map((r) => r.name.length));
|
||||||
|
const maxTypeWidth = Math.max(...rows.map((r) => r.type.length), 4);
|
||||||
|
const hasExtra = rows.some((r) => r.extra);
|
||||||
|
const hasDefault = rows.some((r) => r.defaultClause);
|
||||||
|
const hasComment = rows.some((r) => r.commentClause);
|
||||||
|
const maxNullableWidth = Math.max(...rows.map((r) => r.nullable.length));
|
||||||
|
const maxExtraWidth = Math.max(...rows.map((r) => r.extra.length));
|
||||||
|
const maxDefaultWidth = Math.max(...rows.map((r) => r.defaultClause.length));
|
||||||
|
|
||||||
|
// Field order: name → type → [extra] → [defaultClause] → nullable → [commentClause]
|
||||||
|
return rows.map((r) => {
|
||||||
|
const parts: string[] = [r.name.padEnd(maxNameWidth), " ", r.type.padEnd(maxTypeWidth)];
|
||||||
|
if (hasExtra) parts.push(" ", r.extra.padEnd(maxExtraWidth));
|
||||||
|
if (hasDefault) parts.push(" ", r.defaultClause.padEnd(maxDefaultWidth));
|
||||||
|
parts.push(" ", r.nullable.padEnd(maxNullableWidth));
|
||||||
|
if (hasComment) parts.push(" ", r.commentClause);
|
||||||
|
return parts.join("").trimEnd();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote a SQL identifier by wrapping it in double quotes with proper escaping.
|
||||||
|
*/
|
||||||
|
export function quoteIdentifier(name: string): string {
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote each segment of a qualified name (e.g. `db.schema.table`).
|
||||||
|
*/
|
||||||
|
export function quoteQualifiedName(qualifiedName: string): string {
|
||||||
|
const segments = qualifiedName.split(".");
|
||||||
|
return segments.map((seg) => (seg.startsWith('"') ? seg : quoteIdentifier(seg))).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a human-readable `CREATE TABLE` DDL for the hover tooltip.
|
||||||
|
*
|
||||||
|
* **Primary path**: the backend's `getTableDdl` API should be preferred when
|
||||||
|
* available — it returns database-specific DDL (e.g. `SHOW CREATE TABLE`,
|
||||||
|
* Postgres `pg_ddl`, SQL Server `build_sqlserver_ddl`). This function is the
|
||||||
|
* **fallback** used when the backend DDL path is unavailable.
|
||||||
|
*
|
||||||
|
* The output is formatted for readability in the hover tooltip, not for
|
||||||
|
* execution. Columns are listed with each field (name, type, nullable,
|
||||||
|
* default, extra, comment) vertically aligned across all column lines,
|
||||||
|
* followed by a table-level PRIMARY KEY clause and non-primary index DDL.
|
||||||
|
*/
|
||||||
|
export function buildHoverTableSql(qualifiedName: string, columns: ColumnInfo[], indexes: IndexInfo[], tableComment?: string): string {
|
||||||
|
// Resolve primary key columns from the index (handles composite keys), or
|
||||||
|
// fall back to column-level primary key flags when the index is absent.
|
||||||
|
const primaryKeyIndex = indexes.find((idx) => idx.is_primary);
|
||||||
|
let pkColumns: string[];
|
||||||
|
if (primaryKeyIndex?.columns?.length) {
|
||||||
|
pkColumns = primaryKeyIndex.columns;
|
||||||
|
} else {
|
||||||
|
pkColumns = columns.filter((c) => c.is_primary_key).map((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute each column's display fields so we can determine column widths
|
||||||
|
// for vertical alignment.
|
||||||
|
const rows: ColumnFieldParts[] = columns.map((c) => {
|
||||||
|
const type = formatColumnType(c);
|
||||||
|
const nullable = c.is_nullable ? "null" : "not null";
|
||||||
|
const defaultClause = c.column_default != null ? `default ${formatDefaultValue(c.column_default)}` : "";
|
||||||
|
const extra = c.extra ?? "";
|
||||||
|
const commentClause = c.comment ? `comment '${c.comment.replace(/'/g, "''")}'` : "";
|
||||||
|
return {
|
||||||
|
name: ` ${quoteIdentifier(c.name)}`,
|
||||||
|
type,
|
||||||
|
nullable,
|
||||||
|
defaultClause,
|
||||||
|
extra,
|
||||||
|
commentClause,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build each column line with vertical alignment.
|
||||||
|
const columnDefs: string[] = alignColumnRows(rows);
|
||||||
|
|
||||||
|
// Append the PRIMARY KEY constraint if there are PK columns.
|
||||||
|
if (pkColumns.length > 0) {
|
||||||
|
columnDefs.push(` primary key (${pkColumns.map(quoteIdentifier).join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSuffix = tableComment?.trim() ? ` comment '${tableComment.replace(/'/g, "''")}';` : ";";
|
||||||
|
|
||||||
|
const ddl = `create table ${qualifiedName} (\n${columnDefs.join(",\n")}\n)${closeSuffix}`;
|
||||||
|
|
||||||
|
// Append non-primary index DDL.
|
||||||
|
const nonPrimaryIndexes = indexes.filter((idx) => !idx.is_primary);
|
||||||
|
if (nonPrimaryIndexes.length === 0) return ddl;
|
||||||
|
|
||||||
|
const indexLines: string[] = [""];
|
||||||
|
for (const idx of nonPrimaryIndexes) {
|
||||||
|
const uniquePrefix = idx.is_unique ? "unique " : "";
|
||||||
|
indexLines.push(`create ${uniquePrefix}index ${quoteIdentifier(idx.name)}`);
|
||||||
|
indexLines.push(` on ${qualifiedName} (${idx.columns.map((c) => quoteIdentifier(c)).join(", ")});`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ddl + "\n" + indexLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raw backend DDL parsing & realignment (primary hover path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Scan past a delimited region (string literal or quoted identifier). */
|
||||||
|
function scanDelimited(text: string, start: number, close: string): number {
|
||||||
|
let i = start + 1;
|
||||||
|
const backslashEscapes = close === "'";
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (backslashEscapes && ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === close) {
|
||||||
|
// Doubled delimiter is an escape ('' "" `` ]])
|
||||||
|
if (text[i + 1] === close) {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan past a balanced parenthesized group, respecting quotes. */
|
||||||
|
function scanGroup(text: string, start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let i = start;
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === "'" || ch === '"' || ch === "`") {
|
||||||
|
i = scanDelimited(text, i, ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "[") {
|
||||||
|
i = scanDelimited(text, i, "]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "(") depth++;
|
||||||
|
else if (ch === ")") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i + 1;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split text on a separator at parenthesis depth 0, outside quotes. */
|
||||||
|
function splitTopLevel(text: string, separator: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === "'" || ch === '"' || ch === "`") {
|
||||||
|
i = scanDelimited(text, i, ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "[") {
|
||||||
|
i = scanDelimited(text, i, "]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "(") {
|
||||||
|
i = scanGroup(text, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === separator) {
|
||||||
|
parts.push(text.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
parts.push(text.slice(start));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DdlToken {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a DDL fragment into words, quoted identifiers, string literals and
|
||||||
|
* whole parenthesized groups (each group is a single token).
|
||||||
|
*/
|
||||||
|
function tokenizeDdl(text: string): DdlToken[] {
|
||||||
|
const tokens: DdlToken[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let end: number;
|
||||||
|
if (ch === "'" || ch === '"' || ch === "`") end = scanDelimited(text, i, ch);
|
||||||
|
else if (ch === "[") end = scanDelimited(text, i, "]");
|
||||||
|
else if (ch === "(") end = scanGroup(text, i);
|
||||||
|
else {
|
||||||
|
const m = /^[^\s'"`()[\],]+/.exec(text.slice(i));
|
||||||
|
end = i + (m ? m[0].length : 1);
|
||||||
|
}
|
||||||
|
tokens.push({ text: text.slice(i, end), start: i, end });
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove backtick / double-quote / bracket quoting from an identifier. */
|
||||||
|
function unquoteIdentifier(raw: string): string {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.startsWith("`") && t.endsWith("`")) return t.slice(1, -1).replace(/``/g, "`");
|
||||||
|
if (t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replace(/""/g, '"');
|
||||||
|
if (t.startsWith("[") && t.endsWith("]")) return t.slice(1, -1).replace(/]]/g, "]");
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove single-quote wrapping and unescape a SQL string literal. */
|
||||||
|
function unquoteSqlString(raw: string): string {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.startsWith("'") && t.endsWith("'")) {
|
||||||
|
return t.slice(1, -1).replace(/''/g, "'").replace(/\\'/g, "'");
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quote a parsed key column unless it is an expression (functional index). */
|
||||||
|
function quoteKeyColumn(col: string): string {
|
||||||
|
return /^[\w$#]+$/.test(col) ? quoteIdentifier(col) : col;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedDdlColumn {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: string;
|
||||||
|
defaultClause: string;
|
||||||
|
extra: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedIndex {
|
||||||
|
name: string;
|
||||||
|
columns: string[];
|
||||||
|
unique: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedCreateTable {
|
||||||
|
qualifiedName: string;
|
||||||
|
columns: ParsedDdlColumn[];
|
||||||
|
pkColumns: string[];
|
||||||
|
constraintLines: string[];
|
||||||
|
indexes: ParsedIndex[];
|
||||||
|
tableComment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keywords that terminate the data type and introduce a column clause.
|
||||||
|
const COLUMN_CLAUSE_BOUNDARIES = new Set(["not", "null", "default", "auto_increment", "comment", "generated", "primary", "unique", "references", "check", "on", "constraint", "identity", "stored", "virtual", "invisible", "visible", "collate"]);
|
||||||
|
|
||||||
|
// A body line starting with one of these keywords is a table-level constraint.
|
||||||
|
const CONSTRAINT_LINE_KEYWORDS = new Set(["primary", "unique", "key", "index", "constraint", "foreign", "check", "fulltext", "spatial", "exclude", "period", "like"]);
|
||||||
|
|
||||||
|
/** Parse an index/PK column list, dropping prefix lengths and ASC/DESC. */
|
||||||
|
function parseKeyColumns(group: string): string[] {
|
||||||
|
return splitTopLevel(group, ",")
|
||||||
|
.map((part) => {
|
||||||
|
let p = part.trim();
|
||||||
|
p = p.replace(/\s+(asc|desc)$/i, "");
|
||||||
|
p = p.replace(/\(\d+\)$/, ""); // MySQL prefix length: `email`(20)
|
||||||
|
return unquoteIdentifier(p.trim());
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse one column definition line of a CREATE TABLE body. */
|
||||||
|
function parseColumnLine(line: string): { column: ParsedDdlColumn; inlinePk: boolean } | null {
|
||||||
|
const tokens = tokenizeDdl(line);
|
||||||
|
if (tokens.length < 2) return null;
|
||||||
|
const name = unquoteIdentifier(tokens[0].text);
|
||||||
|
const tokenAt = (i: number) => tokens[i]?.text.toLowerCase();
|
||||||
|
|
||||||
|
// Everything before the first clause keyword is the data type
|
||||||
|
// (e.g. `int(11) unsigned`, `character varying(255)`, `timestamp with time zone`).
|
||||||
|
let boundary = tokens.length;
|
||||||
|
for (let i = 1; i < tokens.length; i++) {
|
||||||
|
if (COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(i)!)) {
|
||||||
|
boundary = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (boundary <= 1) return null;
|
||||||
|
const type = line.slice(tokens[1].start, tokens[boundary - 1].end);
|
||||||
|
|
||||||
|
let nullable = "null";
|
||||||
|
let defaultClause = "";
|
||||||
|
let comment = "";
|
||||||
|
let inlinePk = false;
|
||||||
|
const extras: string[] = [];
|
||||||
|
|
||||||
|
let k = boundary;
|
||||||
|
while (k < tokens.length) {
|
||||||
|
const t = tokenAt(k)!;
|
||||||
|
if (t === "not" && tokenAt(k + 1) === "null") {
|
||||||
|
nullable = "not null";
|
||||||
|
k += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "null") {
|
||||||
|
nullable = "null";
|
||||||
|
k += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "default" && k + 1 < tokens.length) {
|
||||||
|
// The first value token is always part of the expression (handles
|
||||||
|
// DEFAULT NULL); subsequent tokens run until the next clause keyword.
|
||||||
|
let end = k + 2;
|
||||||
|
while (end < tokens.length && !COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(end)!)) end++;
|
||||||
|
defaultClause = `default ${line.slice(tokens[k + 1].start, tokens[end - 1].end)}`;
|
||||||
|
k = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "comment" && k + 1 < tokens.length) {
|
||||||
|
comment = unquoteSqlString(tokens[k + 1].text);
|
||||||
|
k += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "primary" && tokenAt(k + 1) === "key") {
|
||||||
|
inlinePk = true;
|
||||||
|
k += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "unique") {
|
||||||
|
extras.push("unique");
|
||||||
|
k += tokenAt(k + 1) === "key" ? 2 : 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unrecognized clause (AUTO_INCREMENT, ON UPDATE ..., GENERATED ... AS (...),
|
||||||
|
// IDENTITY(1,1), REFERENCES ..., ...): keep the keyword and any following
|
||||||
|
// non-keyword tokens verbatim as extra attributes.
|
||||||
|
let end = k + 1;
|
||||||
|
while (end < tokens.length && !COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(end)!)) end++;
|
||||||
|
extras.push(line.slice(tokens[k].start, tokens[end - 1].end));
|
||||||
|
k = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
column: { name, type, nullable, defaultClause, extra: extras.join(" "), comment },
|
||||||
|
inlinePk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse one table-level constraint line of a CREATE TABLE body. */
|
||||||
|
function parseConstraintLine(line: string, parsed: ParsedCreateTable): void {
|
||||||
|
let rest = line.trim();
|
||||||
|
const named = /^constraint\s+(?:`(?:``|[^`])*`|"(?:""|[^"])*"|\[(?:]]|[^\]])*]|\S+)\s+/i.exec(rest);
|
||||||
|
const namedPrefixLength = named ? named[0].length : 0;
|
||||||
|
rest = rest.slice(namedPrefixLength);
|
||||||
|
|
||||||
|
const tokens = tokenizeDdl(rest);
|
||||||
|
const tokenAt = (i: number) => tokens[i]?.text.toLowerCase();
|
||||||
|
const groupIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
|
||||||
|
const groupColumns = groupIdx >= 0 ? parseKeyColumns(tokens[groupIdx].text.slice(1, -1)) : [];
|
||||||
|
|
||||||
|
if (tokenAt(0) === "primary" && tokenAt(1) === "key" && groupColumns.length > 0) {
|
||||||
|
parsed.pkColumns.push(...groupColumns);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tokenAt(0) === "unique" && (tokenAt(1) === "key" || tokenAt(1) === "index") && groupIdx >= 2 && groupColumns.length > 0) {
|
||||||
|
parsed.indexes.push({ name: unquoteIdentifier(tokens[groupIdx - 1].text), columns: groupColumns, unique: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((tokenAt(0) === "key" || tokenAt(0) === "index") && groupIdx >= 2 && groupColumns.length > 0) {
|
||||||
|
parsed.indexes.push({ name: unquoteIdentifier(tokens[groupIdx - 1].text), columns: groupColumns, unique: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FOREIGN KEY / CHECK / EXCLUDE / unnamed UNIQUE / FULLTEXT ... — preserve verbatim.
|
||||||
|
parsed.constraintLines.push(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single CREATE TABLE statement. Returns null when unparseable. */
|
||||||
|
function parseCreateTableStatement(statement: string): ParsedCreateTable | null {
|
||||||
|
const header = /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\s+(?:if\s+not\s+exists\s+)?/i.exec(statement);
|
||||||
|
if (!header) return null;
|
||||||
|
const rest = statement.slice(header[0].length);
|
||||||
|
const tokens = tokenizeDdl(rest);
|
||||||
|
const bodyIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
|
||||||
|
if (bodyIdx <= 0) return null;
|
||||||
|
|
||||||
|
const rawName = rest.slice(tokens[0].start, tokens[bodyIdx - 1].end);
|
||||||
|
const qualifiedName = splitTopLevel(rawName, ".")
|
||||||
|
.map((seg) => quoteIdentifier(unquoteIdentifier(seg.trim())))
|
||||||
|
.join(".");
|
||||||
|
|
||||||
|
const parsed: ParsedCreateTable = {
|
||||||
|
qualifiedName,
|
||||||
|
columns: [],
|
||||||
|
pkColumns: [],
|
||||||
|
constraintLines: [],
|
||||||
|
indexes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = tokens[bodyIdx].text.slice(1, -1);
|
||||||
|
for (const rawLine of splitTopLevel(body, ",")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const firstWord = /^[A-Za-z_]+/.exec(line)?.[0].toLowerCase();
|
||||||
|
if (firstWord && CONSTRAINT_LINE_KEYWORDS.has(firstWord)) {
|
||||||
|
parseConstraintLine(line, parsed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const col = parseColumnLine(line);
|
||||||
|
// An unparseable column line means the whole reconstruction is unsafe —
|
||||||
|
// bail out so the caller falls back to the sanitized raw DDL.
|
||||||
|
if (!col) return null;
|
||||||
|
parsed.columns.push(col.column);
|
||||||
|
if (col.inlinePk) parsed.pkColumns.push(col.column.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table options: keep only the COMMENT, drop ENGINE / AUTO_INCREMENT / ... noise.
|
||||||
|
const optionsText = rest.slice(tokens[bodyIdx].end);
|
||||||
|
const cm = /comment\s*=?\s*('(?:\\'|''|[^'])*')/i.exec(optionsText);
|
||||||
|
if (cm) parsed.tableComment = unquoteSqlString(cm[1]);
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a standalone CREATE [UNIQUE] INDEX statement. */
|
||||||
|
function parseCreateIndexStatement(statement: string): ParsedIndex | null {
|
||||||
|
const header = /^\s*create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?/i.exec(statement);
|
||||||
|
if (!header) return null;
|
||||||
|
const rest = statement.slice(header[0].length);
|
||||||
|
const tokens = tokenizeDdl(rest);
|
||||||
|
if (tokens.length < 3 || tokens[1].text.toLowerCase() !== "on") return null;
|
||||||
|
const groupIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
|
||||||
|
if (groupIdx < 2) return null;
|
||||||
|
const columns = parseKeyColumns(tokens[groupIdx].text.slice(1, -1));
|
||||||
|
if (columns.length === 0) return null;
|
||||||
|
return { name: unquoteIdentifier(tokens[0].text), columns, unique: !!header[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a parsed CREATE TABLE with buildHoverTableSql's aligned layout. */
|
||||||
|
function renderParsedCreateTable(parsed: ParsedCreateTable, qualifiedNameOverride: string | undefined, passthroughStatements: string[]): string {
|
||||||
|
const displayName = qualifiedNameOverride ?? parsed.qualifiedName;
|
||||||
|
const rows: ColumnFieldParts[] = parsed.columns.map((c) => ({
|
||||||
|
name: ` ${quoteIdentifier(c.name)}`,
|
||||||
|
type: c.type,
|
||||||
|
nullable: c.nullable,
|
||||||
|
defaultClause: c.defaultClause,
|
||||||
|
extra: c.extra,
|
||||||
|
commentClause: c.comment ? `comment '${c.comment.replace(/'/g, "''")}'` : "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columnDefs = alignColumnRows(rows);
|
||||||
|
if (parsed.pkColumns.length > 0) {
|
||||||
|
columnDefs.push(` primary key (${parsed.pkColumns.map(quoteKeyColumn).join(", ")})`);
|
||||||
|
}
|
||||||
|
for (const constraintLine of parsed.constraintLines) {
|
||||||
|
columnDefs.push(` ${constraintLine}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSuffix = parsed.tableComment?.trim() ? ` comment '${parsed.tableComment.replace(/'/g, "''")}';` : ";";
|
||||||
|
let ddl = `create table ${displayName} (\n${columnDefs.join(",\n")}\n)${closeSuffix}`;
|
||||||
|
|
||||||
|
if (parsed.indexes.length > 0) {
|
||||||
|
const indexLines: string[] = [""];
|
||||||
|
for (const idx of parsed.indexes) {
|
||||||
|
const uniquePrefix = idx.unique ? "unique " : "";
|
||||||
|
indexLines.push(`create ${uniquePrefix}index ${quoteIdentifier(idx.name)}`);
|
||||||
|
indexLines.push(` on ${displayName} (${idx.columns.map(quoteKeyColumn).join(", ")});`);
|
||||||
|
}
|
||||||
|
ddl += "\n" + indexLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passthroughStatements.length > 0) {
|
||||||
|
ddl += "\n\n" + passthroughStatements.join("\n");
|
||||||
|
}
|
||||||
|
return ddl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reformat a raw backend DDL (`getTableDdl`) for the hover tooltip.
|
||||||
|
*
|
||||||
|
* This is the **primary** hover path: the backend DDL is authoritative
|
||||||
|
* (SHOW CREATE TABLE, pg_ddl, build_sqlserver_ddl, ...). The DDL is first
|
||||||
|
* stripped of charset/COLLATE noise, then parsed into structured fields and
|
||||||
|
* rebuilt with the same vertical alignment as {@link buildHoverTableSql}
|
||||||
|
* (name, type, extra, default, nullable, comment). Companion statements are
|
||||||
|
* merged in: `COMMENT ON TABLE/COLUMN` become inline comments and
|
||||||
|
* `CREATE INDEX` statements are re-emitted after the table.
|
||||||
|
*
|
||||||
|
* When the DDL cannot be parsed confidently, the sanitized raw DDL is
|
||||||
|
* returned unchanged so no information is ever lost.
|
||||||
|
*/
|
||||||
|
export function reformatHoverDdl(rawDdl: string, qualifiedName?: string): string {
|
||||||
|
const sanitized = sanitizeHoverDdl(rawDdl);
|
||||||
|
const statements = splitTopLevel(sanitized, ";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
// Companion statements can carry database-specific semantics that this
|
||||||
|
// lightweight formatter cannot reproduce losslessly (for example Postgres
|
||||||
|
// USING / INCLUDE / WHERE index clauses). Keep the sanitized backend DDL.
|
||||||
|
if (statements.length !== 1) return sanitized;
|
||||||
|
const createIdx = statements.findIndex((s) => /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\b/i.test(s));
|
||||||
|
if (createIdx === -1) return sanitized;
|
||||||
|
|
||||||
|
const parsed = parseCreateTableStatement(statements[createIdx]);
|
||||||
|
if (!parsed || parsed.columns.length === 0) return sanitized;
|
||||||
|
|
||||||
|
// Table suffixes such as PARTITION BY, DISTRIBUTED BY, ENGINE, TABLESPACE,
|
||||||
|
// and storage options are backend-specific. Rebuilding them partially would
|
||||||
|
// silently remove structure, so only reformat a CREATE TABLE whose suffix is
|
||||||
|
// empty or consists solely of a table COMMENT that the parser preserves.
|
||||||
|
const createStatement = statements[createIdx];
|
||||||
|
const header = /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\s+(?:if\s+not\s+exists\s+)?/i.exec(createStatement);
|
||||||
|
const rest = header ? createStatement.slice(header[0].length) : "";
|
||||||
|
const bodyToken = tokenizeDdl(rest).find((token) => token.text.startsWith("("));
|
||||||
|
const suffix = bodyToken ? rest.slice(bodyToken.end).trim() : "";
|
||||||
|
const suffixWithoutComment = suffix.replace(/^comment\s*=?\s*('(?:\\'|''|[^'])*')\s*$/i, "").trim();
|
||||||
|
if (suffixWithoutComment) return sanitized;
|
||||||
|
|
||||||
|
const passthrough: string[] = [];
|
||||||
|
for (let i = 0; i < statements.length; i++) {
|
||||||
|
if (i === createIdx) continue;
|
||||||
|
const statement = statements[i];
|
||||||
|
|
||||||
|
const tableCommentMatch = /^comment\s+on\s+table\s+.+?\s+is\s+('(?:\\'|''|[^'])*')\s*$/is.exec(statement);
|
||||||
|
if (tableCommentMatch) {
|
||||||
|
parsed.tableComment ??= unquoteSqlString(tableCommentMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnCommentMatch = /^comment\s+on\s+column\s+(\S+)\s+is\s+('(?:\\'|''|[^'])*')\s*$/is.exec(statement);
|
||||||
|
if (columnCommentMatch) {
|
||||||
|
const segments = splitTopLevel(columnCommentMatch[1], ".");
|
||||||
|
const columnName = unquoteIdentifier(segments[segments.length - 1] ?? "");
|
||||||
|
const column = parsed.columns.find((c) => c.name.toLowerCase() === columnName.toLowerCase());
|
||||||
|
if (column) {
|
||||||
|
if (!column.comment) column.comment = unquoteSqlString(columnCommentMatch[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseCreateIndexStatement(statement);
|
||||||
|
if (index) {
|
||||||
|
parsed.indexes.push(index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
passthrough.push(`${statement};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderParsedCreateTable(parsed, qualifiedName, passthrough);
|
||||||
|
}
|
||||||
@@ -1485,7 +1485,7 @@ pub async fn get_table_comment_core(
|
|||||||
&& !db_config.as_ref().is_some_and(is_doris_family_config)
|
&& !db_config.as_ref().is_some_and(is_doris_family_config)
|
||||||
&& !db_config.as_ref().is_some_and(is_manticoresearch_config) =>
|
&& !db_config.as_ref().is_some_and(is_manticoresearch_config) =>
|
||||||
{
|
{
|
||||||
db::mysql::get_table_comment(p, schema, table).await
|
db::mysql::get_table_comment(p, database, table).await
|
||||||
}
|
}
|
||||||
PoolKind::Postgres(p) if !db_config.as_ref().is_some_and(is_questdb_config) => {
|
PoolKind::Postgres(p) if !db_config.as_ref().is_some_and(is_questdb_config) => {
|
||||||
db::postgres::get_table_comment(p, schema, table).await
|
db::postgres::get_table_comment(p, schema, table).await
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ test("tree-level context menu opens with the current row items atomically", () =
|
|||||||
assert.match(connectionTree, /<CustomContextMenu ref="sidebarContextMenuRef"/);
|
assert.match(connectionTree, /<CustomContextMenu ref="sidebarContextMenuRef"/);
|
||||||
assert.match(contextMenu, /function onContextMenu\(event: MouseEvent, itemsOverride\?: ContextMenuItem\[\]\)/);
|
assert.match(contextMenu, /function onContextMenu\(event: MouseEvent, itemsOverride\?: ContextMenuItem\[\]\)/);
|
||||||
assert.match(contextMenu, /const items = itemsOverride \?\?/);
|
assert.match(contextMenu, /const items = itemsOverride \?\?/);
|
||||||
assert.match(contextMenu, /defineExpose\(\{ close \}\)/);
|
assert.match(contextMenu, /defineExpose\(\{ close, menuRef, subRef \}\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rare sidebar dialogs share module-level async wrappers with fallbacks", () => {
|
test("rare sidebar dialogs share module-level async wrappers with fallbacks", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user