mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-07 06:59:32 +08:00
✨ feat(data-grid): 支持无主键表安全编辑
- 定位策略:新增主键、唯一索引和 Oracle ROWID 三类安全行定位能力 - 查询编辑器:简单单表 SELECT 自动补充隐藏定位列,复杂结果保持只读 - 表预览:无主键表可通过唯一索引或 Oracle ROWID 安全编辑 - 提交流程:移除无主键整行 WHERE fallback,隐藏定位列不参与展示和写入 - 后端保护:Oracle、MySQL、PostgreSQL 更新删除必须恰好影响 1 行 - 测试覆盖:补充 QueryEditor、DataViewer、DataGrid 和 ApplyChanges 相关用例 Refs #419
This commit is contained in:
44
frontend/src/utils/queryResultTable.test.ts
Normal file
44
frontend/src/utils/queryResultTable.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractQueryResultTableRef } from './queryResultTable';
|
||||
|
||||
describe('extractQueryResultTableRef', () => {
|
||||
it('preserves Oracle schema-qualified table names for editing', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY', 'oracle', 'ANONYMOUS'))
|
||||
.toEqual({
|
||||
tableName: 'MYCIMLED.EDC_LOG',
|
||||
metadataDbName: 'MYCIMLED',
|
||||
metadataTableName: 'EDC_LOG',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses current schema for unqualified Oracle tables', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM EDC_LOG', 'oracle', 'MYCIMLED'))
|
||||
.toEqual({
|
||||
tableName: 'EDC_LOG',
|
||||
metadataDbName: 'MYCIMLED',
|
||||
metadataTableName: 'EDC_LOG',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps existing simple table behavior for MySQL-style qualified names', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM app.users LIMIT 500', 'mysql', 'app'))
|
||||
.toEqual({
|
||||
tableName: 'users',
|
||||
metadataDbName: 'app',
|
||||
metadataTableName: 'users',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark join results as editable table refs', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM users u JOIN orders o ON u.id = o.user_id', 'oracle', 'APP'))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mark grouped or distinct results as editable table refs', () => {
|
||||
expect(extractQueryResultTableRef('SELECT ID FROM users GROUP BY ID', 'mysql', 'app'))
|
||||
.toBeUndefined();
|
||||
expect(extractQueryResultTableRef('SELECT DISTINCT ID FROM users', 'mysql', 'app'))
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
64
frontend/src/utils/queryResultTable.ts
Normal file
64
frontend/src/utils/queryResultTable.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type QueryResultTableRef = {
|
||||
tableName: string;
|
||||
metadataDbName: string;
|
||||
metadataTableName: string;
|
||||
};
|
||||
|
||||
const stripIdentifierQuotes = (part: string): string => {
|
||||
const text = String(part || '').trim();
|
||||
if (!text) return '';
|
||||
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
if (text.startsWith('[') && text.endsWith(']')) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const normalizeQualifiedName = (raw: string): string => (
|
||||
String(raw || '')
|
||||
.split('.')
|
||||
.map((part) => stripIdentifierQuotes(part.trim()))
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
|
||||
const isOracleLikeDialect = (dialect: string): boolean => {
|
||||
const normalized = String(dialect || '').trim().toLowerCase();
|
||||
return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8';
|
||||
};
|
||||
|
||||
export const extractQueryResultTableRef = (
|
||||
sql: string,
|
||||
dialect: string,
|
||||
currentDb: string,
|
||||
): QueryResultTableRef | undefined => {
|
||||
const text = String(sql || '').trim();
|
||||
if (!text) return undefined;
|
||||
if (/\b(JOIN|UNION|INTERSECT|EXCEPT|MINUS)\b/i.test(text)) return undefined;
|
||||
if (/^\s*SELECT\s+DISTINCT\b/i.test(text)) return undefined;
|
||||
if (/\bGROUP\s+BY\b|\bHAVING\b/i.test(text)) return undefined;
|
||||
|
||||
const tableMatch = text.match(/^\s*SELECT\s+.+?\s+FROM\s+((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})\s*(?:$|[\s;])/im);
|
||||
if (!tableMatch) return undefined;
|
||||
|
||||
const qualifiedName = normalizeQualifiedName(tableMatch[1]);
|
||||
if (!qualifiedName) return undefined;
|
||||
|
||||
const parts = qualifiedName.split('.').filter(Boolean);
|
||||
const metadataTableName = parts[parts.length - 1] || '';
|
||||
if (!metadataTableName) return undefined;
|
||||
|
||||
const owner = parts.length >= 2 ? parts[parts.length - 2] : '';
|
||||
const metadataDbName = owner || currentDb || '';
|
||||
const tableName = isOracleLikeDialect(dialect) && owner
|
||||
? `${owner}.${metadataTableName}`
|
||||
: metadataTableName;
|
||||
|
||||
return {
|
||||
tableName,
|
||||
metadataDbName,
|
||||
metadataTableName,
|
||||
};
|
||||
};
|
||||
146
frontend/src/utils/rowLocator.test.ts
Normal file
146
frontend/src/utils/rowLocator.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ORACLE_ROWID_LOCATOR_COLUMN,
|
||||
filterHiddenLocatorColumns,
|
||||
resolveEditRowLocator,
|
||||
resolveRowLocatorValues,
|
||||
} from './rowLocator';
|
||||
|
||||
const uniqueIndex = (name: string, columnName: string, seqInIndex = 1) => ({
|
||||
name,
|
||||
columnName,
|
||||
seqInIndex,
|
||||
nonUnique: 0,
|
||||
indexType: 'BTREE',
|
||||
});
|
||||
|
||||
const normalIndex = (name: string, columnName: string, seqInIndex = 1) => ({
|
||||
name,
|
||||
columnName,
|
||||
seqInIndex,
|
||||
nonUnique: 1,
|
||||
indexType: 'BTREE',
|
||||
});
|
||||
|
||||
describe('resolveEditRowLocator', () => {
|
||||
it('prefers primary keys over unique indexes', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'mysql',
|
||||
resultColumns: ['ID', 'EMAIL'],
|
||||
primaryKeys: ['ID'],
|
||||
indexes: [uniqueIndex('uk_email', 'EMAIL')],
|
||||
})).toEqual({
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['ID'],
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a unique index when there is no primary key', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'mysql',
|
||||
resultColumns: ['EMAIL', 'NAME'],
|
||||
indexes: [uniqueIndex('uk_email', 'EMAIL')],
|
||||
})).toEqual({
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['EMAIL'],
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts composite unique index columns by sequence', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'postgres',
|
||||
resultColumns: ['TENANT_ID', 'CODE', 'NAME'],
|
||||
indexes: [
|
||||
uniqueIndex('uk_tenant_code', 'CODE', 2),
|
||||
uniqueIndex('uk_tenant_code', 'TENANT_ID', 1),
|
||||
],
|
||||
})).toMatchObject({
|
||||
strategy: 'unique-key',
|
||||
columns: ['TENANT_ID', 'CODE'],
|
||||
valueColumns: ['TENANT_ID', 'CODE'],
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores non-unique indexes', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'mysql',
|
||||
resultColumns: ['NAME'],
|
||||
indexes: [normalIndex('idx_name', 'NAME')],
|
||||
})).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps results read-only when primary key columns are missing from result columns', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'oracle',
|
||||
resultColumns: ['NAME'],
|
||||
primaryKeys: ['ID'],
|
||||
})).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
reason: '结果集中缺少主键列 ID,无法安全提交修改。',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses Oracle ROWID when no primary or unique key is available', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'oracle',
|
||||
resultColumns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
allowOracleRowID: true,
|
||||
})).toEqual({
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowLocatorValues', () => {
|
||||
it('extracts locator values from the original row', () => {
|
||||
const locator = resolveEditRowLocator({
|
||||
dbType: 'mysql',
|
||||
resultColumns: ['EMAIL', 'NAME'],
|
||||
indexes: [uniqueIndex('uk_email', 'EMAIL')],
|
||||
});
|
||||
|
||||
expect(resolveRowLocatorValues(locator, { EMAIL: 'a@example.com', NAME: 'A' })).toEqual({
|
||||
ok: true,
|
||||
values: { EMAIL: 'a@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects nullable unique locator values', () => {
|
||||
const locator = resolveEditRowLocator({
|
||||
dbType: 'mysql',
|
||||
resultColumns: ['EMAIL', 'NAME'],
|
||||
indexes: [uniqueIndex('uk_email', 'EMAIL')],
|
||||
});
|
||||
|
||||
expect(resolveRowLocatorValues(locator, { EMAIL: null, NAME: 'A' })).toEqual({
|
||||
ok: false,
|
||||
error: '定位列 EMAIL 的值为空,无法安全提交修改。',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterHiddenLocatorColumns', () => {
|
||||
it('removes hidden Oracle ROWID columns from displayed columns', () => {
|
||||
const locator = resolveEditRowLocator({
|
||||
dbType: 'oracle',
|
||||
resultColumns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
allowOracleRowID: true,
|
||||
});
|
||||
|
||||
expect(filterHiddenLocatorColumns(['NAME', ORACLE_ROWID_LOCATOR_COLUMN], locator)).toEqual(['NAME']);
|
||||
});
|
||||
});
|
||||
133
frontend/src/utils/rowLocator.ts
Normal file
133
frontend/src/utils/rowLocator.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { IndexDefinition } from '../types';
|
||||
import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyInsert';
|
||||
import { isOracleLikeDialect } from './sqlDialect';
|
||||
|
||||
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
|
||||
|
||||
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'none';
|
||||
|
||||
export type EditRowLocator = {
|
||||
strategy: RowLocatorStrategy;
|
||||
columns: string[];
|
||||
valueColumns: string[];
|
||||
hiddenColumns?: string[];
|
||||
readOnly: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ResolveEditRowLocatorParams = {
|
||||
dbType: string;
|
||||
resultColumns: string[];
|
||||
primaryKeys?: string[];
|
||||
indexes?: IndexDefinition[];
|
||||
allowOracleRowID?: boolean;
|
||||
};
|
||||
|
||||
export type ResolveRowLocatorValuesResult =
|
||||
| { ok: true; values: Record<string, any> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const normalizeColumnName = (value: string): string => String(value || '').trim();
|
||||
|
||||
const hasColumn = (columns: string[], target: string): boolean => {
|
||||
const normalizedTarget = normalizeColumnName(target).toLowerCase();
|
||||
return columns.some((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget);
|
||||
};
|
||||
|
||||
const findColumn = (columns: string[], target: string): string => {
|
||||
const normalizedTarget = normalizeColumnName(target).toLowerCase();
|
||||
return columns.find((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget) || target;
|
||||
};
|
||||
|
||||
const buildReadOnlyLocator = (reason: string): EditRowLocator => ({
|
||||
strategy: 'none',
|
||||
columns: [],
|
||||
valueColumns: [],
|
||||
readOnly: true,
|
||||
reason,
|
||||
});
|
||||
|
||||
export const resolveEditRowLocator = ({
|
||||
dbType,
|
||||
resultColumns,
|
||||
primaryKeys = [],
|
||||
indexes,
|
||||
allowOracleRowID = false,
|
||||
}: ResolveEditRowLocatorParams): EditRowLocator => {
|
||||
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
|
||||
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
|
||||
|
||||
if (primaryKeyColumns.length > 0) {
|
||||
const missing = primaryKeyColumns.filter((column) => !hasColumn(columns, column));
|
||||
if (missing.length === 0) {
|
||||
return {
|
||||
strategy: 'primary-key',
|
||||
columns: primaryKeyColumns,
|
||||
valueColumns: primaryKeyColumns.map((column) => findColumn(columns, column)),
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
return buildReadOnlyLocator(`结果集中缺少主键列 ${missing.join(', ')},无法安全提交修改。`);
|
||||
}
|
||||
|
||||
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
|
||||
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0 && group.every((column) => hasColumn(columns, column)));
|
||||
if (uniqueKeyGroup) {
|
||||
return {
|
||||
strategy: 'unique-key',
|
||||
columns: uniqueKeyGroup,
|
||||
valueColumns: uniqueKeyGroup.map((column) => findColumn(columns, column)),
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (allowOracleRowID && isOracleLikeDialect(dbType) && hasColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN)) {
|
||||
const rowIDColumn = findColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN);
|
||||
return {
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [rowIDColumn],
|
||||
hiddenColumns: [rowIDColumn],
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
|
||||
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID,无法安全提交修改。');
|
||||
}
|
||||
|
||||
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
};
|
||||
|
||||
export const resolveRowLocatorValues = (
|
||||
locator: EditRowLocator | undefined,
|
||||
row: Record<string, any>,
|
||||
): ResolveRowLocatorValuesResult => {
|
||||
if (!locator || locator.readOnly || locator.strategy === 'none') {
|
||||
return { ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' };
|
||||
}
|
||||
|
||||
const values: Record<string, any> = {};
|
||||
for (let index = 0; index < locator.columns.length; index++) {
|
||||
const column = locator.columns[index];
|
||||
const valueColumn = locator.valueColumns[index] || column;
|
||||
const value = row?.[valueColumn];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { ok: false, error: `定位列 ${column} 的值为空,无法安全提交修改。` };
|
||||
}
|
||||
values[column] = value;
|
||||
}
|
||||
|
||||
return { ok: true, values };
|
||||
};
|
||||
|
||||
export const filterHiddenLocatorColumns = (columns: string[], locator?: EditRowLocator): string[] => {
|
||||
const hidden = new Set((locator?.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase()));
|
||||
if (hidden.size === 0) return columns;
|
||||
return (columns || []).filter((column) => !hidden.has(normalizeColumnName(column).toLowerCase()));
|
||||
};
|
||||
|
||||
export const isHiddenLocatorColumn = (column: string, locator?: EditRowLocator): boolean => {
|
||||
const normalized = normalizeColumnName(column).toLowerCase();
|
||||
return (locator?.hiddenColumns || []).some((hidden) => normalizeColumnName(hidden).toLowerCase() === normalized);
|
||||
};
|
||||
Reference in New Issue
Block a user