feat(grid): improve WHERE condition completion

This commit is contained in:
zipg 2026-07-28 01:22:07 +08:00 committed by GitHub
parent d163136b6f
commit c68a5adb25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 576 additions and 56 deletions

View File

@ -216,7 +216,7 @@ import { supportsTableStructureEditing } from "@/lib/database/databaseCapabiliti
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
import { restoreDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { dataGridConditionColumnOptions } from "@/lib/dataGrid/dataGridConditionCompletion";
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
import { isMacOS } from "@/lib/backend/platform";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
@ -615,6 +615,7 @@ const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible
const orderByInput = ref(props.initialOrderByInput ?? "");
const whereFilterInput = ref(props.initialWhereInput ?? "");
const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta?.columns ?? props.result.columns, resolvedDatabaseType.value));
const conditionIdentifierQuote = computed(() => dataGridConditionIdentifierQuote(resolvedDatabaseType.value, connectionStore.connectionIdentifierQuote?.(props.connectionId)));
const conditionHistoryScope = computed(() => ({
connectionId: props.connectionId,
database: props.database,
@ -7774,6 +7775,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
v-model:filter-builder-open="filterBuilderOpen"
:columns="props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns"
:condition-columns="conditionColumns"
:identifier-quote="conditionIdentifierQuote"
:history-scope="conditionHistoryScope"
:can-use-where-search="canUseWhereSearch"
:compact="compactDataGridToolbar"

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch, type CSSProperties } from "vue";
import { ChevronDown, X } from "@lucide/vue";
import { useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
import { completeDataGridConditionQuote, useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
@ -14,6 +14,7 @@ const props = withDefaults(
ariaLabel?: string;
historyEmptyText?: string;
historyNoMatchesText?: string;
identifierQuote?: string;
suggestionProvider?: DataGridConditionSuggestionProvider;
suggestionDebounceMs?: number;
disabled?: boolean;
@ -39,6 +40,8 @@ const emit = defineEmits<{
}>();
const inputRef = ref<HTMLTextAreaElement>();
const selectionStart = ref(modelValue.value.length);
const selectionEnd = ref(modelValue.value.length);
const suggestionListId = `${useId()}-${props.kind}-condition-suggestions`;
const overlayRef = ref<HTMLTextAreaElement>();
const controlRef = ref<HTMLDivElement>();
@ -56,6 +59,9 @@ let expandAfterComposition = false;
const editor = useDataGridConditionEditor({
kind: props.kind,
value: modelValue,
selectionStart,
selectionEnd,
identifierQuote: () => props.identifierQuote,
columns: () => props.columns,
historyScope: () => props.historyScope,
suggestionProvider: props.suggestionProvider,
@ -166,9 +172,9 @@ function resizeEditor(forceExpand = false) {
void nextTick(() => {
const overlay = overlayRef.value;
if (!overlay || composing.value) return;
const start = input.selectionStart;
syncSelection(input);
overlay.focus();
overlay.setSelectionRange(start, start);
overlay.setSelectionRange(selectionStart.value, selectionEnd.value);
});
}
});
@ -188,7 +194,31 @@ function onCompositionEnd() {
function focus(select = false) {
const target = activeEditor.value ?? inputRef.value;
target?.focus();
if (select) target?.select();
if (select) {
target?.select();
if (target) syncSelection(target);
} else {
target?.setSelectionRange(selectionStart.value, selectionEnd.value);
}
resizeEditor(true);
}
function syncSelection(target: HTMLTextAreaElement) {
selectionStart.value = target.selectionStart;
selectionEnd.value = target.selectionEnd;
}
function onFocus(event: FocusEvent) {
syncSelection(event.currentTarget as HTMLTextAreaElement);
resizeEditor(true);
}
function onSelectionChange(event: Event) {
syncSelection(event.currentTarget as HTMLTextAreaElement);
}
function onClick(event: MouseEvent) {
onSelectionChange(event);
resizeEditor(true);
}
@ -201,7 +231,8 @@ function scheduleCollapse() {
}, 0);
}
function onInput() {
function onInput(event: Event) {
syncSelection(event.currentTarget as HTMLTextAreaElement);
resizeEditor(true);
updateSuggestionPosition();
}
@ -221,11 +252,29 @@ async function clearCondition() {
}
function onKeydown(event: KeyboardEvent) {
if (completeQuote(event)) return;
const action = editor.handleKeydown(event);
if (action === "apply") void applyCondition();
if (action === "accept") void nextTick(() => focus());
}
function completeQuote(event: KeyboardEvent) {
if (props.kind !== "where" || (event.key !== "'" && event.key !== '"') || event.isComposing || event.keyCode === 229 || event.metaKey || event.ctrlKey || event.altKey) return false;
const target = event.currentTarget as HTMLTextAreaElement;
const completion = completeDataGridConditionQuote(modelValue.value, target.selectionStart, target.selectionEnd, event.key);
event.preventDefault();
modelValue.value = completion.value;
selectionStart.value = completion.selectionStart;
selectionEnd.value = completion.selectionEnd;
editor.dismiss();
void nextTick(() => {
const active = activeEditor.value;
active?.focus();
active?.setSelectionRange(completion.selectionStart, completion.selectionEnd);
});
return true;
}
function openHistory() {
editor.openHistory();
updateSuggestionPosition();
@ -340,9 +389,11 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
class="data-grid-topbar-condition-input absolute inset-x-0 top-0 h-6 min-w-0 resize-none bg-transparent outline-none"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
style="height: 24px"
@focus="resizeEditor(true)"
@focus="onFocus"
@blur="scheduleCollapse"
@click="resizeEditor(true)"
@click="onClick"
@select="onSelectionChange"
@keyup="onSelectionChange"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@input="onInput"
@ -373,6 +424,10 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--expanded absolute resize-none outline-none"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
@blur="scheduleCollapse"
@focus="onFocus"
@click="onSelectionChange"
@select="onSelectionChange"
@keyup="onSelectionChange"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@input="onInput"

View File

@ -24,6 +24,7 @@ const props = defineProps<{
orderByInput: string;
columns: readonly string[];
conditionColumns: readonly DataGridConditionColumnOption[];
identifierQuote?: string;
historyScope: DataGridConditionHistoryScope;
canUseWhereSearch: boolean;
compact: boolean;
@ -179,6 +180,7 @@ onUnmounted(onResizeEnd);
:model-value="whereInput"
kind="where"
:columns="conditionColumns"
:identifier-quote="identifierQuote"
:history-scope="historyScope"
placeholder="WHERE"
:history-empty-text="t('grid.conditionHistoryEmpty')"
@ -204,6 +206,7 @@ onUnmounted(onResizeEnd);
:model-value="orderByInput"
kind="orderBy"
:columns="conditionColumns"
:identifier-quote="identifierQuote"
:history-scope="historyScope"
placeholder="ORDER BY"
:history-empty-text="t('grid.conditionHistoryEmpty')"

View File

@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
import type { DataGridConditionHistoryKind } from "@/lib/dataGrid/dataGridConditionHistory";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function mountEditor(kind: DataGridConditionHistoryKind, initialValue: string, options: { columns?: string[]; identifierQuote?: string } = {}) {
const value = ref(initialValue);
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(DataGridConditionEditor, {
kind,
modelValue: value.value,
"onUpdate:modelValue": (nextValue: string) => (value.value = nextValue),
historyScope: {},
columns: options.columns,
identifierQuote: options.identifierQuote,
});
},
}),
);
app.mount(host);
mountedApps.push({ app, host });
return { value, input: host.querySelector("textarea") as HTMLTextAreaElement };
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGridConditionEditor quote completion", () => {
it("inserts paired quotes in WHERE and places the caret between them", async () => {
const { value, input } = mountEditor("where", "id = ");
input.focus();
input.setSelectionRange(5, 5);
const event = new KeyboardEvent("keydown", { key: "'", bubbles: true, cancelable: true });
input.dispatchEvent(event);
await nextTick();
expect(event.defaultPrevented).toBe(true);
expect(value.value).toBe("id = ''");
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(6);
});
it("wraps selected WHERE text and skips an existing closing quote", async () => {
const { value, input } = mountEditor("where", "name");
input.focus();
input.setSelectionRange(0, 4);
input.dispatchEvent(new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true }));
await nextTick();
expect(value.value).toBe('"name"');
expect(input.selectionStart).toBe(1);
expect(input.selectionEnd).toBe(5);
input.setSelectionRange(5, 5);
input.dispatchEvent(new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true }));
await nextTick();
expect(value.value).toBe('"name"');
expect(input.selectionStart).toBe(6);
});
it("does not intercept quotes in ORDER BY", () => {
const { value, input } = mountEditor("orderBy", "name");
input.focus();
input.setSelectionRange(4, 4);
const event = new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(value.value).toBe("name");
});
it("passes the textarea caret range through when accepting a suggestion", async () => {
const { value, input } = mountEditor("where", "status = cus AND enabled = 1", { columns: ["customer_id"] });
input.focus();
input.setSelectionRange(12, 12);
input.dispatchEvent(new Event("select", { bubbles: true }));
await nextTick();
await vi.waitFor(() => expect(document.querySelector('[role="option"]')?.textContent).toContain("customer_id"));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
await nextTick();
expect(value.value).toBe("status = customer_id AND enabled = 1");
expect(input.selectionStart).toBe(20);
expect(input.selectionEnd).toBe(20);
});
});

View File

@ -1,6 +1,6 @@
import { effectScope, nextTick, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { completeDataGridConditionQuote, useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
const storage = new Map<string, string>();
@ -41,6 +41,155 @@ describe("useDataGridConditionEditor", () => {
expect(value.value).toBe("status = customer_name");
});
it("builds and applies suggestions at the current caret instead of the value end", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const editor = useDataGridConditionEditor({
kind: "where",
value,
selectionStart,
selectionEnd,
columns: ["customer_id", "customer_name"],
historyScope: {},
});
value.value = "status = cus AND enabled = 1";
selectionStart.value = 12;
selectionEnd.value = 12;
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"]));
expect(editor.accept(0)).toBe(true);
expect(value.value).toBe("status = customer_id AND enabled = 1");
expect(selectionStart.value).toBe(20);
expect(selectionEnd.value).toBe(20);
});
it("uses the current selection as an explicit replacement range", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const editor = useDataGridConditionEditor({ kind: "where", value, selectionStart, selectionEnd, columns: ["old_value"], historyScope: {} });
value.value = "status = old AND enabled = 1";
selectionStart.value = 9;
selectionEnd.value = 12;
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["old_value"]));
expect(editor.accept()).toBe(true);
expect(value.value).toBe("status = old_value AND enabled = 1");
});
it("suggests WHERE connectors after a completed expression", async () => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id", "owner_id"], historyScope: {} });
value.value = "owner_id = 1 a";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "AND", kind: "keyword" }]));
expect(editor.accept()).toBe(true);
expect(value.value).toBe("owner_id = 1 AND");
await nextTick();
value.value = "owner_id = 1 o";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "OR", kind: "keyword" }]));
});
it("suggests fields after a WHERE connector even when the active token is empty", async () => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id", "owner_id"], historyScope: {} });
value.value = "status = 'active' AND ";
await nextTick();
await vi.waitFor(() =>
expect(editor.suggestions.value).toEqual([
{ value: "account_id", kind: "column" },
{ value: "owner_id", kind: "column" },
]),
);
expect(editor.accept(1)).toBe(true);
expect(value.value).toBe("status = 'active' AND owner_id");
});
it("does not offer connectors inside quoted values or in ORDER BY", async () => {
const whereValue = ref("");
const whereEditor = useDataGridConditionEditor({ kind: "where", value: whereValue, columns: ["name"], historyScope: {} });
whereValue.value = "name = 'Alice a";
await nextTick();
await vi.waitFor(() => expect(whereEditor.suggestions.value).toEqual([]));
const orderByValue = ref("");
const orderByEditor = useDataGridConditionEditor({ kind: "orderBy", value: orderByValue, columns: ["amount"], historyScope: {} });
orderByValue.value = "created_at a";
await nextTick();
await vi.waitFor(() => expect(orderByEditor.suggestions.value).toEqual([]));
});
it.each(["deleted_at IS ", "deleted_at IS a", "deleted_at IS NOT o", "name LIKE o", "id IN a", "score BETWEEN o"])("does not offer connectors while the keyword operator is incomplete: %s", async (condition) => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id"], historyScope: {}, suggestionDebounceMs: 1 });
value.value = condition;
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(editor.suggestions.value).toEqual([]);
});
it("completes inside dialect quoted identifiers without treating them as strings", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const editor = useDataGridConditionEditor({
kind: "where",
value,
selectionStart,
selectionEnd,
identifierQuote: '"',
columns: [{ name: "name", insertText: '"name"' }],
historyScope: {},
});
value.value = '"na" = 1';
selectionStart.value = 3;
selectionEnd.value = 3;
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["name"]));
expect(editor.accept()).toBe(true);
expect(value.value).toBe('"name" = 1');
});
it("keeps double quotes as string delimiters when the dialect uses another identifier quote", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const editor = useDataGridConditionEditor({
kind: "where",
value,
selectionStart,
selectionEnd,
identifierQuote: "`",
columns: ["name"],
historyScope: {},
suggestionDebounceMs: 1,
});
value.value = '"na" = 1';
selectionStart.value = 3;
selectionEnd.value = 3;
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(editor.suggestions.value).toEqual([]);
});
it("pairs WHERE quotes, wraps selections, and skips an existing closing quote", () => {
expect(completeDataGridConditionQuote("id = ", 5, 5, "'")).toEqual({ value: "id = ''", selectionStart: 6, selectionEnd: 6 });
expect(completeDataGridConditionQuote("name", 0, 4, '"')).toEqual({ value: '"name"', selectionStart: 1, selectionEnd: 5 });
expect(completeDataGridConditionQuote("id = ''", 6, 6, "'")).toEqual({ value: "id = ''", selectionStart: 7, selectionEnd: 7 });
});
it("reuses column comments for field suggestions without adding them to history", async () => {
const scope = { connectionId: "connection", database: "db", tableName: "users" };
rememberDataGridConditionHistory("where", scope, "customer_id = 1");
@ -161,6 +310,32 @@ describe("useDataGridConditionEditor", () => {
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
});
it("passes the cursor text and replacement range to asynchronous providers", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const suggestionProvider = vi.fn(() => ["customer_id"]);
useDataGridConditionEditor({ kind: "where", value, selectionStart, selectionEnd, historyScope: {}, suggestionProvider });
value.value = "status = cus AND enabled = 1";
selectionStart.value = 12;
selectionEnd.value = 12;
await nextTick();
await vi.waitFor(() => expect(suggestionProvider).toHaveBeenCalledOnce());
expect(suggestionProvider).toHaveBeenCalledWith(
expect.objectContaining({
value: "status = cus AND enabled = 1",
valueBeforeCursor: "status = cus",
token: "cus",
from: 9,
to: 12,
selectionStart: 12,
selectionEnd: 12,
}),
);
});
it("loads, filters, accepts, and deletes scoped history", () => {
const scope = { connectionId: "connection", database: "db", tableName: "users" };
rememberDataGridConditionHistory("where", scope, "status = 'active'");

View File

@ -1,7 +1,7 @@
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
export type DataGridConditionSuggestionKind = "column" | "history";
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
export interface DataGridConditionColumnSuggestion {
name: string;
@ -21,7 +21,12 @@ export interface DataGridConditionSuggestion {
export interface DataGridConditionSuggestionContext {
kind: DataGridConditionHistoryKind;
value: string;
valueBeforeCursor: string;
token: string;
from: number;
to: number;
selectionStart: number;
selectionEnd: number;
signal: AbortSignal;
}
@ -30,6 +35,9 @@ export type DataGridConditionSuggestionProvider = (context: DataGridConditionSug
export interface UseDataGridConditionEditorOptions {
kind: DataGridConditionHistoryKind;
value: Ref<string>;
selectionStart?: Ref<number>;
selectionEnd?: Ref<number>;
identifierQuote?: MaybeRefOrGetter<string | undefined>;
columns?: MaybeRefOrGetter<readonly DataGridConditionColumnOption[] | undefined>;
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
suggestionProvider?: DataGridConditionSuggestionProvider;
@ -39,25 +47,152 @@ export interface UseDataGridConditionEditorOptions {
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
const WHERE_TOKEN_FORWARD_PATTERN = /^([^\s,()><=!&|]+)/;
const ORDER_BY_TOKEN_FORWARD_PATTERN = /^([^\s,()]+)/;
const WHERE_CONNECTOR_KEYWORDS = ["AND", "OR"] as const;
const WHERE_VALUE_OPERATOR_PATTERN = /(?:^|[\s(])(?:IS(?:\s+NOT)?|(?:NOT\s+)?(?:LIKE|ILIKE|IN|BETWEEN)|SIMILAR\s+TO|REGEXP|RLIKE|GLOB|MATCH)\s*$/i;
interface DataGridConditionCompletionTarget {
value: string;
valueBeforeCursor: string;
token: string;
from: number;
to: number;
selectionStart: number;
selectionEnd: number;
quotedIdentifier: boolean;
insideString: boolean;
}
interface ActiveQuote {
kind: "identifier" | "string";
close: string;
contentStart: number;
}
function normalizedColumnComment(column: DataGridConditionColumnOption): string | undefined {
if (typeof column === "string" || typeof column.comment !== "string") return undefined;
return column.comment.trim() || undefined;
}
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
return (
value
.trim()
.split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/)
.pop() ?? ""
);
function normalizedIdentifierQuote(identifierQuote: string | undefined): string | undefined {
const quote = identifierQuote?.trim();
return quote && quote !== "'" ? quote : undefined;
}
function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string {
const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
if (!match) return value;
return `${value.slice(0, -match[1].length)}${replacement}`;
function identifierCloseQuote(open: string): string {
return open === "[" ? "]" : open;
}
function activeQuoteAt(value: string, cursor: number, identifierQuote: string | undefined): ActiveQuote | undefined {
const identifierOpen = normalizedIdentifierQuote(identifierQuote);
const identifierClose = identifierOpen ? identifierCloseQuote(identifierOpen) : undefined;
let active: ActiveQuote | undefined;
for (let index = 0; index < cursor; index += 1) {
const character = value[index];
if (!active) {
if (identifierOpen && value.startsWith(identifierOpen, index)) {
active = { kind: "identifier", close: identifierClose!, contentStart: index + identifierOpen.length };
index += identifierOpen.length - 1;
} else if (character === "'" || (character === '"' && identifierOpen !== '"')) {
active = { kind: "string", close: character, contentStart: index + 1 };
}
continue;
}
if (active.kind === "string" && character === "\\") {
index += 1;
continue;
}
if (!value.startsWith(active.close, index)) continue;
if (value.startsWith(active.close + active.close, index) && index + active.close.length * 2 <= cursor) {
index += active.close.length * 2 - 1;
continue;
}
index += active.close.length - 1;
active = undefined;
}
return active;
}
function clampedSelection(value: string, selectionStart: number | undefined, selectionEnd: number | undefined): { start: number; end: number } {
const start = Math.min(Math.max(selectionStart ?? value.length, 0), value.length);
const end = Math.min(Math.max(selectionEnd ?? start, start), value.length);
return { start, end };
}
function conditionCompletionTarget(kind: DataGridConditionHistoryKind, value: string, selectionStart: number | undefined, selectionEnd: number | undefined, identifierQuote: string | undefined): DataGridConditionCompletionTarget {
const selection = clampedSelection(value, selectionStart, selectionEnd);
const valueBeforeCursor = value.slice(0, selection.start);
const quote = kind === "where" ? activeQuoteAt(value, selection.start, identifierQuote) : undefined;
if (quote?.kind === "string") {
return { value, valueBeforeCursor, token: "", from: selection.start, to: selection.end, selectionStart: selection.start, selectionEnd: selection.end, quotedIdentifier: false, insideString: true };
}
if (quote?.kind === "identifier") {
const closeIndex = selection.start === selection.end ? value.indexOf(quote.close, selection.start) : -1;
return {
value,
valueBeforeCursor,
token: selection.start === selection.end ? value.slice(quote.contentStart, selection.start) : value.slice(selection.start, selection.end),
from: selection.start === selection.end ? quote.contentStart : selection.start,
to: selection.start === selection.end && closeIndex >= 0 ? closeIndex : selection.end,
selectionStart: selection.start,
selectionEnd: selection.end,
quotedIdentifier: true,
insideString: false,
};
}
if (selection.start !== selection.end) {
return {
value,
valueBeforeCursor,
token: value.slice(selection.start, selection.end),
from: selection.start,
to: selection.end,
selectionStart: selection.start,
selectionEnd: selection.end,
quotedIdentifier: false,
insideString: false,
};
}
const beforeMatch = valueBeforeCursor.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
const token = beforeMatch?.[1] ?? "";
const afterMatch = value.slice(selection.start).match(kind === "where" ? WHERE_TOKEN_FORWARD_PATTERN : ORDER_BY_TOKEN_FORWARD_PATTERN);
return {
value,
valueBeforeCursor,
token,
from: selection.start - token.length,
to: selection.start + (afterMatch?.[1].length ?? 0),
selectionStart: selection.start,
selectionEnd: selection.end,
quotedIdentifier: false,
insideString: false,
};
}
function whereSuggestionRole(target: DataGridConditionCompletionTarget): "field" | "connector" | "none" {
if (target.insideString) return "none";
if (target.quotedIdentifier) return "field";
const prefix = target.value.slice(0, target.from).trimEnd();
if (WHERE_VALUE_OPERATOR_PATTERN.test(prefix)) return "none";
if (!prefix || /(?:^|\s)(?:AND|OR|NOT)$/i.test(prefix) || /[,(<>=!~+\-*/]$/.test(prefix)) return "field";
return "connector";
}
export interface DataGridConditionQuoteCompletion {
value: string;
selectionStart: number;
selectionEnd: number;
}
export function completeDataGridConditionQuote(value: string, selectionStart: number, selectionEnd: number, quote: "'" | '"'): DataGridConditionQuoteCompletion {
if (selectionStart === selectionEnd && value[selectionStart] === quote) {
return { value, selectionStart: selectionStart + 1, selectionEnd: selectionStart + 1 };
}
const selected = value.slice(selectionStart, selectionEnd);
const nextValue = `${value.slice(0, selectionStart)}${quote}${selected}${quote}${value.slice(selectionEnd)}`;
if (selected) return { value: nextValue, selectionStart: selectionStart + 1, selectionEnd: selectionEnd + 1 };
return { value: nextValue, selectionStart: selectionStart + 1, selectionEnd: selectionStart + 1 };
}
export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) {
@ -65,6 +200,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
const highlightedIndex = ref(-1);
const historyOpen = ref(false);
const suggestionsLoading = ref(false);
const replacementRange = ref<{ from: number; to: number }>();
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
let suggestionRequestId = 0;
let suggestionAbortController: AbortController | undefined;
@ -86,35 +222,48 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
replacementRange.value = undefined;
}
function defaultSuggestions(token: string): DataGridConditionSuggestion[] {
const normalizedToken = token.toLowerCase();
if (!normalizedToken) return [];
function defaultSuggestions(target: DataGridConditionCompletionTarget): DataGridConditionSuggestion[] {
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
if (role === "none") return [];
const normalizedToken = target.token.toLowerCase();
const seen = new Set<string>();
const suggestions: DataGridConditionSuggestion[] = [];
for (const column of toValue(options.columns) ?? []) {
const value = typeof column === "string" ? column : column.name;
const normalizedValue = value.toLowerCase();
if (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken || seen.has(value)) continue;
seen.add(value);
const comment = normalizedColumnComment(column);
const insertText = typeof column === "string" ? value : column.insertText;
suggestions.push({ value, kind: "column", ...(insertText !== undefined && insertText !== value ? { insertText } : {}), ...(comment ? { comment } : {}) });
if (role === "field") {
for (const column of toValue(options.columns) ?? []) {
const columnValue = typeof column === "string" ? column : column.name;
const normalizedValue = columnValue.toLowerCase();
if ((normalizedToken && (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken)) || seen.has(columnValue)) continue;
seen.add(columnValue);
const comment = normalizedColumnComment(column);
const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText;
suggestions.push({ value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) });
}
} else {
for (const keyword of WHERE_CONNECTOR_KEYWORDS) {
if (!keyword.toLowerCase().startsWith(normalizedToken) || keyword.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: keyword, kind: "keyword" });
}
}
return suggestions;
}
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
const token = activeToken(options.kind, value);
if (!token) return;
async function loadSuggestions(target: DataGridConditionCompletionTarget, requestId: number, controller: AbortController) {
if (!target.token && (options.kind !== "where" || !target.value.trim())) return;
suggestionsLoading.value = true;
try {
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : undefined;
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
const values =
options.suggestionProvider && target.token && role === "field"
? await options.suggestionProvider({ kind: options.kind, value: target.value, valueBeforeCursor: target.valueBeforeCursor, token: target.token, from: target.from, to: target.to, selectionStart: target.selectionStart, selectionEnd: target.selectionEnd, signal: controller.signal })
: undefined;
// A slower request must never replace suggestions for a newer editor value.
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== target.value || historyOpen.value) return;
const limit = options.suggestionLimit ?? 8;
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(token).slice(0, limit);
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(target).slice(0, limit);
replacementRange.value = { from: target.from, to: target.to };
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
} catch (error) {
if (!controller.signal.aborted && requestId === suggestionRequestId) {
@ -127,19 +276,21 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
}
}
function scheduleSuggestions(value: string) {
function scheduleSuggestions(value: string, selectionStart = options.selectionStart?.value, selectionEnd = options.selectionEnd?.value) {
cancelSuggestionRequest();
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
if (!value.trim()) return;
const target = conditionCompletionTarget(options.kind, value, selectionStart, selectionEnd, toValue(options.identifierQuote));
const requestId = suggestionRequestId;
const controller = new AbortController();
suggestionAbortController = controller;
suggestionTimer = setTimeout(() => {
suggestionTimer = undefined;
void loadSuggestions(value, requestId, controller);
void loadSuggestions(target, requestId, controller);
}, options.suggestionDebounceMs ?? 0);
}
@ -150,6 +301,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
return;
}
historyOpen.value = true;
replacementRange.value = undefined;
suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" }));
highlightedIndex.value = -1;
}
@ -179,9 +331,22 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
function accept(index = highlightedIndex.value) {
const suggestion = suggestions.value[index];
if (!suggestion) return false;
suppressNextValueSuggestion = true;
// History is already executable SQL; only fresh column suggestions apply dialect quoting.
options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.insertText ?? suggestion.value);
let caret: number;
if (suggestion.kind === "history") {
suppressNextValueSuggestion = true;
options.value.value = suggestion.value;
caret = suggestion.value.length;
} else {
const range = replacementRange.value;
const currentTarget = conditionCompletionTarget(options.kind, options.value.value, options.selectionStart?.value, options.selectionEnd?.value, toValue(options.identifierQuote));
if (!range || currentTarget.from !== range.from || currentTarget.to !== range.to) return false;
const replacement = suggestion.insertText ?? suggestion.value;
suppressNextValueSuggestion = true;
options.value.value = `${options.value.value.slice(0, range.from)}${replacement}${options.value.value.slice(range.to)}`;
caret = range.from + replacement.length;
}
if (options.selectionStart) options.selectionStart.value = caret;
if (options.selectionEnd) options.selectionEnd.value = caret;
dismiss();
return true;
}
@ -199,28 +364,30 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
return "navigate";
}
if (suggestions.value.length > 0 && event.key === "Tab") {
if (!accept()) return undefined;
event.preventDefault();
accept();
return "accept";
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
accept();
return "accept";
if (accept()) return "accept";
}
return "apply";
}
return undefined;
}
watch(options.value, (value) => {
if (suppressNextValueSuggestion) {
suppressNextValueSuggestion = false;
return;
}
scheduleSuggestions(value);
});
watch(
() => [options.value.value, options.selectionStart?.value, options.selectionEnd?.value] as const,
([value, selectionStart, selectionEnd]) => {
if (suppressNextValueSuggestion) {
suppressNextValueSuggestion = false;
return;
}
scheduleSuggestions(value, selectionStart, selectionEnd);
},
);
if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest);
return {
@ -228,6 +395,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
highlightedIndex,
historyOpen,
suggestionsLoading,
replacementRange,
dropdownOpen,
scheduleSuggestions,
openHistory,

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { dataGridConditionColumnOptions } from "@/lib/dataGrid/dataGridConditionCompletion";
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
describe("dataGridConditionColumnOptions", () => {
it("reuses PostgreSQL completion quoting while preserving display metadata", () => {
@ -17,4 +17,12 @@ describe("dataGridConditionColumnOptions", () => {
{ name: "order", insertText: "order" },
]);
});
it("uses the active dialect identifier quote while preserving runtime overrides", () => {
expect(dataGridConditionIdentifierQuote("postgres")).toBe('"');
expect(dataGridConditionIdentifierQuote("oracle")).toBe('"');
expect(dataGridConditionIdentifierQuote("sqlite")).toBe('"');
expect(dataGridConditionIdentifierQuote("mysql")).toBe("`");
expect(dataGridConditionIdentifierQuote("kingbase", "`")).toBe("`");
});
});

View File

@ -34,10 +34,11 @@ describe("getDataGridConditionSuggestionPosition", () => {
expect(position.width).toBeLessThanOrEqual(520);
});
test("keeps normal width when suggestions have no comments or are history entries", () => {
test("keeps normal width when suggestions have no comments or are keyword/history entries", () => {
expect(
getDataGridConditionSuggestionPreferredWidth([
{ value: "customer_id", kind: "column", comment: "" },
{ value: "AND", kind: "keyword" },
{ value: "status = 'active'", kind: "history" },
]),
).toBeUndefined();

View File

@ -1,6 +1,7 @@
import type { DataGridConditionColumnOption } from "@/composables/useDataGridConditionEditor";
import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
import { quoteSqlIdentifier } from "@/lib/sql/sqlCompletion";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType } from "@/types/database";
export function dataGridConditionColumnOptions(columns: readonly DataGridConditionColumnOption[], databaseType?: DatabaseType): DataGridConditionColumnOption[] {
@ -12,3 +13,8 @@ export function dataGridConditionColumnOptions(columns: readonly DataGridConditi
return { name, insertText, ...(comment !== undefined ? { comment } : {}) };
});
}
export function dataGridConditionIdentifierQuote(databaseType?: DatabaseType, runtimeQuote?: string): string | undefined {
if (runtimeQuote !== undefined) return runtimeQuote || undefined;
return sqlSemanticDialectFor({ databaseType }).identifierQuotes[0]?.open;
}

View File

@ -21,7 +21,7 @@ export interface DataGridConditionSuggestionPosition {
export interface DataGridConditionSuggestionContent {
value: string;
kind: "column" | "history";
kind: "column" | "keyword" | "history";
comment?: string | null;
}