mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 09:21:38 +08:00
🐛 fix(metadata): 修复多数据源主键唯一索引识别
- 统一 PG-like 数据源字段和索引元数据查询,支持 search_path 可见表 - 兼容 snake_case、布尔别名和字符串唯一索引标记 - 修复 DuckDB main/memory 路径解析,避免误判外部 catalog - 补充前后端回归测试,覆盖可编辑结果定位和元数据重试路径
This commit is contained in:
@@ -137,6 +137,26 @@ describe('DataViewer safe editing locator', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('enables table preview editing when primary key metadata uses boolean aliases', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ column_name: 'ID', isPrimary: true }, { column_name: 'NAME' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload();
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['ID'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('uses a unique index when the table has no primary key', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
|
||||
@@ -1723,6 +1723,46 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses snake_case unique index metadata for query result row locators', async () => {
|
||||
storeState.connections[0].config.type = 'kingbase';
|
||||
storeState.connections[0].config.database = 'KINGBASE';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ column_name: 'EMAIL' }, { column_name: 'NAME' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ index_name: 'users_email_key', column_name: 'EMAIL', is_unique: 't', seq_in_index: '1', index_type: 'btree' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'KINGBASE', query: 'SELECT NAME FROM users' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['__gonavi_locator_1_EMAIL'],
|
||||
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
|
||||
@@ -108,6 +108,20 @@ describe('buildCopyInsertSQL', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts non-MySQL index metadata aliases when resolving safe unique locators', () => {
|
||||
expect(resolveUniqueKeyGroupsFromIndexes([
|
||||
{ index_name: 'events_slug_key', column_name: 'slug', is_unique: 't', seq_in_index: '1', index_type: 'BTREE' } as any,
|
||||
{ INDEX_NAME: 'events_tenant_code_key', COLUMN_NAME: 'code', UNIQUE: true, COLUMN_POSITION: 2 } as any,
|
||||
{ INDEX_NAME: 'events_tenant_code_key', COLUMN_NAME: 'tenant_id', UNIQUE: true, COLUMN_POSITION: 1 } as any,
|
||||
{ indexName: 'events_ext_key', columnName: 'external_id', indexType: 'UNIQUE' } as any,
|
||||
{ index_name: 'idx_note', column_name: 'note', non_unique: '1' } as any,
|
||||
])).toEqual([
|
||||
['slug'],
|
||||
['tenant_id', 'code'],
|
||||
['external_id'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
|
||||
const result = buildCopyUpdateSQL({
|
||||
dbType: 'mysql',
|
||||
|
||||
@@ -407,13 +407,59 @@ export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | u
|
||||
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
|
||||
};
|
||||
|
||||
const readIndexProp = (index: unknown, keys: string[]): unknown => {
|
||||
const source = index as Record<string, unknown> | null | undefined;
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
for (const key of keys) {
|
||||
if (source[key] !== undefined && source[key] !== null) return source[key];
|
||||
}
|
||||
for (const [sourceKey, raw] of Object.entries(source)) {
|
||||
if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readIndexText = (index: unknown, keys: string[]): string => {
|
||||
const raw = readIndexProp(index, keys);
|
||||
return raw === undefined || raw === null ? '' : String(raw).trim();
|
||||
};
|
||||
|
||||
const readIndexBool = (index: unknown, keys: string[]): boolean | undefined => {
|
||||
const raw = readIndexProp(index, keys);
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
if (typeof raw === 'number') return raw !== 0;
|
||||
const text = String(raw).trim().toLowerCase();
|
||||
if (['1', 't', 'true', 'y', 'yes', 'unique'].includes(text)) return true;
|
||||
if (['0', 'f', 'false', 'n', 'no', 'nonunique', 'non-unique'].includes(text)) return false;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isUniqueIndex = (index: unknown): boolean => {
|
||||
const nonUniqueRaw = readIndexProp(index, ['nonUnique', 'NonUnique', 'non_unique', 'NON_UNIQUE', 'Non_unique']);
|
||||
if (nonUniqueRaw !== undefined && nonUniqueRaw !== null) {
|
||||
if (typeof nonUniqueRaw === 'number') return nonUniqueRaw === 0;
|
||||
const text = String(nonUniqueRaw).trim().toLowerCase();
|
||||
if (['0', 'false', 'f', 'no', 'n'].includes(text)) return true;
|
||||
if (['1', 'true', 't', 'yes', 'y'].includes(text)) return false;
|
||||
}
|
||||
const unique = readIndexBool(index, ['isUnique', 'is_unique', 'IS_UNIQUE', 'unique', 'UNIQUE']);
|
||||
if (unique !== undefined) return unique;
|
||||
const uniqueness = readIndexText(index, ['uniqueness', 'UNIQUENESS']);
|
||||
if (uniqueness.toLowerCase() === 'unique') return true;
|
||||
const indexType = readIndexText(index, ['indexType', 'IndexType', 'index_type', 'INDEX_TYPE']);
|
||||
return indexType.toLowerCase() === 'unique';
|
||||
};
|
||||
|
||||
const buckets = new Map<string, IndexBucket>();
|
||||
(indexes || []).forEach((index, order) => {
|
||||
if (index?.nonUnique !== 0) {
|
||||
if (!isUniqueIndex(index)) {
|
||||
return;
|
||||
}
|
||||
const name = String(index?.name || '').trim();
|
||||
const columnName = String(index?.columnName || '').trim();
|
||||
const name = readIndexText(index, ['name', 'Name', 'indexName', 'index_name', 'INDEX_NAME']);
|
||||
const columnName = readIndexText(index, ['columnName', 'ColumnName', 'column_name', 'COLUMN_NAME']);
|
||||
if (!name || !columnName) {
|
||||
return;
|
||||
}
|
||||
@@ -426,7 +472,9 @@ export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | u
|
||||
}
|
||||
bucket.columns.push({
|
||||
columnName,
|
||||
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
|
||||
seqInIndex: Number.isFinite(Number(readIndexProp(index, ['seqInIndex', 'SeqInIndex', 'seq_in_index', 'SEQ_IN_INDEX', 'columnPosition', 'column_position'])))
|
||||
? Number(readIndexProp(index, ['seqInIndex', 'SeqInIndex', 'seq_in_index', 'SEQ_IN_INDEX', 'columnPosition', 'column_position']))
|
||||
: 0,
|
||||
order,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,4 +28,11 @@ describe('columnDefinition metadata normalization', () => {
|
||||
comment: '更新时间',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps boolean primary and unique metadata aliases to GoNavi keys', () => {
|
||||
expect(getColumnDefinitionKey({ column_name: 'id', isPrimary: true })).toBe('PRI');
|
||||
expect(getColumnDefinitionKey({ column_name: 'id', primary_key: 't' })).toBe('PRI');
|
||||
expect(getColumnDefinitionKey({ column_name: 'email', is_unique: 'yes' })).toBe('UNI');
|
||||
expect(getColumnDefinitionKey({ column_name: 'id', column_key: 'primary key' })).toBe('PRI');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,34 @@ const readStringProperty = (value: unknown, keys: string[]): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const readProperty = (value: unknown, keys: string[]): unknown => {
|
||||
const source = value as Record<string, unknown> | null | undefined;
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
|
||||
for (const key of keys) {
|
||||
if (source[key] !== undefined && source[key] !== null) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sourceKey, raw] of Object.entries(source)) {
|
||||
if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readBooleanProperty = (value: unknown, keys: string[]): boolean => {
|
||||
const raw = readProperty(value, keys);
|
||||
if (raw === undefined || raw === null) return false;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
if (typeof raw === 'number') return raw !== 0;
|
||||
const text = String(raw).trim().toLowerCase();
|
||||
return text === '1' || text === 't' || text === 'true' || text === 'y' || text === 'yes' || text === 'pri' || text === 'primary';
|
||||
};
|
||||
|
||||
export const getColumnDefinitionName = (column: unknown): string => (
|
||||
readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field'])
|
||||
);
|
||||
@@ -28,9 +56,24 @@ export const getColumnDefinitionType = (column: unknown): string => (
|
||||
readStringProperty(column, ['type', 'Type', 'DATA_TYPE', 'data_type'])
|
||||
);
|
||||
|
||||
export const getColumnDefinitionKey = (column: unknown): string => (
|
||||
readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key'])
|
||||
);
|
||||
export const getColumnDefinitionKey = (column: unknown): string => {
|
||||
const key = readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']);
|
||||
if (key) {
|
||||
const normalized = key.trim();
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered === 'pri' || lowered === 'primary' || lowered === 'primary key') return 'PRI';
|
||||
if (lowered === 'uni' || lowered === 'unique') return 'UNI';
|
||||
if (lowered === 'mul' || lowered === 'multiple') return 'MUL';
|
||||
return normalized;
|
||||
}
|
||||
if (readBooleanProperty(column, ['primaryKey', 'primary_key', 'isPrimary', 'is_primary', 'IS_PRIMARY', 'pk', 'PK'])) {
|
||||
return 'PRI';
|
||||
}
|
||||
if (readBooleanProperty(column, ['unique', 'isUnique', 'is_unique', 'UNIQUE', 'IS_UNIQUE'])) {
|
||||
return 'UNI';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getColumnDefinitionExtra = (column: unknown): string => (
|
||||
readStringProperty(column, ['extra', 'Extra'])
|
||||
|
||||
Reference in New Issue
Block a user