feat(grid): enhance field search and filter interaction

This commit is contained in:
zipg authored and GitHub committed 2026-08-07 13:43:55 +08:00
1 parent 13878a1a30
commit f9e93b356f
26 files changed
+491 -35

No files matched your search

@@ -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 } });
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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":
@@ -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<ColumnLookupItem extends DataGridColumnLookupItem>(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)));
}
+1 -1
View File
@@ -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;
@@ -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));
}
+6 -4
View File
@@ -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<str
const relevanceBoost = context.referencedTables.length > 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<T extends SqlCompletionColumn>(table: SqlC
function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): 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,