feat(structure): auto name new indexes

This commit is contained in:
t8y2
2026-06-11 22:40:10 +08:00
parent 57d03c2f08
commit a017e04d4c
4 changed files with 93 additions and 3 deletions
@@ -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) => {
<tbody>
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-index-row="!index.original ? 'true' : undefined">
<td :class="structureCellClass">
<Input v-model="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input />
<Input :model-value="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
</td>
<td :class="[structureCellClass, 'overflow-hidden']">
<DropdownMenu v-if="canEditIndexDraft(index)">
@@ -30,6 +30,7 @@ export interface EditableStructureIndex {
id: string;
name: string;
columns: string[];
nameEdited?: boolean;
isUnique: boolean;
isPrimary: boolean;
filter: string;
@@ -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<string>, 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("(");
@@ -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");