From f995e060a8aa7b11fc5576583c259b40fb328c56 Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:29:03 +0800 Subject: [PATCH] feat(grid): filter WHERE syntax suggestions by dialect --- apps/desktop/src/components/grid/DataGrid.vue | 1 + .../grid/DataGridConditionEditor.vue | 3 + .../components/grid/DataGridQueryControls.vue | 4 + .../useDataGridConditionEditor.spec.ts | 174 +++++++++++++++++- .../composables/useDataGridConditionEditor.ts | 84 +++++---- 5 files changed, 230 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2d677948f6..1e25fba09d 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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" diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue index aa612e063a..e3ba477171 100644 --- a/apps/desktop/src/components/grid/DataGridConditionEditor.vue +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -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, diff --git a/apps/desktop/src/components/grid/DataGridQueryControls.vue b/apps/desktop/src/components/grid/DataGridQueryControls.vue index 57a18522df..99441d70d6 100644 --- a/apps/desktop/src/components/grid/DataGridQueryControls.vue +++ b/apps/desktop/src/components/grid/DataGridQueryControls.vue @@ -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; 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')" diff --git a/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts index 8a8b4a99c2..45826cefc9 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts @@ -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(); 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("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" }])); + }); }); diff --git a/apps/desktop/src/composables/useDataGridConditionEditor.ts b/apps/desktop/src/composables/useDataGridConditionEditor.ts index cc15097a85..9cbb094688 100644 --- a/apps/desktop/src/composables/useDataGridConditionEditor.ts +++ b/apps/desktop/src/composables/useDataGridConditionEditor.ts @@ -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; + databaseType?: MaybeRefOrGetter; } 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 = new Set(["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 = new Set(["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(); - 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) {