mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(editor): highlight unknown SQL objects
This commit is contained in:
@@ -111,7 +111,7 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
|
||||
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
|
||||
|
||||
@@ -122,18 +122,12 @@ import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOcc
|
||||
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
|
||||
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
|
||||
import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut";
|
||||
|
||||
import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
|
||||
|
||||
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
|
||||
|
||||
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
|
||||
|
||||
const props = defineProps<QueryEditorProps>();
|
||||
@@ -160,6 +154,9 @@ const COMPLETION_ENTER_MAX_WAIT_MS = 125;
|
||||
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
|
||||
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
|
||||
// Master switch for colouring table/column names that the connected database does not have.
|
||||
// There is no settings entry yet: flip this to false to turn the whole feature off.
|
||||
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = true;
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
@@ -216,7 +213,8 @@ const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.ge
|
||||
const MAX_COMPLETION_TABLES = 200;
|
||||
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
|
||||
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
|
||||
|
||||
const SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS = 900;
|
||||
const SQL_UNKNOWN_OBJECT_DEBOUNCE_MS = 600;
|
||||
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const isGestureZooming = ref(false);
|
||||
@@ -1499,52 +1497,55 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) {
|
||||
currentView.focus();
|
||||
}
|
||||
|
||||
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({
|
||||
props,
|
||||
view,
|
||||
settingsStore,
|
||||
connectionStore,
|
||||
sqlDriverProfile,
|
||||
sqlStatementParameterOptions,
|
||||
sqlBehaviorDialect,
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
maxCompletionTables: MAX_COMPLETION_TABLES,
|
||||
runtime: {
|
||||
get setSqlDiagnosticsEffect() {
|
||||
return codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, loadSqlUnknownObjectSpans } =
|
||||
useQueryEditorDiagnostics({
|
||||
props,
|
||||
view,
|
||||
settingsStore,
|
||||
connectionStore,
|
||||
sqlDriverProfile,
|
||||
sqlStatementParameterOptions,
|
||||
sqlBehaviorDialect,
|
||||
queryEditorSelectionLanguage,
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
maxCompletionTables: MAX_COMPLETION_TABLES,
|
||||
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
runtime: {
|
||||
get setSqlDiagnosticsEffect() {
|
||||
return codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
},
|
||||
get diagnosticComp() {
|
||||
return codeMirrorRuntime.diagnosticComp;
|
||||
},
|
||||
get buildSqlDiagnosticExtension() {
|
||||
return codeMirrorRuntime.buildSqlDiagnosticExtension;
|
||||
},
|
||||
get codeMirrorCompletionStatus() {
|
||||
return codeMirrorRuntime.codeMirrorCompletionStatus;
|
||||
},
|
||||
get executableStatementRangeCache() {
|
||||
return executableStatementRangeCache;
|
||||
},
|
||||
set executableStatementRangeCache(value) {
|
||||
executableStatementRangeCache = value;
|
||||
},
|
||||
get editorIsActive() {
|
||||
return editorIsActive;
|
||||
},
|
||||
},
|
||||
get diagnosticComp() {
|
||||
return codeMirrorRuntime.diagnosticComp;
|
||||
metadata: {
|
||||
get cachedTables() {
|
||||
return completionMetadata.cachedTables;
|
||||
},
|
||||
cachedColumnsByTable,
|
||||
loadedColumnsByTable,
|
||||
usesOracleSessionCompletionColumns,
|
||||
findExactSemanticDiagnosticTable,
|
||||
completionCacheKey,
|
||||
ensureColumnsForTable,
|
||||
isMissingTableMetadataError,
|
||||
},
|
||||
get buildSqlDiagnosticExtension() {
|
||||
return codeMirrorRuntime.buildSqlDiagnosticExtension;
|
||||
},
|
||||
get codeMirrorCompletionStatus() {
|
||||
return codeMirrorRuntime.codeMirrorCompletionStatus;
|
||||
},
|
||||
get executableStatementRangeCache() {
|
||||
return executableStatementRangeCache;
|
||||
},
|
||||
set executableStatementRangeCache(value) {
|
||||
executableStatementRangeCache = value;
|
||||
},
|
||||
get editorIsActive() {
|
||||
return editorIsActive;
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
get cachedTables() {
|
||||
return completionMetadata.cachedTables;
|
||||
},
|
||||
cachedColumnsByTable,
|
||||
loadedColumnsByTable,
|
||||
usesOracleSessionCompletionColumns,
|
||||
findExactSemanticDiagnosticTable,
|
||||
completionCacheKey,
|
||||
ensureColumnsForTable,
|
||||
isMissingTableMetadataError,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function formatCurrentSql() {
|
||||
if (props.readOnly) return;
|
||||
@@ -1872,6 +1873,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
|
||||
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
|
||||
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
|
||||
createSqlUnknownObjectHighlights({
|
||||
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
load: loadSqlUnknownObjectSpans,
|
||||
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
|
||||
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
|
||||
}),
|
||||
tooltips({ parent: tooltipParent }),
|
||||
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
|
||||
sqlCompletionTheme(EditorView),
|
||||
@@ -2495,6 +2502,7 @@ function resumeQueryEditorBackgroundWork() {
|
||||
editorIsActive = true;
|
||||
registerTableReferenceDropListener();
|
||||
scheduleSemanticDiagnostics();
|
||||
view.value?.dispatch({ effects: [refreshSqlUnknownObjectHighlights.of(null)] });
|
||||
// Warm the database driver/pool while the user is still reading or typing, so
|
||||
// the first Run does not pay pool creation or external-driver startup.
|
||||
warmActiveTabConnection();
|
||||
|
||||
@@ -17,6 +17,8 @@ import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"
|
||||
import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics";
|
||||
import { needsDiagnosticCaretReanchor } from "@/lib/editor/queryEditorDiagnosticCaretAnchor";
|
||||
import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
|
||||
import {
|
||||
areSqlSemanticDiagnosticsEqual,
|
||||
buildSqlParserErrorDiagnostic,
|
||||
@@ -29,11 +31,11 @@ import {
|
||||
tableReferenceKey,
|
||||
type SqlSemanticDiagnostic,
|
||||
} from "@/lib/sql/semantic/diagnostics";
|
||||
import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
|
||||
import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
|
||||
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
|
||||
import { buildMongoSyntaxDiagnostics } from "@/lib/mongo/mongoSyntaxDiagnostics";
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
|
||||
interface QueryEditorDiagnosticsRuntime {
|
||||
setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null;
|
||||
@@ -63,8 +65,10 @@ interface QueryEditorDiagnosticsOptions {
|
||||
sqlDriverProfile: ComputedRef<string | undefined>;
|
||||
sqlStatementParameterOptions: () => SqlParameterOptions;
|
||||
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
|
||||
queryEditorSelectionLanguage: () => "sql" | "text";
|
||||
semanticCompletionEnabled: boolean;
|
||||
maxCompletionTables: number;
|
||||
unknownObjectHighlightEnabled: boolean;
|
||||
runtime: QueryEditorDiagnosticsRuntime;
|
||||
metadata: QueryEditorDiagnosticMetadata;
|
||||
}
|
||||
@@ -73,6 +77,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options;
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
|
||||
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
|
||||
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = options.unknownObjectHighlightEnabled;
|
||||
const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4;
|
||||
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
|
||||
let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -287,7 +292,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
return { tables: enriched, missingTables };
|
||||
}
|
||||
|
||||
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<Set<string>> {
|
||||
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope, limit = MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES): Promise<Set<string>> {
|
||||
const missingTables = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const targets: SqlTableReference[] = [];
|
||||
@@ -303,7 +308,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
if (seen.has(normalizedKey)) continue;
|
||||
seen.add(normalizedKey);
|
||||
targets.push(table);
|
||||
if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break;
|
||||
if (targets.length >= limit) break;
|
||||
}
|
||||
await Promise.all(
|
||||
targets.map(async (table) => {
|
||||
@@ -493,6 +498,135 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
|
||||
onBeforeUnmount(clearScheduledSemanticDiagnostics);
|
||||
|
||||
// ==================== Unknown table/column highlighting ====================
|
||||
const NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set(["qdrant", "milvus", "weaviate", "chromadb", "solr"]);
|
||||
const MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH = 200_000;
|
||||
const MAX_SQL_UNKNOWN_OBJECT_STATEMENTS = 80;
|
||||
const SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT = 24;
|
||||
const MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES = 200;
|
||||
const sqlUnknownObjectSpanMemo = new Map<string, SqlUnknownObjectSpan[]>();
|
||||
|
||||
function sqlUnknownObjectMemoKey(range: SqlTextRange, metadataEpoch: string): string {
|
||||
return `${sqlTextFingerprint(range.sql)}|${range.from}:${range.to}|${metadataEpoch}`;
|
||||
}
|
||||
|
||||
function rememberSqlUnknownObjectSpans(key: string, spans: SqlUnknownObjectSpan[]) {
|
||||
sqlUnknownObjectSpanMemo.set(key, spans);
|
||||
while (sqlUnknownObjectSpanMemo.size > MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES) {
|
||||
const oldest = sqlUnknownObjectSpanMemo.keys().next();
|
||||
if (oldest.done) break;
|
||||
sqlUnknownObjectSpanMemo.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSqlUnknownObjectSpans(currentView: EditorViewType): Promise<SqlUnknownObjectSpan[]> {
|
||||
if (!SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED || !runtime.editorIsActive) return [];
|
||||
if (options.queryEditorSelectionLanguage() !== "sql") return [];
|
||||
if (shouldSkipSqlSemanticDiagnostics()) return [];
|
||||
if (props.databaseType && NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES.has(props.databaseType)) return [];
|
||||
if (!props.connectionId || props.database == null) return [];
|
||||
const doc = currentView.state.doc;
|
||||
if (doc.length > MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH) return [];
|
||||
const sql = doc.toString();
|
||||
if (!sql.trim()) return [];
|
||||
|
||||
if (props.databaseType !== "sqlserver") {
|
||||
runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, doc, props.databaseType, sqlStatementParameterOptions());
|
||||
}
|
||||
const ranges = sqlSemanticDiagnosticRangesForViewport(sql, [{ from: 0, to: sql.length }], props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions()).slice(0, MAX_SQL_UNKNOWN_OBJECT_STATEMENTS);
|
||||
if (ranges.length === 0) return [];
|
||||
|
||||
const metadataEpoch = `${resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() })}|${props.connectionId}|${props.database}|${props.schema ?? ""}|${props.databaseType}|${semanticDiagnosticRunId}|${metadata.cachedColumnsByTable.size}:${metadata.loadedColumnsByTable.size}`;
|
||||
|
||||
const spans: SqlUnknownObjectSpan[] = [];
|
||||
let analyzedStatements = 0;
|
||||
let touchedTables = 0;
|
||||
let columnMetadataLoads = 0;
|
||||
|
||||
for (const range of ranges) {
|
||||
const memoKey = sqlUnknownObjectMemoKey(range, metadataEpoch);
|
||||
const cachedSpans = sqlUnknownObjectSpanMemo.get(memoKey);
|
||||
if (cachedSpans) {
|
||||
spans.push(...cachedSpans);
|
||||
continue;
|
||||
}
|
||||
if (currentView.state.doc !== doc) return [];
|
||||
|
||||
const statementSpans: SqlUnknownObjectSpan[] = [];
|
||||
try {
|
||||
const analysis = await api.analyzeSqlReferences(
|
||||
range.sql,
|
||||
sqlReferenceAnalysisDialectFor({
|
||||
databaseType: props.databaseType,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId),
|
||||
fallbackDialect: props.formatDialect ?? props.dialect ?? "generic",
|
||||
}),
|
||||
);
|
||||
if (currentView.state.doc !== doc) return [];
|
||||
|
||||
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
|
||||
? buildSqlSemanticModel(range.sql, semanticCursor, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
})
|
||||
: null;
|
||||
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
|
||||
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
|
||||
const scopedAnalysis = {
|
||||
...semanticAnalysis,
|
||||
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
|
||||
};
|
||||
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
|
||||
const loadedColumnsBefore = metadata.loadedColumnsByTable.size;
|
||||
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope, SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT);
|
||||
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
|
||||
if (currentView.state.doc !== doc) return [];
|
||||
|
||||
touchedTables += tables.length;
|
||||
columnMetadataLoads += Math.max(0, metadata.loadedColumnsByTable.size - loadedColumnsBefore);
|
||||
|
||||
const diagnostics = offsetSqlSemanticDiagnostics(
|
||||
buildSqlSemanticDiagnostics(
|
||||
{ ...scopedAnalysis, tables },
|
||||
{
|
||||
tables: metadata.cachedTables,
|
||||
columnsByTable: metadata.cachedColumnsByTable,
|
||||
missingTables,
|
||||
loadedColumnTables: metadata.loadedColumnsByTable,
|
||||
sql: range.sql,
|
||||
databaseType: props.databaseType,
|
||||
},
|
||||
),
|
||||
range,
|
||||
sql,
|
||||
);
|
||||
for (const diagnostic of diagnostics) {
|
||||
const offsetRange = sqlTextSpanToRange(sql, diagnostic.span);
|
||||
if (offsetRange) statementSpans.push(offsetRange);
|
||||
}
|
||||
} catch {
|
||||
// A failed parse must not colour anything.
|
||||
}
|
||||
|
||||
analyzedStatements += 1;
|
||||
rememberSqlUnknownObjectSpans(memoKey, statementSpans);
|
||||
spans.push(...statementSpans);
|
||||
}
|
||||
|
||||
const dedupedSpans: SqlUnknownObjectSpan[] = [];
|
||||
const seenSpans = new Set<string>();
|
||||
for (const span of spans) {
|
||||
const key = `${span.from}:${span.to}`;
|
||||
if (seenSpans.has(key)) continue;
|
||||
seenSpans.add(key);
|
||||
dedupedSpans.push(span);
|
||||
}
|
||||
dedupedSpans.sort((left, right) => left.from - right.from || left.to - right.to);
|
||||
|
||||
return dedupedSpans;
|
||||
}
|
||||
|
||||
return {
|
||||
sqlErrorDecorationRange,
|
||||
sqlSemanticDecorationRanges,
|
||||
@@ -502,6 +636,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
invalidateSemanticDiagnosticsForDocumentChange,
|
||||
shouldSkipSqlSemanticDiagnostics,
|
||||
scheduleSemanticDiagnostics,
|
||||
loadSqlUnknownObjectSpans,
|
||||
get diagnostics() {
|
||||
return semanticDiagnostics;
|
||||
},
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights, setSqlUnknownObjectSpans, type SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
|
||||
|
||||
const DOC = "SELECT USER_ID FROM T";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
|
||||
function mount(doc: string, options: { enabled?: boolean; load?: (view: EditorView) => Promise<SqlUnknownObjectSpan[]> } = {}) {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
createSqlUnknownObjectHighlights({
|
||||
enabled: options.enabled ?? true,
|
||||
load: options.load ?? (async () => []),
|
||||
initialDelayMs: 0,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Let the zero-delay deferred task fire and its promise chain settle. */
|
||||
async function settle() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function highlightedText(current: EditorView): string[] {
|
||||
return Array.from(current.dom.querySelectorAll(".cm-sql-unknown-object")).map((element) => element.textContent ?? "");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("createSqlUnknownObjectHighlights", () => {
|
||||
it("returns no extension when disabled", () => {
|
||||
expect(createSqlUnknownObjectHighlights({ enabled: false, load: async () => [], initialDelayMs: 0, debounceMs: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("colours the ranges supplied through the effect", () => {
|
||||
const current = mount(DOC);
|
||||
current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) });
|
||||
|
||||
expect(highlightedText(current)).toEqual(["USER_ID"]);
|
||||
|
||||
current.dispatch({ effects: setSqlUnknownObjectSpans.of([]) });
|
||||
expect(highlightedText(current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("clamps, dedupes and orders the supplied ranges", () => {
|
||||
const current = mount(DOC);
|
||||
current.dispatch({
|
||||
effects: setSqlUnknownObjectSpans.of([
|
||||
{ from: 15, to: 500 },
|
||||
{ from: 7, to: 14 },
|
||||
{ from: 7, to: 14 },
|
||||
{ from: 3, to: 3 },
|
||||
]),
|
||||
});
|
||||
|
||||
// Ordered by start, duplicate collapsed, out-of-range clipped to the document end, empty dropped.
|
||||
expect(highlightedText(current)).toEqual(["USER_ID", "FROM T"]);
|
||||
});
|
||||
|
||||
it("shifts existing highlights through document changes without clearing them", () => {
|
||||
const current = mount(DOC);
|
||||
current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) });
|
||||
|
||||
current.dispatch({ changes: { from: 0, insert: "XX" } });
|
||||
|
||||
expect(highlightedText(current)).toEqual(["USER_ID"]);
|
||||
});
|
||||
|
||||
it("runs load on mount and applies the resolved spans", async () => {
|
||||
const load = vi.fn(async () => [{ from: 7, to: 14 }]);
|
||||
const current = mount(DOC, { load });
|
||||
|
||||
expect(highlightedText(current)).toEqual([]);
|
||||
await settle();
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(highlightedText(current)).toEqual(["USER_ID"]);
|
||||
});
|
||||
|
||||
it("re-runs load when a refresh effect is dispatched", async () => {
|
||||
const load = vi.fn(async () => [{ from: 7, to: 14 }]);
|
||||
const current = mount(DOC, { load });
|
||||
await settle();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
current.dispatch({ effects: refreshSqlUnknownObjectHighlights.of(null) });
|
||||
await settle();
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(highlightedText(current)).toEqual(["USER_ID"]);
|
||||
});
|
||||
|
||||
it("discards a result computed against an older document", async () => {
|
||||
let resolveLoad: ((spans: SqlUnknownObjectSpan[]) => void) | null = null;
|
||||
const load = () =>
|
||||
new Promise<SqlUnknownObjectSpan[]>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
});
|
||||
const current = mount(DOC, { load });
|
||||
await settle();
|
||||
|
||||
current.dispatch({ changes: { from: 0, insert: "XX" } });
|
||||
resolveLoad?.([{ from: 7, to: 14 }]);
|
||||
await settle();
|
||||
|
||||
expect(highlightedText(current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a load failure", async () => {
|
||||
const load = async () => {
|
||||
throw new Error("metadata unavailable");
|
||||
};
|
||||
const current = mount(DOC, { load });
|
||||
await settle();
|
||||
|
||||
expect(highlightedText(current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits an !important colour rule that outranks the table-name colour", () => {
|
||||
mount(DOC);
|
||||
const css = Array.from(document.querySelectorAll("style"))
|
||||
.map((style) => style.textContent ?? "")
|
||||
.join("\n");
|
||||
|
||||
// The repeated class is what beats .cm-sql-table-name (same specificity as a
|
||||
// single class once the generated theme id prefix is accounted for).
|
||||
expect(css).toContain(".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object");
|
||||
expect(css).toContain("var(--dbx-sql-unknown-object-color, #e5484d) !important");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
|
||||
|
||||
/** Absolute document range that must be rendered with the "unknown object" colour. */
|
||||
export interface SqlUnknownObjectSpan {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export const setSqlUnknownObjectSpans = StateEffect.define<SqlUnknownObjectSpan[]>();
|
||||
/**
|
||||
* Ask a mounted editor to re-run `load` immediately (used on tab activation,
|
||||
* where KeepAlive does not necessarily produce a focus or viewport change).
|
||||
*/
|
||||
export const refreshSqlUnknownObjectHighlights = StateEffect.define<null>();
|
||||
|
||||
export interface SqlUnknownObjectHighlightOptions {
|
||||
enabled: boolean;
|
||||
/** Resolves the ranges to colour. Called off the typing path, never per keystroke. */
|
||||
load: (view: EditorView) => Promise<SqlUnknownObjectSpan[]>;
|
||||
initialDelayMs: number;
|
||||
debounceMs: number;
|
||||
}
|
||||
|
||||
const unknownObjectMark = Decoration.mark({
|
||||
class: "cm-sql-unknown-object",
|
||||
attributes: {
|
||||
"data-sql-token": "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
function buildDecorationSet(spans: readonly SqlUnknownObjectSpan[], docLength: number): DecorationSet {
|
||||
const normalized: SqlUnknownObjectSpan[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const span of spans) {
|
||||
const from = Math.max(0, Math.min(span.from, docLength));
|
||||
const to = Math.max(0, Math.min(span.to, docLength));
|
||||
if (to <= from) continue;
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push({ from, to });
|
||||
}
|
||||
// DecorationSet requires ranges in ascending order.
|
||||
normalized.sort((left, right) => left.from - right.from || left.to - right.to);
|
||||
return Decoration.set(
|
||||
normalized.map((span) => unknownObjectMark.range(span.from, span.to)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function createSqlUnknownObjectHighlights(options: SqlUnknownObjectHighlightOptions): Extension {
|
||||
if (!options.enabled) return [];
|
||||
|
||||
// Keeps the decorations as a DecorationSet so that typing shifts them through
|
||||
// the ChangeSet instead of clearing them until the next deferred scan lands.
|
||||
const spansField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(decorations, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(setSqlUnknownObjectSpans)) return buildDecorationSet(effect.value, transaction.state.doc.length);
|
||||
}
|
||||
if (transaction.docChanged) return decorations.map(transaction.changes);
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private running = false;
|
||||
private view: EditorView;
|
||||
private readonly runTask: () => void;
|
||||
private readonly initialTask: ReturnType<typeof createDeferredEditorTask>;
|
||||
private readonly changeTask: ReturnType<typeof createDeferredEditorTask>;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.view = view;
|
||||
this.runTask = () => {
|
||||
void this.run();
|
||||
};
|
||||
this.initialTask = createDeferredEditorTask(this.runTask, options.initialDelayMs);
|
||||
this.changeTask = createDeferredEditorTask(this.runTask, options.debounceMs);
|
||||
this.initialTask.schedule();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.view = update.view;
|
||||
if (update.docChanged) {
|
||||
this.initialTask.cancel();
|
||||
this.changeTask.schedule();
|
||||
return;
|
||||
}
|
||||
if (update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlUnknownObjectHighlights)))) {
|
||||
this.initialTask.cancel();
|
||||
this.changeTask.cancel();
|
||||
this.runTask();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.initialTask.cancel();
|
||||
this.changeTask.cancel();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
if (this.running) return;
|
||||
const view = this.view;
|
||||
if (!view.dom.isConnected) return;
|
||||
const doc = view.state.doc;
|
||||
this.running = true;
|
||||
let spans: SqlUnknownObjectSpan[] = [];
|
||||
try {
|
||||
spans = await options.load(view);
|
||||
} catch {
|
||||
spans = [];
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
// A result computed against an older document (or a detached editor) is
|
||||
// stale: the next debounced run owns the current text.
|
||||
if (!view.dom.isConnected || view.state.doc !== doc) return;
|
||||
view.dispatch({ effects: setSqlUnknownObjectSpans.of(spans) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
spansField,
|
||||
plugin,
|
||||
EditorView.baseTheme({
|
||||
// The generated theme class prefixes this selector, and the table-name
|
||||
// colour is an `!important` rule at the same specificity, so the class is
|
||||
// repeated to win the cascade regardless of style-module injection order.
|
||||
".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object": {
|
||||
color: "var(--dbx-sql-unknown-object-color, #e5484d) !important",
|
||||
},
|
||||
".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object *": {
|
||||
color: "var(--dbx-sql-unknown-object-color, #e5484d) !important",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user