feat(data-grid): 新增快速 WHERE 筛选输入与补全能力

- 新增表格筛选面板快速 WHERE 条件输入
- 支持字段、操作符和关键字自动补全
- 查询、分页统计与筛选导出合并快速 WHERE 条件
- 修复补全过程中的字段引号丢失和重复追加问题
Refs #354
This commit is contained in:
Syngnat
2026-04-26 20:06:15 +08:00
parent 55829bce86
commit 30301cd637
5 changed files with 544 additions and 14 deletions

View File

@@ -99,4 +99,28 @@ describe('DataGrid layout', () => {
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

View File

@@ -1,7 +1,7 @@
// cspell:ignore anticon sqls uuidv uuidv4 hscroll
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
@@ -61,6 +61,13 @@ import {
resolveTemporalEditorSaveValue,
type TemporalPickerType,
} from './dataGridTemporal';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
resolveWhereConditionSelectedValue,
resolveWhereConditionSuggestions,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -889,6 +896,8 @@ interface DataGridProps {
exportSqlWithFilter?: string;
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
appliedFilterConditions?: FilterCondition[];
quickWhereCondition?: string;
onApplyQuickWhereCondition?: (condition: string) => void;
scrollSnapshot?: { top: number; left: number };
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
}
@@ -914,7 +923,8 @@ const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange
}) => {
const connections = useStore(state => state.connections);
@@ -2199,6 +2209,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Filter State
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition));
const filterPanelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -2208,6 +2219,29 @@ const DataGrid: React.FC<DataGridProps> = ({
setNextFilterId(Math.max(1, maxId + 1));
}, [appliedFilterConditions, normalizeGridFilterConditions]);
useEffect(() => {
setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition));
}, [quickWhereCondition]);
const quickWhereSuggestionOptions = useMemo(() => {
const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames;
return resolveWhereConditionSuggestions({
input: quickWhereDraft,
columnNames: columnSuggestionSource,
dbType,
}).map((item) => ({
value: item.value,
insertText: item.insertText,
suggestionKind: item.kind,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span>{item.label}</span>
<span style={{ color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(0,0,0,0.42)', fontSize: 12 }}>{item.detail}</span>
</div>
),
}));
}, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]);
useEffect(() => {
if (!showFilter) {
return;
@@ -4032,9 +4066,10 @@ const DataGrid: React.FC<DataGridProps> = ({
return clauses.join(' OR ');
}, [pkColumns, tableName]);
const buildCurrentPageSql = useCallback((dbType: string) => {
const buildCurrentPageSql = useCallback((dbType: string) => {
if (!tableName || !pagination) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = String(dbType || '').trim().toLowerCase();
@@ -4045,7 +4080,7 @@ const DataGrid: React.FC<DataGridProps> = ({
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tableName, pagination, filterConditions, sortInfo, pkColumns]);
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
@@ -4277,7 +4312,25 @@ const DataGrid: React.FC<DataGridProps> = ({
const removeFilter = (id: number) => {
setFilterConditions(prev => prev.filter(c => c.id !== id));
};
const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
void message.warning(validation.message);
return false;
}
setQuickWhereDraft(normalized);
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized);
return true;
}, [quickWhereDraft, onApplyQuickWhereCondition]);
const clearQuickWhereCondition = useCallback(() => {
setQuickWhereDraft('');
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition('');
}, [onApplyQuickWhereCondition]);
const applyFilters = () => {
if (!applyQuickWhereCondition()) return;
if (onApplyFilter) onApplyFilter(filterConditions);
};
@@ -5149,6 +5202,73 @@ const DataGrid: React.FC<DataGridProps> = ({
display: 'flex',
flexDirection: 'column',
}}>
<div
data-grid-quick-where="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
marginBottom: 10,
borderRadius: Math.max(10, panelRadius - 2),
border: `1px solid ${panelFrameColor}`,
background: darkMode ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.72)',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<span
style={{
flex: '0 0 auto',
minWidth: 58,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
background: darkMode ? 'rgba(24,144,255,0.18)' : 'rgba(24,144,255,0.10)',
border: `1px solid ${darkMode ? 'rgba(24,144,255,0.32)' : 'rgba(24,144,255,0.22)'}`,
color: selectionAccentHex,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.03em',
}}
>
WHERE
</span>
<AutoComplete
value={quickWhereDraft}
options={quickWhereSuggestionOptions}
onChange={setQuickWhereDraft}
onSelect={(value, option) => {
setQuickWhereDraft(resolveWhereConditionSelectedValue({
selectedValue: value,
currentInput: quickWhereDraft,
insertText: (option as any)?.insertText,
}));
}}
style={{ flex: '1 1 320px', minWidth: 220 }}
popupMatchSelectWidth={420}
>
<Input
{...noAutoCapInputProps}
allowClear
placeholder={dbType === 'mongodb' ? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}' : '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\''}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault();
applyQuickWhereCondition();
}
}}
/>
</AutoComplete>
<Button size="small" type="primary" onClick={() => applyQuickWhereCondition()}>
WHERE
</Button>
<Button size="small" onClick={clearQuickWhereCondition} disabled={!quickWhereDraft && !quickWhereCondition}>
</Button>
</div>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
@@ -5316,6 +5436,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<Button type="primary" onClick={applyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={() => {
setFilterConditions([]);
clearQuickWhereCondition();
if (onApplyFilter) onApplyFilter([]);
if (onSort) onSort('', '');
}}></Button>

View File

@@ -10,6 +10,11 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
type ViewerPaginationState = {
current: number;
@@ -135,6 +140,7 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
type ViewerFilterSnapshot = {
showFilter: boolean;
conditions: FilterCondition[];
quickWhereCondition: string;
currentPage: number;
pageSize: number;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
@@ -165,11 +171,12 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: Array.isArray(cached.sortInfo)
@@ -226,6 +233,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
@@ -239,6 +247,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
@@ -246,12 +255,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions);
setQuickWhereCondition(snapshot.quickWhereCondition);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
@@ -259,7 +269,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
}, [persistViewerSnapshot]);
useEffect(() => {
return () => {
@@ -399,6 +409,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const dbType = resolveDataSourceType(config);
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
if (!quickWhereValidation.ok) {
message.error(quickWhereValidation.message);
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -406,7 +424,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let mongoFilter: Record<string, unknown> | undefined;
if (isMongoDB) {
try {
mongoFilter = buildMongoFilter(filterConditions);
mongoFilter = buildMongoFilter(effectiveFilterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
if (fetchSeqRef.current === seq) setLoading(false);
@@ -416,7 +434,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, filterConditions);
: buildWhereSQL(dbType, effectiveFilterConditions);
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
@@ -824,7 +842,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -852,13 +870,23 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
message.error(validation.message);
return;
}
setQuickWhereCondition(normalized);
}, []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = resolveDataSourceType(currentConnConfig);
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
@@ -869,7 +897,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({
@@ -886,7 +914,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
return;
}
fetchData(1, pagination.pageSize);
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -909,6 +937,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
appliedFilterConditions={filterConditions}
quickWhereCondition={quickWhereCondition}
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}

View 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` = ');
});
});

View 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;
};