🐛 fix(duckdb): 修复唯一索引识别与多库对象解析

- 合并 DuckDB 约束与索引元数据,恢复唯一索引表的可编辑判定
- 修复 attach 多库场景下 catalog/schema/table 定位混乱问题
- 统一前后端 qualified name 解析,支持带点和带引号对象名
- 补充 DuckDB 元数据与编辑链路回归测试
This commit is contained in:
Syngnat
2026-06-02 21:12:59 +08:00
parent 8fba42adbf
commit eeaf3c658b
16 changed files with 969 additions and 219 deletions

View File

@@ -26,6 +26,7 @@ import {
getColumnDefinitionName,
getColumnDefinitionType,
} from '../utils/columnDefinition';
import { splitQualifiedNameLast, splitQualifiedNameSegments } from '../utils/qualifiedName';
type ViewerPaginationState = {
current: number;
@@ -171,33 +172,21 @@ const buildDataViewerBaseSelectSQL = (
return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
return text.slice(1, -1).trim();
}
}
return text;
};
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
const rawTable = String(tableName || '').trim();
if (!rawTable) return { schemaName: 'main', pureTableName: '' };
const parts = rawTable.split('.');
if (parts.length >= 2) {
const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]);
const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]);
if (schemaName && pureTableName) {
return { schemaName, pureTableName };
}
const segments = splitQualifiedNameSegments(rawTable);
if (segments.length >= 2) {
return {
schemaName: segments[segments.length - 2],
pureTableName: segments[segments.length - 1],
};
}
const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main';
return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) };
const fallbackParsed = splitQualifiedNameLast(String(dbName || '').trim());
const fallbackSchema = fallbackParsed.objectName || String(dbName || '').trim() || 'main';
return { schemaName: fallbackSchema, pureTableName: segments[0] || rawTable };
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");

View File

@@ -6,6 +6,7 @@ import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { splitQualifiedNameLast } from '../utils/qualifiedName';
interface DefinitionViewerProps {
tab: TabData;
@@ -63,12 +64,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
};
const parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
if (idx > 0 && idx < raw.length - 1) {
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
}
return { schema: '', name: raw };
const parsed = splitQualifiedNameLast(fullName);
return { schema: parsed.parentPath, name: parsed.objectName };
};
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {

View File

@@ -65,6 +65,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { splitQualifiedNameLast } from '../utils/qualifiedName';
import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay';
@@ -1796,9 +1797,8 @@ const Sidebar: React.FC<{
const rawName = String(tableName || '').trim();
if (!rawName) return rawName;
if (!shouldHideSchemaPrefix(conn)) return rawName;
const lastDotIndex = rawName.lastIndexOf('.');
if (lastDotIndex <= 0 || lastDotIndex >= rawName.length - 1) return rawName;
return rawName.substring(lastDotIndex + 1);
const parsed = splitQualifiedName(rawName);
return parsed.objectName || rawName;
};
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
@@ -1984,15 +1984,10 @@ const Sidebar: React.FC<{
};
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) {
return { schemaName: '', objectName: raw };
}
const parsed = splitQualifiedNameLast(qualifiedName);
return {
schemaName: raw.substring(0, idx),
objectName: raw.substring(idx + 1),
schemaName: parsed.parentPath,
objectName: parsed.objectName,
};
};
@@ -4785,12 +4780,7 @@ const Sidebar: React.FC<{
};
const extractObjectName = (fullName: string) => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
if (idx >= 0 && idx < raw.length - 1) {
return raw.substring(idx + 1);
}
return raw;
return splitQualifiedName(String(fullName || '').trim()).objectName || String(fullName || '').trim();
};
const handleRenameDatabase = async () => {
@@ -5012,9 +5002,9 @@ const Sidebar: React.FC<{
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
const parts = viewName.split('.');
const schema = parts.length > 1 ? parts[0] : 'public';
const name = parts.length > 1 ? parts[1] : viewName;
const parts = splitQualifiedName(viewName);
const schema = parts.schemaName || 'public';
const name = parts.objectName || viewName;
query = `SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`;
break;
}
@@ -5133,7 +5123,10 @@ const Sidebar: React.FC<{
const conn = node.dataRef;
const { tableName, dbName, id } = conn;
const safeTable = String(tableName || 'table_name').trim();
const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
const safeTableParts = [splitQualifiedName(safeTable).schemaName, splitQualifiedName(safeTable).objectName].filter(Boolean);
const quotedTable = safeTable.includes('`')
? safeTable
: (safeTableParts.length > 0 ? safeTableParts : [safeTable]).map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
addTab({
id: `query-create-starrocks-rollup-${Date.now()}`,
title: '新增 Rollup',
@@ -6504,7 +6497,7 @@ const Sidebar: React.FC<{
const dbName = String(conn?.dbName || '').trim();
const tableName = String(conn?.tableName || node?.title || '').trim();
const objectName = extractObjectName(tableName);
const schemaName = String(conn?.schemaName || (tableName.includes('.') ? tableName.split('.').slice(0, -1).join('.') : '')).trim();
const schemaName = String(conn?.schemaName || splitQualifiedName(tableName).schemaName || '').trim();
switch (dialect) {
case 'mysql':
case 'starrocks':
@@ -8140,8 +8133,7 @@ const Sidebar: React.FC<{
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || '').trim();
const conn = node?.dataRef as SavedConnection | undefined;
if (rawTableName && shouldHideSchemaPrefix(conn)) {
const lastDotIndex = rawTableName.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < rawTableName.length - 1) {
if (splitQualifiedName(rawTableName).schemaName) {
hoverTitle = rawTableName;
}
}

View File

@@ -29,6 +29,7 @@ import {
resolveColumnTypeOptions,
resolveSqlDialect,
} from '../utils/sqlDialect';
import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -1390,26 +1391,11 @@ ${selectedTrigger.statement}`;
const escapeDoubleQuoteIdentifier = (name: string) => String(name || '').replace(/"/g, '""');
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
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 splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
const parsed = splitQualifiedNameLast(qualifiedName);
return {
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
schemaName: parsed.parentPath,
objectName: parsed.objectName,
};
};

View File

@@ -6,6 +6,7 @@ import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { splitQualifiedNameLast } from '../utils/qualifiedName';
interface TriggerViewerProps {
tab: TabData;
@@ -25,12 +26,8 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
if (idx > 0 && idx < raw.length - 1) {
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
}
return { schema: '', name: raw };
const parsed = splitQualifiedNameLast(fullName);
return { schema: parsed.parentPath, name: parsed.objectName };
};
const getMetadataDialect = (conn: any): string => {

View File

@@ -10,6 +10,7 @@ import {
unquoteSqlIdentifierPart,
unquoteSqlIdentifierPath,
} from '../utils/sqlDialect';
import { splitQualifiedNameLast } from '../utils/qualifiedName';
export interface EditableColumnSnapshot {
_key: string;
@@ -83,13 +84,10 @@ const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''
const stripIdentifierQuotes = unquoteSqlIdentifierPart;
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
const parsed = splitQualifiedNameLast(qualifiedName);
return {
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
schemaName: parsed.parentPath,
objectName: parsed.objectName,
};
};

View File

@@ -0,0 +1,126 @@
export type QualifiedNameParts = {
parentPath: string;
objectName: string;
};
const normalizeIdentifierEscapes = (raw: string): string => {
let value = String(raw || '').trim();
for (let i = 0; i < 4; i += 1) {
const next = String(value || '').trim()
.replace(/\\\\"/g, '\\"')
.replace(/\\"/g, '"');
if (next === value) break;
value = next;
}
return String(value || '').trim();
};
export const stripIdentifierQuotes = (part: string): string => {
const text = normalizeIdentifierEscapes(part);
if (!text) return '';
if (text.length >= 2) {
const first = text[0];
const last = text[text.length - 1];
if (first === '"' && last === '"') {
return text.slice(1, -1).replace(/""/g, '"').trim();
}
if (first === '`' && last === '`') {
return text.slice(1, -1).replace(/``/g, '`').trim();
}
if (first === '[' && last === ']') {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
}
return text;
};
export const splitQualifiedNameSegments = (qualifiedName: string): string[] => {
const text = normalizeIdentifierEscapes(qualifiedName);
if (!text) return [];
const segments: string[] = [];
let current = '';
let inDouble = false;
let inBacktick = false;
let inBracket = false;
const flush = () => {
const value = current.trim();
current = '';
if (!value) return;
segments.push(stripIdentifierQuotes(value));
};
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inDouble) {
current += ch;
if (ch === '"' && text[i + 1] === '"') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === '"') inDouble = false;
continue;
}
if (inBacktick) {
current += ch;
if (ch === '`' && text[i + 1] === '`') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === '`') inBacktick = false;
continue;
}
if (inBracket) {
current += ch;
if (ch === ']' && text[i + 1] === ']') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === ']') inBracket = false;
continue;
}
if (ch === '"') {
inDouble = true;
current += ch;
continue;
}
if (ch === '`') {
inBacktick = true;
current += ch;
continue;
}
if (ch === '[') {
inBracket = true;
current += ch;
continue;
}
if (ch === '.') {
flush();
continue;
}
current += ch;
}
flush();
return segments;
};
export const splitQualifiedName = (qualifiedName: string): QualifiedNameParts => {
const segments = splitQualifiedNameSegments(qualifiedName);
if (segments.length === 0) return { parentPath: '', objectName: '' };
if (segments.length === 1) return { parentPath: '', objectName: segments[0] };
return {
parentPath: segments.slice(0, -1).join('.'),
objectName: segments[segments.length - 1],
};
};
export const splitQualifiedNameLast = splitQualifiedName;

View File

@@ -1,17 +1,5 @@
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) {
return { schemaName: '', objectName: raw };
}
return {
schemaName: raw.substring(0, idx),
objectName: raw.substring(idx + 1),
};
};
import { splitQualifiedNameLast } from './qualifiedName';
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
@@ -45,7 +33,7 @@ export const normalizeSidebarViewName = (dialect: string, dbName: string, schema
}
if (normalizedDialect === 'mysql') {
const parsed = splitQualifiedName(normalizedViewName);
const parsed = splitQualifiedNameLast(normalizedViewName);
if (parsed.objectName) {
return parsed.objectName;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildOrderBySQL, buildPaginatedSelectSQL, reverseOrderBySQL } from './sql';
import { buildOrderBySQL, buildPaginatedSelectSQL, quoteQualifiedIdent, reverseOrderBySQL } from './sql';
describe('buildOrderBySQL', () => {
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
@@ -52,3 +52,15 @@ describe('reverseOrderBySQL', () => {
.toBe(' ORDER BY COALESCE([a], [b]) DESC, [id] ASC');
});
});
describe('quoteQualifiedIdent', () => {
it('does not split dots inside quoted DuckDB identifiers', () => {
expect(quoteQualifiedIdent('duckdb', '"daily.events"."2026.06"'))
.toBe('"daily.events"."2026.06"');
});
it('preserves three-part DuckDB names with quoted dots', () => {
expect(quoteQualifiedIdent('duckdb', '"analytics.catalog"."main.schema"."daily.events"'))
.toBe('"analytics.catalog"."main.schema"."daily.events"');
});
});

View File

@@ -1,3 +1,5 @@
import { splitQualifiedNameSegments, stripIdentifierQuotes } from './qualifiedName';
export type FilterCondition = {
id?: number;
enabled?: boolean;
@@ -8,17 +10,7 @@ export type FilterCondition = {
value2?: string;
};
const normalizeIdentPart = (ident: string) => {
let raw = (ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
raw = raw.slice(1, -1).trim();
}
raw = raw.replace(/["`]/g, '').trim();
return raw;
};
const normalizeIdentPart = (ident: string) => stripIdentifierQuotes(ident);
// 检查标识符是否需要引号(包含特殊字符或是保留字)
const needsQuote = (ident: string): boolean => {
@@ -62,9 +54,10 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
if (parts.length <= 1) return quoteIdentPart(dbType, raw);
return parts.map(p => quoteIdentPart(dbType, p)).join('.');
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
if (parts.length === 0) return quoteIdentPart(dbType, raw);
if (parts.length === 1 && parts[0] === normalizeIdentPart(raw)) return quoteIdentPart(dbType, raw);
return parts.map((part) => quoteIdentPart(dbType, part)).join('.');
};
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");