diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 34d0092fee..f2e399ecf0 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -25,7 +25,7 @@ import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities"; import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities"; import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; -import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState"; +import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState"; import type { ForeignKeyInfo, TriggerInfo } from "@/types/database"; import * as api from "@/lib/api"; @@ -482,6 +482,7 @@ function addIndex() { id: `new:${uuid()}`, name: "", columns: [], + nameEdited: false, isUnique: false, isPrimary: false, filter: "", @@ -500,6 +501,32 @@ function addIndex() { }); } +function structureIndexTableName(): string { + return (isCreateMode.value ? newTableName.value : props.tableName).trim(); +} + +function existingIndexNamesForDraft(index: EditableStructureIndex): string[] { + return indexes.value.filter((item) => item.id !== index.id && !item.markedForDrop).map((item) => item.name); +} + +function generatedIndexNameForDraft(index: EditableStructureIndex, columnsForName = index.columns): string { + return generateUniqueIndexName(structureIndexTableName(), columnsForName, existingIndexNamesForDraft(index)); +} + +function refreshAutoIndexName(index: EditableStructureIndex, previousColumns = index.columns) { + if (index.original || index.nameEdited) return; + const previousName = generateIndexName(structureIndexTableName(), previousColumns); + const previousUniqueName = generateUniqueIndexName(structureIndexTableName(), previousColumns, existingIndexNamesForDraft(index)); + const currentName = index.name.trim(); + if (currentName && currentName !== previousName && currentName !== previousUniqueName) return; + index.name = generatedIndexNameForDraft(index); +} + +function onIndexNameInput(index: EditableStructureIndex, value: string | number) { + index.name = String(value ?? ""); + index.nameEdited = true; +} + const availableColumnNames = computed(() => columns.value .filter((c) => !c.markedForDrop) @@ -515,9 +542,11 @@ const filteredColumnNames = computed(() => { }); function toggleIndexColumn(index: EditableStructureIndex, col: string) { + const previousColumns = [...index.columns]; const i = index.columns.indexOf(col); if (i >= 0) index.columns.splice(i, 1); else index.columns.push(col); + refreshAutoIndexName(index, previousColumns); } function toggleIncludedColumn(index: EditableStructureIndex, col: string) { @@ -699,6 +728,12 @@ watch( { deep: true, immediate: true }, ); +watch([() => props.tableName, newTableName], () => { + for (const index of indexes.value) { + refreshAutoIndexName(index); + } +}); + watch(refreshVersion, (version, previous) => { if (version === previous || !version || isCreateMode.value) return; void loadStructure(true); @@ -1019,7 +1054,7 @@ watch(activeTab, (tab) => { - + diff --git a/apps/desktop/src/lib/tableStructureEditorSql.ts b/apps/desktop/src/lib/tableStructureEditorSql.ts index 7cbfc62331..e60af9e101 100644 --- a/apps/desktop/src/lib/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/tableStructureEditorSql.ts @@ -30,6 +30,7 @@ export interface EditableStructureIndex { id: string; name: string; columns: string[]; + nameEdited?: boolean; isUnique: boolean; isPrimary: boolean; filter: string; diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index d050b57e50..0c27f631df 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -358,6 +358,7 @@ export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[ id: `existing:${index.name}`, name: index.name, columns: [...index.columns], + nameEdited: true, isUnique: index.is_unique, isPrimary: index.is_primary, filter: index.filter ?? "", @@ -373,6 +374,48 @@ export function toColumnNames(columns: string[]): string { return columns.join(", "); } +const AUTO_INDEX_NAME_MAX_LENGTH = 63; + +function normalizeIndexNamePart(value: string): string { + const trimmed = value.trim(); + const unquoted = (trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("`") && trimmed.endsWith("`")) || (trimmed.startsWith('"') && trimmed.endsWith('"')) ? trimmed.slice(1, -1) : trimmed; + return unquoted + .trim() + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} + +function truncateIndexName(value: string, maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string { + if (value.length <= maxLength) return value; + const suffix = "_IDX"; + if (!value.endsWith(suffix)) return value.slice(0, maxLength).replace(/_+$/g, ""); + return `${value.slice(0, maxLength - suffix.length).replace(/_+$/g, "")}${suffix}`; +} + +export function generateIndexName(tableName: string, columns: string[], maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string { + const parts = [tableName, ...columns].map(normalizeIndexNamePart).filter(Boolean); + if (parts.length === 0) return ""; + return truncateIndexName(`${parts.join("_")}_IDX`, maxLength); +} + +export function generateUniqueIndexName(tableName: string, columns: string[], existingNames: Iterable, maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string { + const base = generateIndexName(tableName, columns, maxLength); + if (!base) return ""; + + const taken = new Set([...existingNames].map((name) => name.trim().toLowerCase()).filter(Boolean)); + if (!taken.has(base.toLowerCase())) return base; + + for (let counter = 2; counter < 10_000; counter++) { + const suffix = `_${counter}`; + const stem = base.length + suffix.length <= maxLength ? base : base.slice(0, maxLength - suffix.length).replace(/_+$/g, ""); + const candidate = `${stem}${suffix}`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } + return base; +} + export function splitDataType(raw: string): { baseType: string; params: string } { const trimmed = raw.trim(); const parenIdx = trimmed.indexOf("("); diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index 98b64d5cf0..e47aa535b7 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, getDataTypeOptions, normalizeDataTypeParams, parseExtraToColumnExtra, toColumnNames } from "../../apps/desktop/src/lib/tableStructureEditorState.ts"; +import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getDataTypeOptions, normalizeDataTypeParams, parseExtraToColumnExtra, toColumnNames } from "../../apps/desktop/src/lib/tableStructureEditorState.ts"; import type { ColumnInfo, IndexInfo } from "../../apps/desktop/src/types/database.ts"; const columns: ColumnInfo[] = [ @@ -153,6 +153,17 @@ test("creates editable index drafts and splits pasted column lists", () => { assert.equal(toColumnNames(["id", "name"]), "id, name"); }); +test("generates conventional index names from table and columns", () => { + assert.equal(generateIndexName("A", ["B"]), "A_B_IDX"); + assert.equal(generateIndexName("order item", ["customer-id", "created_at"]), "ORDER_ITEM_CUSTOMER_ID_CREATED_AT_IDX"); + assert.equal(generateIndexName("users", ["email"], 12), "USERS_EM_IDX"); +}); + +test("generates unique index names when automatic name already exists", () => { + assert.equal(generateUniqueIndexName("users", ["email"], ["USERS_EMAIL_IDX"]), "USERS_EMAIL_IDX_2"); + assert.equal(generateUniqueIndexName("users", ["email"], ["users_email_idx", "USERS_EMAIL_IDX_2"]), "USERS_EMAIL_IDX_3"); +}); + test("structure editor target label omits duplicate database and schema", () => { assert.equal(buildStructureTargetLabel("online-clickhouse", "testdb", "testdb", "users"), "online-clickhouse / testdb / users"); assert.equal(buildStructureTargetLabel("online-postgres", "app", "public", "users"), "online-postgres / app / public / users");