mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:20:02 +00:00
feat(grid): filter WHERE syntax suggestions by dialect
This commit is contained in:
@@ -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')"
|
||||
|
||||
+173
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user