mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(data-grid): 新增快速 WHERE 筛选输入与补全能力
- 新增表格筛选面板快速 WHERE 条件输入 - 支持字段、操作符和关键字自动补全 - 查询、分页统计与筛选导出合并快速 WHERE 条件 - 修复补全过程中的字段引号丢失和重复追加问题 Refs #354
This commit is contained in:
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal file
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyWhereConditionSuggestion,
|
||||
buildEffectiveFilterConditions,
|
||||
buildQuickWhereFilterCondition,
|
||||
normalizeQuickWhereCondition,
|
||||
resolveWhereConditionSuggestions,
|
||||
resolveWhereConditionSelectedValue,
|
||||
validateQuickWhereCondition,
|
||||
} from './dataGridWhereFilter';
|
||||
|
||||
describe('dataGridWhereFilter', () => {
|
||||
it('normalizes pasted WHERE clauses to condition bodies', () => {
|
||||
expect(normalizeQuickWhereCondition(' WHERE status = 1; ')).toBe('status = 1');
|
||||
expect(normalizeQuickWhereCondition('\nwhere name like \'A%\'\n')).toBe("name like 'A%'");
|
||||
});
|
||||
|
||||
it('rejects multi statement or commented quick where conditions', () => {
|
||||
expect(validateQuickWhereCondition('status = 1')).toEqual({ ok: true });
|
||||
expect(validateQuickWhereCondition('status = 1; drop table users')).toEqual({
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
});
|
||||
expect(validateQuickWhereCondition('status = 1 -- bypass')).toEqual({
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges structured filters with a quick custom where condition', () => {
|
||||
const effective = buildEffectiveFilterConditions(
|
||||
[{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' }],
|
||||
'amount > 100',
|
||||
);
|
||||
|
||||
expect(effective).toEqual([
|
||||
{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' },
|
||||
{
|
||||
id: -1,
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: '',
|
||||
op: 'CUSTOM',
|
||||
value: 'amount > 100',
|
||||
value2: '',
|
||||
},
|
||||
]);
|
||||
expect(buildQuickWhereFilterCondition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('suggests columns, operators and keywords for quick where editing', () => {
|
||||
const columnSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'sta',
|
||||
columnNames: ['status', 'created_at'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(columnSuggestions[0]).toMatchObject({
|
||||
label: 'status',
|
||||
kind: 'column',
|
||||
value: '`status`',
|
||||
});
|
||||
|
||||
const operatorSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'status ',
|
||||
columnNames: ['status'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(operatorSuggestions.map((item) => item.label)).toContain('LIKE');
|
||||
|
||||
const quotedOperatorSuggestions = resolveWhereConditionSuggestions({
|
||||
input: '`username` ',
|
||||
columnNames: ['username'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(quotedOperatorSuggestions.find((item) => item.label === '=')?.value).toBe('`username` = ');
|
||||
|
||||
const keywordSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'status = 1 a',
|
||||
columnNames: ['status'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(keywordSuggestions.map((item) => item.label)).toContain('AND');
|
||||
});
|
||||
|
||||
it('applies a suggestion to the current trailing token', () => {
|
||||
expect(applyWhereConditionSuggestion('status = 1 a', 'AND ')).toBe('status = 1 AND ');
|
||||
expect(applyWhereConditionSuggestion('', '`user`')).toBe('`user`');
|
||||
});
|
||||
|
||||
it('keeps a completed quoted column intact when applying an operator suggestion', () => {
|
||||
expect(applyWhereConditionSuggestion('`字段名`', '= ')).toBe('`字段名` = ');
|
||||
expect(applyWhereConditionSuggestion('`字段名` ', '= ')).toBe('`字段名` = ');
|
||||
expect(applyWhereConditionSuggestion('"字段名"', 'LIKE ')).toBe('"字段名" LIKE ');
|
||||
});
|
||||
|
||||
it('uses the selected autocomplete value once without appending it again', () => {
|
||||
expect(
|
||||
resolveWhereConditionSelectedValue({
|
||||
selectedValue: '`username`',
|
||||
currentInput: '`username`',
|
||||
insertText: '`username`',
|
||||
}),
|
||||
).toBe('`username`');
|
||||
expect(
|
||||
resolveWhereConditionSelectedValue({
|
||||
selectedValue: '`username` = ',
|
||||
currentInput: '`username` = ',
|
||||
insertText: '= ',
|
||||
}),
|
||||
).toBe('`username` = ');
|
||||
});
|
||||
});
|
||||
242
frontend/src/utils/dataGridWhereFilter.ts
Normal file
242
frontend/src/utils/dataGridWhereFilter.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { quoteIdentPart, type FilterCondition } from './sql';
|
||||
|
||||
export type WhereConditionSuggestionKind = 'column' | 'operator' | 'keyword';
|
||||
|
||||
export type WhereConditionSuggestion = {
|
||||
label: string;
|
||||
value: string;
|
||||
insertText: string;
|
||||
detail: string;
|
||||
kind: WhereConditionSuggestionKind;
|
||||
};
|
||||
|
||||
const QUICK_WHERE_CONDITION_ID = -1;
|
||||
|
||||
const WHERE_KEYWORDS = [
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'IS',
|
||||
'NULL',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'IN',
|
||||
'LIKE',
|
||||
'BETWEEN',
|
||||
'EXISTS',
|
||||
];
|
||||
|
||||
const WHERE_OPERATORS = [
|
||||
'=',
|
||||
'!=',
|
||||
'<>',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'LIKE',
|
||||
'NOT LIKE',
|
||||
'IN',
|
||||
'BETWEEN',
|
||||
'IS NULL',
|
||||
'IS NOT NULL',
|
||||
];
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeSuggestionPrefix = (value: string): string => {
|
||||
const text = String(value || '');
|
||||
if (!text || /\s$/.test(text)) return '';
|
||||
|
||||
const identifierMatch = text.match(/([A-Za-z_][A-Za-z0-9_$]*)$/);
|
||||
if (identifierMatch) return identifierMatch[1];
|
||||
|
||||
const isBoundary = (char: string | undefined) => !char || /[\s([,{=<>!]/.test(char);
|
||||
const boundaryIndex = Math.max(
|
||||
text.lastIndexOf(' '),
|
||||
text.lastIndexOf('\t'),
|
||||
text.lastIndexOf('\n'),
|
||||
text.lastIndexOf('('),
|
||||
text.lastIndexOf('['),
|
||||
text.lastIndexOf(','),
|
||||
text.lastIndexOf('{'),
|
||||
text.lastIndexOf('='),
|
||||
text.lastIndexOf('<'),
|
||||
text.lastIndexOf('>'),
|
||||
text.lastIndexOf('!'),
|
||||
);
|
||||
|
||||
for (const quote of ['`', '"']) {
|
||||
const start = text.lastIndexOf(quote);
|
||||
if (start < 0 || !isBoundary(text[start - 1])) continue;
|
||||
const tokenStart = boundaryIndex + 1;
|
||||
const tokenHead = text.slice(tokenStart, start);
|
||||
if (tokenHead.includes(quote)) continue;
|
||||
return text.slice(start);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const shouldSuggestOperators = (input: string): boolean => {
|
||||
return /\s$/.test(input) && /(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+"|`[^`]+`)\s$/.test(input);
|
||||
};
|
||||
|
||||
const toOperatorInsertText = (operator: string): string => {
|
||||
if (operator === 'IN') return 'IN ()';
|
||||
if (operator === 'BETWEEN') return 'BETWEEN AND ';
|
||||
return `${operator} `;
|
||||
};
|
||||
|
||||
export const normalizeQuickWhereCondition = (value: unknown): string => {
|
||||
let text = toTrimmedString(value);
|
||||
text = text.replace(/^where\b/i, '').trim();
|
||||
text = text.replace(/;+\s*$/, '').trim();
|
||||
return text;
|
||||
};
|
||||
|
||||
export const validateQuickWhereCondition = (
|
||||
value: unknown,
|
||||
): { ok: true } | { ok: false; message: string } => {
|
||||
const text = normalizeQuickWhereCondition(value);
|
||||
if (!text) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (/[;]/.test(text) || /--|\/\*/.test(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const buildQuickWhereFilterCondition = (
|
||||
value: unknown,
|
||||
): FilterCondition | null => {
|
||||
const text = normalizeQuickWhereCondition(value);
|
||||
if (!text) return null;
|
||||
return {
|
||||
id: QUICK_WHERE_CONDITION_ID,
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: '',
|
||||
op: 'CUSTOM',
|
||||
value: text,
|
||||
value2: '',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEffectiveFilterConditions = (
|
||||
conditions: FilterCondition[] | undefined,
|
||||
quickWhereCondition: unknown,
|
||||
): FilterCondition[] => {
|
||||
const baseConditions = Array.isArray(conditions) ? conditions : [];
|
||||
const quickCondition = buildQuickWhereFilterCondition(quickWhereCondition);
|
||||
if (!quickCondition) {
|
||||
return baseConditions;
|
||||
}
|
||||
return [...baseConditions, quickCondition];
|
||||
};
|
||||
|
||||
export const applyWhereConditionSuggestion = (
|
||||
input: string,
|
||||
insertText: string,
|
||||
): string => {
|
||||
const text = String(input || '');
|
||||
const prefix = normalizeSuggestionPrefix(text);
|
||||
if (!prefix) {
|
||||
if (text && !/\s$/.test(text) && !/[([,{=<>!]$/.test(text)) {
|
||||
return `${text} ${insertText}`;
|
||||
}
|
||||
return `${text}${insertText}`;
|
||||
}
|
||||
return `${text.slice(0, text.length - prefix.length)}${insertText}`;
|
||||
};
|
||||
|
||||
export const resolveWhereConditionSelectedValue = ({
|
||||
selectedValue,
|
||||
currentInput,
|
||||
insertText,
|
||||
}: {
|
||||
selectedValue: unknown;
|
||||
currentInput: unknown;
|
||||
insertText?: unknown;
|
||||
}): string => {
|
||||
const selectedText = String(selectedValue ?? '');
|
||||
if (selectedText) {
|
||||
return selectedText;
|
||||
}
|
||||
const insertTextValue = String(insertText ?? '');
|
||||
if (!insertTextValue) {
|
||||
return String(currentInput ?? '');
|
||||
}
|
||||
return applyWhereConditionSuggestion(String(currentInput ?? ''), insertTextValue);
|
||||
};
|
||||
|
||||
export const resolveWhereConditionSuggestions = ({
|
||||
input,
|
||||
columnNames,
|
||||
dbType,
|
||||
}: {
|
||||
input: string;
|
||||
columnNames: string[];
|
||||
dbType: string;
|
||||
}): WhereConditionSuggestion[] => {
|
||||
const text = String(input || '');
|
||||
const prefix = normalizeSuggestionPrefix(text).replace(/^["`]/, '').toLowerCase();
|
||||
const options: WhereConditionSuggestion[] = [];
|
||||
|
||||
if (shouldSuggestOperators(text)) {
|
||||
WHERE_OPERATORS.forEach((operator) => {
|
||||
const insertText = toOperatorInsertText(operator);
|
||||
options.push({
|
||||
label: operator,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '操作符',
|
||||
kind: 'operator',
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
(columnNames || [])
|
||||
.map((column) => toTrimmedString(column))
|
||||
.filter(Boolean)
|
||||
.filter((column) => !prefix || column.toLowerCase().startsWith(prefix))
|
||||
.slice(0, 30)
|
||||
.forEach((column) => {
|
||||
const insertText = quoteIdentPart(dbType, column);
|
||||
options.push({
|
||||
label: column,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '字段',
|
||||
kind: 'column',
|
||||
});
|
||||
});
|
||||
|
||||
WHERE_KEYWORDS
|
||||
.filter((keyword) => !prefix || keyword.toLowerCase().startsWith(prefix))
|
||||
.forEach((keyword) => {
|
||||
const insertText = `${keyword} `;
|
||||
options.push({
|
||||
label: keyword,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '关键字',
|
||||
kind: 'keyword',
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
Reference in New Issue
Block a user