feat(grid): filter WHERE syntax suggestions by dialect

This commit is contained in:
lxk955
2026-10-08 00:29:03 +08:00
committed by GitHub
parent 83efdfb65e
commit f995e060a8
5 changed files with 230 additions and 36 deletions
@@ -12955,6 +12955,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:comment-by-column="columnCommentMap"
:condition-columns="conditionColumns"
:identifier-quote="conditionIdentifierQuote"
:database-type="resolvedDatabaseType"
:history-scope="conditionHistoryScope"
:can-use-where-search="canUseWhereSearch"
:compact="compactDataGridToolbar"
@@ -5,6 +5,7 @@ import { completeDataGridConditionQuote, useDataGridConditionEditor, type DataGr
import { tokenizeDataGridCondition, type DataGridConditionTokenType } from "@/lib/dataGrid/dataGridConditionHighlight";
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import type { DatabaseType } from "@/types/database";
const props = withDefaults(
defineProps<{
@@ -16,6 +17,7 @@ const props = withDefaults(
historyEmptyText?: string;
historyNoMatchesText?: string;
identifierQuote?: string;
databaseType?: DatabaseType;
suggestionProvider?: DataGridConditionSuggestionProvider;
suggestionDebounceMs?: number;
disabled?: boolean;
@@ -80,6 +82,7 @@ const editor = useDataGridConditionEditor({
selectionStart,
selectionEnd,
identifierQuote: () => props.identifierQuote,
databaseType: () => props.databaseType,
columns: () => props.columns,
historyScope: () => props.historyScope,
suggestionProvider: props.suggestionProvider,
@@ -15,6 +15,7 @@ import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { DataGridFilterEditorView } from "@/stores/settingsStore";
import type { DatabaseType } from "@/types/database";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
type LocalFilterSummary = {
@@ -31,6 +32,7 @@ const props = defineProps<{
commentByColumn?: ReadonlyMap<string, string>;
conditionColumns: readonly DataGridConditionColumnOption[];
identifierQuote?: string;
databaseType?: DatabaseType;
historyScope: DataGridConditionHistoryScope;
canUseWhereSearch: boolean;
compact: boolean;
@@ -305,6 +307,7 @@ onUnmounted(onResizeEnd);
kind="where"
:columns="conditionColumns"
:identifier-quote="identifierQuote"
:database-type="databaseType"
:history-scope="historyScope"
placeholder="WHERE"
:history-empty-text="t('grid.conditionHistoryEmpty')"
@@ -377,6 +380,7 @@ onUnmounted(onResizeEnd);
kind="orderBy"
:columns="conditionColumns"
:identifier-quote="identifierQuote"
:database-type="databaseType"
:history-scope="historyScope"
placeholder="ORDER BY"
:history-empty-text="t('grid.conditionHistoryEmpty')"
@@ -1,7 +1,8 @@
import { effectScope, nextTick, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { completeDataGridConditionQuote, useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { completeDataGridConditionQuote, isConditionKeywordSupported, supportsConditionIlike, supportsConditionRegexp, useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
import type { DatabaseType } from "@/types/database";
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
@@ -624,4 +625,175 @@ describe("useDataGridConditionEditor", () => {
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "AND", kind: "keyword" }]));
});
it("checks dialect support for ILIKE and REGEXP operators", () => {
expect(supportsConditionIlike(undefined)).toBe(true);
expect(supportsConditionIlike("postgres")).toBe(true);
expect(supportsConditionIlike("duckdb")).toBe(true);
expect(supportsConditionIlike("clickhouse")).toBe(true);
expect(supportsConditionIlike("mysql")).toBe(false);
expect(supportsConditionIlike("sqlserver")).toBe(false);
expect(supportsConditionIlike("oracle")).toBe(false);
expect(supportsConditionIlike("sqlite")).toBe(false);
expect(supportsConditionRegexp(undefined)).toBe(true);
expect(supportsConditionRegexp("mysql")).toBe(true);
expect(supportsConditionRegexp("doris")).toBe(true);
// SQLite parses REGEXP but needs a driver-registered regexp() function DBX does not provide
expect(supportsConditionRegexp("sqlite")).toBe(false);
expect(supportsConditionRegexp("turso")).toBe(false);
expect(supportsConditionRegexp("hive")).toBe(true);
expect(supportsConditionRegexp("impala")).toBe(true);
expect(supportsConditionRegexp("postgres")).toBe(false);
expect(supportsConditionRegexp("sqlserver")).toBe(false);
expect(supportsConditionRegexp("oracle")).toBe(false);
expect(supportsConditionRegexp("duckdb")).toBe(false);
expect(isConditionKeywordSupported("BETWEEN", "mysql")).toBe(true);
expect(isConditionKeywordSupported("ILIKE", "mysql")).toBe(false);
expect(isConditionKeywordSupported("ILIKE", "postgres")).toBe(true);
expect(isConditionKeywordSupported("REGEXP", "mysql")).toBe(true);
expect(isConditionKeywordSupported("REGEXP", "postgres")).toBe(false);
});
it("filters WHERE syntax keywords per dialect and does not suggest EXISTS in operator positions", async () => {
// 1. MySQL: REGEXP supported, ILIKE not supported, EXISTS not suggested in operator position
const mysqlValue = ref("");
const mysqlEditor = useDataGridConditionEditor({
kind: "where",
value: mysqlValue,
columns: ["score", "name"],
databaseType: "mysql",
historyScope: {},
suggestionDebounceMs: 1,
});
mysqlValue.value = "score re";
await nextTick();
await vi.waitFor(() => expect(mysqlEditor.suggestions.value).toEqual([{ value: "REGEXP", kind: "keyword" }]));
mysqlValue.value = "score il";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(mysqlEditor.suggestions.value).toEqual([]);
mysqlValue.value = "score ex";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(mysqlEditor.suggestions.value).toEqual([]);
mysqlValue.value = "score not il";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(mysqlEditor.suggestions.value).toEqual([]);
mysqlValue.value = "score not ex";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(mysqlEditor.suggestions.value).toEqual([]);
// 2. SQL Server: neither REGEXP nor ILIKE supported, EXISTS not suggested
const sqlserverValue = ref("");
const sqlserverEditor = useDataGridConditionEditor({
kind: "where",
value: sqlserverValue,
columns: ["score", "name"],
databaseType: "sqlserver",
historyScope: {},
suggestionDebounceMs: 1,
});
sqlserverValue.value = "score re";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(sqlserverEditor.suggestions.value).toEqual([]);
sqlserverValue.value = "score il";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(sqlserverEditor.suggestions.value).toEqual([]);
sqlserverValue.value = "score ex";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(sqlserverEditor.suggestions.value).toEqual([]);
// 3. PostgreSQL: ILIKE and NOT ILIKE supported, REGEXP not supported as binary keyword
const pgValue = ref("");
const pgEditor = useDataGridConditionEditor({
kind: "where",
value: pgValue,
columns: ["score", "name"],
databaseType: "postgres",
historyScope: {},
suggestionDebounceMs: 1,
});
pgValue.value = "score re";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(pgEditor.suggestions.value).toEqual([]);
pgValue.value = "score il";
await nextTick();
await vi.waitFor(() => expect(pgEditor.suggestions.value).toEqual([{ value: "ILIKE", kind: "keyword" }]));
// NOT ILIKE is offered after NOT on PostgreSQL
pgValue.value = "score not il";
await nextTick();
await vi.waitFor(() => expect(pgEditor.suggestions.value).toEqual([{ value: "ILIKE", kind: "keyword" }]));
pgValue.value = "score ex";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(pgEditor.suggestions.value).toEqual([]);
// 4. Dialect-agnostic (undefined): keeps both ILIKE and REGEXP for fallback compatibility
const genericValue = ref("");
const genericEditor = useDataGridConditionEditor({
kind: "where",
value: genericValue,
columns: ["score", "name"],
historyScope: {},
suggestionDebounceMs: 1,
});
genericValue.value = "score re";
await nextTick();
await vi.waitFor(() => expect(genericEditor.suggestions.value).toEqual([{ value: "REGEXP", kind: "keyword" }]));
genericValue.value = "score il";
await nextTick();
await vi.waitFor(() => expect(genericEditor.suggestions.value).toEqual([{ value: "ILIKE", kind: "keyword" }]));
genericValue.value = "score ex";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(genericEditor.suggestions.value).toEqual([]);
});
it("reactively updates keyword suggestions when databaseType changes", async () => {
const dbType = ref<DatabaseType | undefined>("mysql");
const value = ref("");
const editor = useDataGridConditionEditor({
kind: "where",
value,
columns: ["score"],
databaseType: dbType,
historyScope: {},
suggestionDebounceMs: 1,
});
// MySQL: no ILIKE
value.value = "score il";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(editor.suggestions.value).toEqual([]);
// Switch to PostgreSQL: ILIKE becomes available
dbType.value = "postgres";
value.value = "score ili";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "ILIKE", kind: "keyword" }]));
});
});
@@ -2,6 +2,7 @@ import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type Ma
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import { pinyinAwareMatchScore } from "@/lib/common/pinyin";
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
import type { DatabaseType } from "@/types/database";
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
@@ -46,6 +47,7 @@ export interface UseDataGridConditionEditorOptions {
suggestionDebounceMs?: number;
suggestionLimit?: number;
suggestionsEnabled?: MaybeRefOrGetter<boolean>;
databaseType?: MaybeRefOrGetter<DatabaseType | undefined>;
}
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
@@ -73,15 +75,52 @@ export const WHERE_SYNTAX_KEYWORDS: readonly DataGridConditionKeyword[] = [
{ value: "IS" },
{ value: "NOT" },
{ value: "REGEXP" },
{ value: "EXISTS" },
{ value: "NOT EXISTS" },
];
export const WHERE_IS_KEYWORDS: readonly DataGridConditionKeyword[] = [{ value: "NULL" }, { value: "NOT NULL" }, { value: "TRUE" }, { value: "FALSE" }];
export const WHERE_IS_NOT_KEYWORDS: readonly DataGridConditionKeyword[] = [{ value: "NULL" }, { value: "TRUE" }, { value: "FALSE" }];
export const WHERE_AFTER_NOT_KEYWORDS: readonly DataGridConditionKeyword[] = [{ value: "BETWEEN", comment: "BETWEEN ... AND ..." }, { value: "IN" }, { value: "LIKE" }, { value: "ILIKE" }, { value: "EXISTS" }];
export const WHERE_AFTER_NOT_KEYWORDS: readonly DataGridConditionKeyword[] = [{ value: "BETWEEN", comment: "BETWEEN ... AND ..." }, { value: "IN" }, { value: "LIKE" }, { value: "ILIKE" }];
const ILIKE_SUPPORTED_DATABASES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["postgres", "redshift", "duckdb", "snowflake", "clickhouse", "databend", "kingbase", "highgo", "uxdb", "vastbase", "gaussdb", "opengauss", "questdb", "vertica", "databricks", "kwdb", "h2"]);
// SQLite parses `x REGEXP y` but needs a driver-registered regexp() function,
// which DBX does not provide, so the SQLite family is excluded; Hive-family
// dialects ship the REGEXP/RLIKE binary operator.
const REGEXP_OPERATOR_SUPPORTED_DATABASES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "manticoresearch", "gbase", "hive", "spark", "kyuubi", "impala"]);
export function supportsConditionIlike(databaseType?: DatabaseType): boolean {
return !databaseType || ILIKE_SUPPORTED_DATABASES.has(databaseType);
}
export function supportsConditionRegexp(databaseType?: DatabaseType): boolean {
return !databaseType || REGEXP_OPERATOR_SUPPORTED_DATABASES.has(databaseType);
}
export function isConditionKeywordSupported(keyword: string, databaseType?: DatabaseType): boolean {
if (!databaseType) return true;
if (keyword === "ILIKE" || keyword === "NOT ILIKE") {
return supportsConditionIlike(databaseType);
}
if (keyword === "REGEXP") {
return supportsConditionRegexp(databaseType);
}
return true;
}
function filterKeywordSuggestions(keywords: readonly (DataGridConditionKeyword | string)[], normalizedToken: string, predicate?: (value: string) => boolean): DataGridConditionSuggestion[] {
const result: DataGridConditionSuggestion[] = [];
for (const keyword of keywords) {
const value = typeof keyword === "string" ? keyword : keyword.value;
const comment = typeof keyword === "string" ? undefined : keyword.comment;
if (predicate && !predicate(value)) continue;
const lower = value.toLowerCase();
if (normalizedToken && (!lower.startsWith(normalizedToken) || lower === normalizedToken)) continue;
result.push({ value, kind: "keyword", ...(comment ? { comment } : {}) });
}
return result;
}
const WHERE_CONNECTOR_KEYWORDS = ["AND", "OR"] as const;
const WHERE_IS_NOT_OPERATOR_PATTERN = /(?:^|[\s(])IS\s+NOT$/i;
@@ -358,57 +397,32 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
if (role === "none") return [];
const normalizedToken = target.token.toLowerCase();
const seen = new Set<string>();
const suggestions: DataGridConditionSuggestion[] = [];
const isKeywordSupported = (keyword: string) => isConditionKeywordSupported(keyword, toValue(options.databaseType));
if (role === "field") {
const columns = toValue(options.columns) ?? [];
return columnSuggestions(columns, normalizedToken, target, seen);
}
if (role === "after_not") {
const columns = toValue(options.columns) ?? [];
suggestions.push(...columnSuggestions(columns, normalizedToken, target, seen));
const suggestions = columnSuggestions(columns, normalizedToken, target, seen);
if (normalizedToken) {
for (const keyword of WHERE_AFTER_NOT_KEYWORDS) {
if (!keyword.value.toLowerCase().startsWith(normalizedToken) || keyword.value.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: keyword.value, kind: "keyword", ...(keyword.comment ? { comment: keyword.comment } : {}) });
}
suggestions.push(...filterKeywordSuggestions(WHERE_AFTER_NOT_KEYWORDS, normalizedToken, isKeywordSupported));
}
return suggestions;
}
if (role === "is_value") {
if (normalizedToken) {
for (const keyword of WHERE_IS_KEYWORDS) {
if (!keyword.value.toLowerCase().startsWith(normalizedToken) || keyword.value.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: keyword.value, kind: "keyword", ...(keyword.comment ? { comment: keyword.comment } : {}) });
}
}
return suggestions;
return normalizedToken ? filterKeywordSuggestions(WHERE_IS_KEYWORDS, normalizedToken) : [];
}
if (role === "is_not_value") {
if (normalizedToken) {
for (const keyword of WHERE_IS_NOT_KEYWORDS) {
if (!keyword.value.toLowerCase().startsWith(normalizedToken) || keyword.value.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: keyword.value, kind: "keyword", ...(keyword.comment ? { comment: keyword.comment } : {}) });
}
}
return suggestions;
return normalizedToken ? filterKeywordSuggestions(WHERE_IS_NOT_KEYWORDS, normalizedToken) : [];
}
if (options.kind === "where") {
// WHERE 已写完一个表达式时提示连接符;在列或操作符位置提示 SQL 语法及连接符
const keywords = normalizedToken ? WHERE_SYNTAX_KEYWORDS : WHERE_CONNECTOR_KEYWORDS;
for (const keyword of keywords) {
const val = typeof keyword === "string" ? keyword : keyword.value;
const comment = typeof keyword === "string" ? undefined : keyword.comment;
if (!val.toLowerCase().startsWith(normalizedToken) || val.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: val, kind: "keyword", ...(comment ? { comment } : {}) });
}
return suggestions;
return filterKeywordSuggestions(keywords, normalizedToken, isKeywordSupported);
}
// ORDER BY 已写出排序列时提示排序方向。
for (const keyword of ORDER_BY_DIRECTION_KEYWORDS) {
if (!keyword.toLowerCase().startsWith(normalizedToken) || keyword.toLowerCase() === normalizedToken) continue;
suggestions.push({ value: keyword, kind: "keyword" });
}
return suggestions;
return filterKeywordSuggestions(ORDER_BY_DIRECTION_KEYWORDS, normalizedToken);
}
async function loadSuggestions(target: DataGridConditionCompletionTarget, requestId: number, controller: AbortController) {