diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index ddd959f20..bf2a2c997 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -720,7 +720,9 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
{ value: "like", labelKey: "grid.filterBuilderContains" },
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
+ { value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
+ { value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
{ value: "in", labelKey: "grid.filterBuilderIn" },
{ value: "not-in", labelKey: "grid.filterBuilderNotIn" },
{ value: "between", labelKey: "grid.filterBuilderBetween" },
@@ -1565,8 +1567,8 @@ watch(
[structuredFilterRules, appliedStructuredWhereInput, serverColumnFilters],
() => {
const columns = filterBuilderColumnOptions.value;
- if (columns.length > 0 && structuredFilterRules.value.some((rule) => !columns.includes(rule.columnName))) {
- structuredFilterRules.value = structuredFilterRules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
+ if (columns.length > 0 && structuredFilterRules.value.some((rule) => rule.columnName && !columns.includes(rule.columnName))) {
+ structuredFilterRules.value = structuredFilterRules.value.map((rule) => (!rule.columnName || columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
return;
}
persistStructuredFilterState();
diff --git a/apps/desktop/src/components/grid/DataGridFilterBuilder.vue b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue
index fc2f8aa6a..bf14dc53c 100644
--- a/apps/desktop/src/components/grid/DataGridFilterBuilder.vue
+++ b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue
@@ -134,6 +134,13 @@ async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open:
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
}
+async function openFirstEmptyRuleColumnSearch() {
+ const rule = props.rules.find((item) => !item.columnName && !item.disabled);
+ if (rule) await handleColumnSelectOpen(rule, true);
+}
+
+defineExpose({ openFirstEmptyRuleColumnSearch });
+
function handleColumnCloseAutoFocus(id: string, event: Event) {
if (pendingKeyboardAddFocus.delete(id)) {
event.preventDefault();
@@ -263,7 +270,7 @@ function blurValueRule(id: string) {
-
+
{
expect(document.querySelector('[role="option"][aria-selected="true"]')?.textContent).toContain("name");
});
+ it("keeps suggestions closed after Enter applies a complete condition", async () => {
+ const { input } = mountEditor("where", "", { columns: ["id", "order0", "status"] });
+ input.focus();
+ input.value = "id > 0";
+ input.setSelectionRange(6, 6);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
+
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ await nextTick();
+ input.setSelectionRange(0, 0);
+ input.dispatchEvent(new Event("select", { bubbles: true }));
+ await nextTick();
+
+ expect(document.querySelector('[role="listbox"]')).toBeNull();
+ });
+
it("keeps expanded input first-line indent and wraps long tokens", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?[\s\S]*?)\n\}/)?.groups?.body;
diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts
index c544a1326..7e32ea226 100644
--- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts
+++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts
@@ -387,6 +387,21 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
+ it("opens the first empty rule column search on request", async () => {
+ const mounted = mountComponent(DataGridFilterBuilder, {
+ rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
+ columns: ["id"],
+ filteredColumns: ["id"],
+ modeOptions: [{ value: "equals", labelKey: "equals" }],
+ columnSearch: "",
+ });
+
+ await mounted.exposed.value.openFirstEmptyRuleColumnSearch();
+
+ const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
+ expect(columnSelect.props.open).toBe(true);
+ });
+
it("keeps selected columns and values readable without stretching the controls", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
@@ -400,7 +415,7 @@ describe("DataGridFilterBuilder", () => {
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
- const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]"));
+ const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@@ -414,7 +429,7 @@ describe("DataGridFilterBuilder", () => {
expect(items.every((item) => String(item.props.class).includes("rounded-none"))).toBe(true);
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
- expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]");
+ expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@@ -616,6 +631,88 @@ describe("DataGridFilterBuilder", () => {
});
describe("DataGridQueryControls", () => {
+ it("opens column search when the filter button creates the first rule", async () => {
+ let mounted: ReturnType;
+ const firstRule = { id: "r1", columnName: "", mode: "equals" as const, rawValue: "", rawEndValue: "", conjunction: "AND" as const };
+ const ensureRule = vi.fn(() => {
+ void mounted.setProps({ rules: [firstRule], filterBuilderOpen: true });
+ });
+ mounted = mountComponent(DataGridQueryControls, {
+ whereInput: "",
+ orderByInput: "",
+ columns: ["id"],
+ conditionColumns: ["id"],
+ historyScope: {},
+ canUseWhereSearch: true,
+ compact: false,
+ leadingBorder: false,
+ filterBuilderOpen: false,
+ filterButtonActive: false,
+ filterButtonCount: 0,
+ hasLocalColumnFilters: false,
+ localFilterCount: 0,
+ localFilterSummaries: [],
+ rules: [],
+ filteredColumns: ["id"],
+ modeOptions: [{ value: "equals", labelKey: "equals" }],
+ columnSearch: "",
+ applyWhere: vi.fn(),
+ applyOrderBy: vi.fn(),
+ clearOrderBy: vi.fn(),
+ onEnsureRule: ensureRule,
+ });
+
+ const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
+ dispatch(filterButton, "click");
+ await nextTick();
+ await nextTick();
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ await nextTick();
+
+ const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
+ expect(ensureRule).toHaveBeenCalledOnce();
+ expect(columnSelect.props.open).toBe(true);
+ });
+
+ it("does not open column search when filter rules already exist", async () => {
+ let mounted: ReturnType;
+ const ensureRule = vi.fn(() => {
+ void mounted.setProps({ filterBuilderOpen: true });
+ });
+ mounted = mountComponent(DataGridQueryControls, {
+ whereInput: "id = 1",
+ orderByInput: "",
+ columns: ["id"],
+ conditionColumns: ["id"],
+ historyScope: {},
+ canUseWhereSearch: true,
+ compact: false,
+ leadingBorder: false,
+ filterBuilderOpen: false,
+ filterButtonActive: true,
+ filterButtonCount: 1,
+ hasLocalColumnFilters: false,
+ localFilterCount: 0,
+ localFilterSummaries: [],
+ rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
+ filteredColumns: ["id"],
+ modeOptions: [{ value: "equals", labelKey: "equals" }],
+ columnSearch: "",
+ applyWhere: vi.fn(),
+ applyOrderBy: vi.fn(),
+ clearOrderBy: vi.fn(),
+ onEnsureRule: ensureRule,
+ });
+
+ const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
+ dispatch(filterButton, "click");
+ await nextTick();
+
+ const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
+ expect(ensureRule).toHaveBeenCalledOnce();
+ expect(columnSelect.props.open).toBe(false);
+ });
+
it("gives filter rules enough horizontal space for longer column names", () => {
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
diff --git a/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts
index 7cb429638..ece02c489 100644
--- a/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts
@@ -43,6 +43,59 @@ describe("useDataGridConditionEditor", () => {
expect(value.value).toBe("status = customer_name");
});
+ it.each(["where", "orderBy"] as const)("searches %s fields by camel-case initials and any-position text", async (kind) => {
+ const value = ref("");
+ const editor = useDataGridConditionEditor({ kind, value, columns: ["userProfile", "order_id", "created_at"], historyScope: {} });
+
+ value.value = "up";
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["userProfile"]));
+
+ value.value = "id";
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]));
+ });
+
+ it.each(["where", "orderBy"] as const)("hides weaker %s matches when the input exactly matches a field", async (kind) => {
+ const value = ref("");
+ const editor = useDataGridConditionEditor({ kind, value, columns: ["id", "Is_Di", "Did"], historyScope: {} });
+
+ value.value = "id";
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value).toEqual([]));
+ });
+
+ it("keeps suggestions dismissed after accepting until the text changes again", async () => {
+ const value = ref("");
+ const selectionStart = ref(0);
+ const selectionEnd = ref(0);
+ const editor = useDataGridConditionEditor({ kind: "orderBy", value, selectionStart, selectionEnd, columns: ["name", "namespace"], historyScope: {} });
+
+ value.value = "na";
+ selectionStart.value = 2;
+ selectionEnd.value = 2;
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(2));
+ editor.navigate(1);
+ expect(editor.handleKeydown(keyboardEvent("Enter"))).toBe("accept");
+ expect(value.value).toBe("name");
+ expect(editor.suggestions.value).toEqual([]);
+
+ selectionStart.value = 2;
+ selectionEnd.value = 2;
+ await nextTick();
+ selectionStart.value = 4;
+ selectionEnd.value = 4;
+ await nextTick();
+ expect(editor.suggestions.value).toEqual([]);
+
+ value.value = "nam";
+ selectionStart.value = 3;
+ selectionEnd.value = 3;
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["name", "namespace"]));
+ });
+
it("builds and applies suggestions at the current caret instead of the value end", async () => {
const value = ref("");
const selectionStart = ref(0);
@@ -393,6 +446,11 @@ describe("useDataGridConditionEditor", () => {
expect(editor.handleKeydown(initialEnter)).toBe("apply");
expect(initialEnter.preventDefault).toHaveBeenCalledOnce();
expect(value.value).toBe("na");
+ expect(editor.suggestions.value).toEqual([]);
+
+ value.value = "nam";
+ await nextTick();
+ await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(2));
const down = keyboardEvent("ArrowDown");
expect(editor.handleKeydown(down)).toBe("navigate");
diff --git a/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts
index 730e3bc01..ade2345c9 100644
--- a/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts
@@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest";
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
describe("useDataGridFilterBuilder", () => {
+ it("searches columns by camel-case initials and any-position text", () => {
+ const builder = useDataGridFilterBuilder({ columns: ["userProfile", "order_id", "created_at"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "" });
+
+ builder.columnSearch.value = "up";
+ expect(builder.filteredColumns.value).toEqual(["userProfile"]);
+
+ builder.columnSearch.value = "id";
+ expect(builder.filteredColumns.value).toEqual(["order_id"]);
+ });
+
it("normalizes values when modes change", () => {
const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" });
builder.ensureRule();
@@ -9,6 +19,14 @@ describe("useDataGridFilterBuilder", () => {
expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" });
});
+ it("starts new filter rules without preselecting a column", () => {
+ const builder = useDataGridFilterBuilder({ columns: ["id", "name"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "" });
+
+ builder.ensureRule();
+
+ expect(builder.rules.value[0]?.columnName).toBe("");
+ });
+
it("skips disabled rules and applies conjunctions", async () => {
let nextId = 0;
const builder = useDataGridFilterBuilder({
@@ -18,7 +36,7 @@ describe("useDataGridFilterBuilder", () => {
buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`,
});
builder.ensureRule();
- builder.updateRule("rule-1", { rawValue: "1" });
+ builder.updateRule("rule-1", { columnName: "id", rawValue: "1" });
builder.addRule();
builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" });
expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')");
diff --git a/apps/desktop/src/composables/useDataGridConditionEditor.ts b/apps/desktop/src/composables/useDataGridConditionEditor.ts
index 0f20c2319..18ff1f782 100644
--- a/apps/desktop/src/composables/useDataGridConditionEditor.ts
+++ b/apps/desktop/src/composables/useDataGridConditionEditor.ts
@@ -1,6 +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";
import { pinyinAwareMatchScore } from "@/lib/common/pinyin";
+import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
@@ -205,7 +206,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
let suggestionTimer: ReturnType | undefined;
let suggestionRequestId = 0;
let suggestionAbortController: AbortController | undefined;
- let suppressNextValueSuggestion = false;
+ let suppressedSuggestionValue: string | undefined;
const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value);
@@ -226,6 +227,11 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
replacementRange.value = undefined;
}
+ function dismissUntilValueChanges() {
+ suppressedSuggestionValue = options.value.value;
+ dismiss();
+ }
+
function defaultSuggestions(target: DataGridConditionCompletionTarget): DataGridConditionSuggestion[] {
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
if (role === "none") return [];
@@ -233,17 +239,19 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
const seen = new Set();
const suggestions: DataGridConditionSuggestion[] = [];
if (role === "field") {
+ const columns = toValue(options.columns) ?? [];
+ if (normalizedToken && columns.some((column) => (typeof column === "string" ? column : column.name).toLowerCase() === normalizedToken)) return [];
const scored: Array<{ suggestion: DataGridConditionSuggestion; score: number; index: number }> = [];
let index = 0;
- for (const column of toValue(options.columns) ?? []) {
+ for (const column of columns) {
const columnValue = typeof column === "string" ? column : column.name;
- const normalizedValue = columnValue.toLowerCase();
- if ((normalizedToken && normalizedValue === normalizedToken) || seen.has(columnValue)) continue;
+ if (seen.has(columnValue)) continue;
seen.add(columnValue);
- // Substring + pinyin-initials matching (金 / zzj / zj → 总租金), scored so
- // prefix matches rank above looser ones and ties keep the column order.
- const score = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0;
- if (score < 0) continue;
+ // Keep the existing prefix/pinyin/substring ranking stable, then append
+ // new camel-initial and ordered-fuzzy matches behind those tiers.
+ const existingScore = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0;
+ if (existingScore < 0 && !matchesIdentifierSearch(columnValue, normalizedToken)) continue;
+ const score = existingScore < 0 ? 50 : existingScore;
const comment = normalizedColumnComment(column);
const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText;
scored.push({ suggestion: { value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) }, score, index: index++ });
@@ -271,7 +279,8 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
// A slower request must never replace suggestions for a newer editor value.
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(target).slice(0, limit);
+ const providerValues = values ? [...new Set(values)] : undefined;
+ suggestions.value = providerValues ? (providerValues.some((value) => value.toLowerCase() === target.token.toLowerCase()) ? [] : providerValues.slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }))) : defaultSuggestions(target).slice(0, limit);
replacementRange.value = { from: target.from, to: target.to };
highlightedIndex.value = -1;
} catch (error) {
@@ -342,7 +351,6 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
if (!suggestion) return false;
let caret: number;
if (suggestion.kind === "history") {
- suppressNextValueSuggestion = true;
options.value.value = suggestion.value;
caret = suggestion.value.length;
} else {
@@ -350,10 +358,10 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
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;
}
+ suppressedSuggestionValue = options.value.value;
if (options.selectionStart) options.selectionStart.value = caret;
if (options.selectionEnd) options.selectionEnd.value = caret;
dismiss();
@@ -382,6 +390,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
if (accept()) return "accept";
}
+ dismissUntilValueChanges();
return "apply";
}
return undefined;
@@ -390,9 +399,9 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
watch(
() => [options.value.value, options.selectionStart?.value, options.selectionEnd?.value] as const,
([value, selectionStart, selectionEnd]) => {
- if (suppressNextValueSuggestion) {
- suppressNextValueSuggestion = false;
- return;
+ if (suppressedSuggestionValue !== undefined) {
+ if (value === suppressedSuggestionValue) return;
+ suppressedSuggestionValue = undefined;
}
scheduleSuggestions(value, selectionStart, selectionEnd);
},
diff --git a/apps/desktop/src/composables/useDataGridFilterBuilder.ts b/apps/desktop/src/composables/useDataGridFilterBuilder.ts
index 5f4278226..5cd87592c 100644
--- a/apps/desktop/src/composables/useDataGridFilterBuilder.ts
+++ b/apps/desktop/src/composables/useDataGridFilterBuilder.ts
@@ -1,6 +1,7 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
+import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
export type DataGridStructuredFilterRule = {
id: string;
@@ -34,13 +35,13 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
const columnSearch = ref("");
const appliedWhereInput = ref("");
const filteredColumns = computed(() => {
- const query = columnSearch.value.trim().toLowerCase();
- return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)];
+ const query = columnSearch.value.trim();
+ return query ? toValue(options.columns).filter((column) => matchesIdentifierSearch(column, query)) : [...toValue(options.columns)];
});
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
function defaultRule(): DataGridStructuredFilterRule {
- return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
+ return { id: options.createId?.() ?? crypto.randomUUID(), columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
}
function ensureRule() {
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
@@ -85,7 +86,7 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
() => [...toValue(options.columns)],
(columns) => {
if (!columns.length) rules.value = [];
- else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
+ else rules.value = rules.value.map((rule) => (!rule.columnName || columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
},
);
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 000e963cd..f830858f8 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1311,7 +1311,9 @@ export default {
filterBuilderContains: "Contains",
filterBuilderNotContains: "Does not contain",
filterBuilderGreaterThan: "Greater than",
+ filterBuilderGreaterThanOrEqual: "Greater than or equal to",
filterBuilderLessThan: "Less than",
+ filterBuilderLessThanOrEqual: "Less than or equal to",
filterBuilderIn: "In list",
filterBuilderNotIn: "Not in list",
filterBuilderBetween: "Within range",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 3372df01a..bda1d8a95 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1203,7 +1203,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contiene",
filterBuilderNotContains: "No contiene",
filterBuilderGreaterThan: "Mayor que",
+ filterBuilderGreaterThanOrEqual: "Mayor o igual que",
filterBuilderLessThan: "Menor que",
+ filterBuilderLessThanOrEqual: "Menor o igual que",
filterBuilderIn: "En lista",
filterBuilderNotIn: "Fuera de lista",
filterBuilderBetween: "En rango",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 12187f806..8c0a67e4f 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1201,7 +1201,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contiene",
filterBuilderNotContains: "Non contiene",
filterBuilderGreaterThan: "Maggiore di",
+ filterBuilderGreaterThanOrEqual: "Maggiore o uguale a",
filterBuilderLessThan: "Minore di",
+ filterBuilderLessThanOrEqual: "Minore o uguale a",
filterBuilderIn: "Nell'elenco",
filterBuilderNotIn: "Fuori elenco",
filterBuilderBetween: "Nell'intervallo",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index ae61cd09f..4028a4860 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1219,7 +1219,9 @@ export default withEnglishFallback({
filterBuilderContains: "含む",
filterBuilderNotContains: "含まない",
filterBuilderGreaterThan: "より大きい",
+ filterBuilderGreaterThanOrEqual: "以上",
filterBuilderLessThan: "より小さい",
+ filterBuilderLessThanOrEqual: "以下",
filterBuilderIn: "一覧内",
filterBuilderNotIn: "一覧外",
filterBuilderBetween: "範囲内",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index b9ca6feb3..41f5fc435 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1215,7 +1215,9 @@ export default withEnglishFallback({
filterBuilderContains: "포함",
filterBuilderNotContains: "포함하지 않음",
filterBuilderGreaterThan: "보다 큼",
+ filterBuilderGreaterThanOrEqual: "크거나 같음",
filterBuilderLessThan: "보다 작음",
+ filterBuilderLessThanOrEqual: "작거나 같음",
filterBuilderIn: "목록 내",
filterBuilderNotIn: "목록 외",
filterBuilderBetween: "범위 내",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index a303d356e..4353057c7 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1203,7 +1203,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contém",
filterBuilderNotContains: "Não contém",
filterBuilderGreaterThan: "Maior que",
+ filterBuilderGreaterThanOrEqual: "Maior ou igual a",
filterBuilderLessThan: "Menor que",
+ filterBuilderLessThanOrEqual: "Menor ou igual a",
filterBuilderIn: "Na lista",
filterBuilderNotIn: "Fora da lista",
filterBuilderBetween: "No intervalo",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index c4a1bb0ec..953c4e0ed 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1311,7 +1311,9 @@ export default withEnglishFallback({
filterBuilderContains: "包含",
filterBuilderNotContains: "不包含",
filterBuilderGreaterThan: "大于",
+ filterBuilderGreaterThanOrEqual: "大于等于",
filterBuilderLessThan: "小于",
+ filterBuilderLessThanOrEqual: "小于等于",
filterBuilderIn: "列表内",
filterBuilderNotIn: "列表外",
filterBuilderBetween: "介于",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 086d6c15c..249d1101a 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1202,7 +1202,9 @@ export default withEnglishFallback({
filterBuilderContains: "包含",
filterBuilderNotContains: "不包含",
filterBuilderGreaterThan: "大於",
+ filterBuilderGreaterThanOrEqual: "大於等於",
filterBuilderLessThan: "小於",
+ filterBuilderLessThanOrEqual: "小於等於",
filterBuilderIn: "清單內",
filterBuilderNotIn: "清單外",
filterBuilderBetween: "介於",
diff --git a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts
new file mode 100644
index 000000000..e371f5286
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { buildDocumentFilterCondition, documentFilterModeOptions } from "@/lib/app/documentStoreProvider";
+
+describe("document store structured filters", () => {
+ it("offers and builds inclusive comparison filters", () => {
+ expect(documentFilterModeOptions.map((option) => option.value)).toEqual(expect.arrayContaining(["greater-than-or-equal", "less-than-or-equal"]));
+ expect(buildDocumentFilterCondition({ id: "gte", fieldName: "score", mode: "greater-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $gte: 80 } });
+ expect(buildDocumentFilterCondition({ id: "lte", fieldName: "score", mode: "less-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $lte: 80 } });
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/dataGridColumnLookup.spec.ts b/apps/desktop/src/lib/__tests__/dataGridColumnLookup.spec.ts
new file mode 100644
index 000000000..2cf983cc6
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/dataGridColumnLookup.spec.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup";
+
+describe("data grid column lookup search", () => {
+ const items = buildDataGridColumnLookupItems({
+ columns: ["userProfile", "order_id", "created_at"],
+ });
+
+ it("matches camel-case initials", () => {
+ expect(filterDataGridColumnLookupItems(items, "up").map((item) => item.name)).toEqual(["userProfile"]);
+ });
+
+ it("matches text from any position", () => {
+ expect(filterDataGridColumnLookupItems(items, "id").map((item) => item.name)).toEqual(["order_id"]);
+ });
+
+ it("returns all columns for an empty query", () => {
+ expect(filterDataGridColumnLookupItems(items, " ")).toEqual(items);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts
index 6fba8034c..dc8ab3eaa 100644
--- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { containsHan, matchesPinyinInitials, orderedSubsequenceSpan, pinyinAwareMatchScore, pinyinFirstLetters } from "@/lib/common/pinyin";
import { completionMatchRanges } from "@/lib/common/completionMatch";
import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion";
+import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
+
+describe("identifier search", () => {
+ it("matches camel-case initials and ranks them above loose fuzzy matches", () => {
+ expect(matchesIdentifierSearch("userProfile", "up")).toBe(true);
+ expect(identifierMatchScore("userProfile", "up")).toBeGreaterThan(identifierMatchScore("userPreference", "up"));
+ });
+
+ it("matches an identifier from any position", () => {
+ expect(matchesIdentifierSearch("order_id", "id")).toBe(true);
+ expect(matchesIdentifierSearch("created_at", "id")).toBe(false);
+ expect(matchesIdentifierSearch("customer_order_total", "cot")).toBe(true);
+ });
+
+ it("keeps unrelated identifiers out of the result", () => {
+ expect(matchesIdentifierSearch("userProfile", "xyz")).toBe(false);
+ });
+});
describe("pinyinAwareMatchScore", () => {
it("ranks prefix above pinyin prefix above substring above pinyin subsequence", () => {
@@ -127,3 +145,35 @@ describe("sqlCompletion Chinese column matching", () => {
expect(items.some((item) => item.label === "总租金")).toBe(false);
});
});
+
+describe("sqlCompletion identifier column matching", () => {
+ function completionItems(typedPrefix: string) {
+ const sql = `SELECT * FROM orders WHERE ${typedPrefix}`;
+ return buildSqlCompletionItems(sql, sql.length, {
+ databaseType: "mysql",
+ tables: [{ name: "orders", type: "table" }],
+ columnsByTable: new Map([
+ [
+ "orders",
+ [
+ { name: "userProfile", table: "orders" },
+ { name: "order_id", table: "orders" },
+ { name: "created_at", table: "orders" },
+ ],
+ ],
+ ]),
+ });
+ }
+
+ it("matches camel-case initials in WHERE column completion", () => {
+ const items = completionItems("up");
+ expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "userProfile", type: "column" })]));
+ expect(items.some((item) => item.label === "order_id")).toBe(false);
+ });
+
+ it("matches WHERE columns from any position", () => {
+ const items = completionItems("id");
+ expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "order_id", type: "column" })]));
+ expect(items.some((item) => item.label === "created_at")).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts
index 61f8932e1..a0b57512b 100644
--- a/apps/desktop/src/lib/app/documentStoreProvider.ts
+++ b/apps/desktop/src/lib/app/documentStoreProvider.ts
@@ -5,7 +5,7 @@ import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch";
-export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
+export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
@@ -55,7 +55,9 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
{ value: "like", labelKey: "grid.filterBuilderContains" },
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
+ { value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
+ { value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" },
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
];
@@ -435,8 +437,12 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } };
case "greater-than":
return { [rule.fieldName]: { $gt: value } };
+ case "greater-than-or-equal":
+ return { [rule.fieldName]: { $gte: value } };
case "less-than":
return { [rule.fieldName]: { $lt: value } };
+ case "less-than-or-equal":
+ return { [rule.fieldName]: { $lte: value } };
case "is-null":
return { [rule.fieldName]: null };
case "is-not-null":
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
index f0efe7007..44e95b359 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
@@ -1,3 +1,5 @@
+import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
+
export interface DataGridColumnLookupItem {
index: number;
name: string;
@@ -13,7 +15,7 @@ export interface DataGridColumnLookupOptions {
}
function normalizedSearchText(value: string): string {
- return value.trim().toLocaleLowerCase();
+ return value.trim();
}
function nonEmptyComment(value: string | undefined): string | undefined {
@@ -49,5 +51,5 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
export function filterDataGridColumnLookupItems(items: readonly ColumnLookupItem[], query: string): ColumnLookupItem[] {
const normalizedQuery = normalizedSearchText(query);
if (!normalizedQuery) return [...items];
- return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => value.toLocaleLowerCase().includes(normalizedQuery)));
+ return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => matchesIdentifierSearch(value, normalizedQuery)));
}
diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts
index 9d626fed2..c76e591f8 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts
@@ -56,7 +56,7 @@ export interface DataGridCopyInsertStatementOptions {
insertMode?: DataGridCopyInsertMode;
}
-export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than" | "in" | "not-in" | "between" | "not-between";
+export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between";
export interface DataGridContextFilterConditionOptions {
databaseType?: DatabaseType;
diff --git a/apps/desktop/src/lib/sql/identifierSearch.ts b/apps/desktop/src/lib/sql/identifierSearch.ts
new file mode 100644
index 000000000..ad75b0129
--- /dev/null
+++ b/apps/desktop/src/lib/sql/identifierSearch.ts
@@ -0,0 +1,82 @@
+import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
+
+/**
+ * Scores identifier matches for completion and field pickers.
+ * Higher scores are better; -1 means there is no match.
+ */
+export function identifierMatchScore(candidate: string, query: string): number {
+ if (!query) return 1;
+ const candidateLower = candidate.toLowerCase();
+ const queryLower = query.toLowerCase();
+
+ if (candidateLower === queryLower) return 3000 - candidateLower.length;
+ if (candidateLower.startsWith(queryLower)) return 2000 - candidateLower.length;
+
+ const initials = identifierInitials(candidate);
+ if (initials && initials.startsWith(queryLower)) {
+ const exactInitialsBonus = initials === queryLower ? 400 : 0;
+ return 2400 + exactInitialsBonus - candidateLower.length;
+ }
+
+ if (/^[a-z0-9]+$/.test(queryLower) && containsHan(candidateLower)) {
+ const pinyinInitials = pinyinFirstLetters(candidateLower);
+ if (pinyinInitials.startsWith(queryLower)) {
+ const exactInitialsBonus = pinyinInitials === queryLower ? 300 : 0;
+ return 2300 + exactInitialsBonus - candidateLower.length;
+ }
+ const subsequence = orderedSubsequenceSpan(pinyinInitials, queryLower);
+ if (subsequence) {
+ return 1600 - subsequence.first * 30 - (subsequence.span - queryLower.length) * 10 - candidateLower.length;
+ }
+ }
+
+ const substringIndex = candidateLower.indexOf(queryLower);
+ if (substringIndex >= 0) {
+ const boundaryBonus = isIdentifierBoundary(candidate, substringIndex) ? 400 : Math.max(0, 180 - substringIndex * 12);
+ return 900 + boundaryBonus - candidateLower.length;
+ }
+
+ let candidateIndex = 0;
+ let totalGap = 0;
+ let firstMatchPosition = -1;
+ let boundaryBonus = 0;
+ for (const character of queryLower) {
+ const nextPosition = candidateLower.indexOf(character, candidateIndex);
+ if (nextPosition === -1) return -1;
+ if (firstMatchPosition === -1) firstMatchPosition = nextPosition;
+ if (isIdentifierBoundary(candidate, nextPosition)) boundaryBonus += 40;
+ totalGap += nextPosition - candidateIndex;
+ candidateIndex = nextPosition + 1;
+ }
+
+ const earlyMatchBonus = Math.max(0, 700 - firstMatchPosition * 35) + boundaryBonus;
+ if (totalGap >= queryLower.length) {
+ return 400 + earlyMatchBonus * 0.3 - totalGap * 20 - candidateLower.length;
+ }
+ return 1200 + earlyMatchBonus - totalGap * 10 - candidateLower.length;
+}
+
+export function matchesIdentifierSearch(candidate: string, query: string): boolean {
+ return identifierMatchScore(candidate, query.trim()) >= 0;
+}
+
+function identifierWords(candidate: string): string[] {
+ return candidate
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter(Boolean);
+}
+
+function identifierInitials(candidate: string): string {
+ return identifierWords(candidate)
+ .map((part) => part[0])
+ .join("");
+}
+
+function isIdentifierBoundary(candidate: string, index: number): boolean {
+ if (index <= 0) return true;
+ const previous = candidate[index - 1] ?? "";
+ const current = candidate[index] ?? "";
+ return /[^A-Za-z0-9]/.test(previous) || (/[a-z0-9]/.test(previous) && /[A-Z]/.test(current));
+}
diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts
index 7aa26c193..e497e704a 100644
--- a/apps/desktop/src/lib/sql/sqlCompletion.ts
+++ b/apps/desktop/src/lib/sql/sqlCompletion.ts
@@ -10,6 +10,7 @@ import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/seman
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
+import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
@@ -3635,16 +3636,17 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map 0 || !!context.qualifier || !!context.insertTable ? 2000 : 0;
return uniqueColumns
- .filter((column) => matchesPrefix(column.displayLabel, context.prefix))
+ .filter((column) => matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix))
.map((column) => {
const keyBoost = isKeyColumn(column.name) ? 500 : 0;
+ const matchScore = Math.max(identifierMatchScore(column.name, context.prefix), identifierMatchScore(column.displayLabel, context.prefix));
return {
label: column.displayLabel,
type: "column" as const,
detail: buildColumnDetail(column),
info: buildColumnInfo(column),
apply: buildColumnApply(column, context, dialect),
- boost: computeBoost(column.displayLabel, context.prefix) + keyBoost + relevanceBoost,
+ boost: matchScore + keyBoost + relevanceBoost,
};
})
.sort(compareCompletionItems);
@@ -3667,7 +3669,7 @@ function applyReferencedColumnAliases(table: SqlC
function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, columnsByTable: Map): boolean {
if (!context.suggestColumns || !context.prefix || context.referencedTables.length === 0) return false;
- return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesPrefix(column.name, context.prefix)));
+ return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesIdentifierSearch(column.name, context.prefix)));
}
function qualifiedTableTargetFromContext(context: SqlCompletionContext): { database?: string; schema: string; table: string } | null {
@@ -4230,7 +4232,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT
for (const col of cols) {
const key = col.name.toLowerCase();
if (!nonAggSet.has(key) || seen.has(key)) continue;
- if (context.prefix && !matchesPrefix(col.name, context.prefix)) continue;
+ if (context.prefix && !matchesIdentifierSearch(col.name, context.prefix)) continue;
seen.add(key);
items.push({
label: col.name,
diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs
index 8cd93cd40..7322ae8cd 100644
--- a/crates/dbx-core/src/data_grid_sql.rs
+++ b/crates/dbx-core/src/data_grid_sql.rs
@@ -139,7 +139,9 @@ pub enum DataGridContextFilterMode {
Like,
NotLike,
LessThan,
+ LessThanOrEqual,
GreaterThan,
+ GreaterThanOrEqual,
In,
NotIn,
Between,
@@ -520,10 +522,18 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo
"{column} < {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
+ DataGridContextFilterMode::LessThanOrEqual => Some(format!(
+ "{column} <= {}",
+ format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
+ )),
DataGridContextFilterMode::GreaterThan => Some(format!(
"{column} > {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
+ DataGridContextFilterMode::GreaterThanOrEqual => Some(format!(
+ "{column} >= {}",
+ format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
+ )),
DataGridContextFilterMode::In => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
@@ -3508,6 +3518,25 @@ mod tests {
.as_deref(),
Some("`file_name` = '34-B-0048'")
);
+ for (mode, expected) in [
+ (DataGridContextFilterMode::GreaterThanOrEqual, "`score` >= 80"),
+ (DataGridContextFilterMode::LessThanOrEqual, "`score` <= 80"),
+ ] {
+ assert_eq!(
+ build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
+ database_type: Some(DatabaseType::Mysql),
+ identifier_quote: None,
+ column_name: "score".to_string(),
+ mode,
+ value: json!(80),
+ values: Vec::new(),
+ end_value: None,
+ column_info: Some(column("score", "int", false, None)),
+ })
+ .as_deref(),
+ Some(expected)
+ );
+ }
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Kingbase),