feat(pg,kingbase): partition table operations and detail queries

This commit is contained in:
amwps290 authored and GitHub committed 2026-09-21 15:36:49 +08:00
1 parent 882bd130ab
commit 1000aa07b6
62 files changed
+6180 -133

No files matched your search

+69 -2
View File
@@ -45,6 +45,7 @@ import {
RefreshCw,
RefreshCcw,
TableProperties,
Network,
UserRound,
Database,
Eraser,
@@ -90,7 +91,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef, QueryPageJumpProgress } from "@/types/database";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef, QueryPageJumpProgress } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoColumnNavigation";
import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference";
@@ -10438,6 +10439,16 @@ const constraintsLoaded = ref(false);
const constraintsLoading = ref(false);
const constraintsError = ref("");
const constraintsRequestGeneration = ref(0);
const partitioning = ref<PgTablePartitioning | null>(null);
const partitioningLoaded = ref(false);
const partitioningLoading = ref(false);
const partitioningError = ref("");
const partitioningRequestGeneration = ref(0);
// The Partitions tab is only offered for tables that actually are partitioned
// (a partitioned parent or a member partition). Probed lazily with the cheap
// partition-status query, not the full tree.
const isPartitionedTable = ref(false);
const partitionStatusResolved = ref(false);
// The Constraints tab hides foreign keys when a dedicated Foreign Keys tab is
// also shown (each constraint appears once; FK navigation stays in that tab).
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
@@ -10448,6 +10459,7 @@ const activeTableInfoLoading = computed(() => {
if (activeTableInfoTab.value === "indexes") return indexesLoading.value;
if (activeTableInfoTab.value === "foreignKeys") return foreignKeysLoading.value;
if (activeTableInfoTab.value === "constraints") return constraintsLoading.value;
if (activeTableInfoTab.value === "partitions") return partitioningLoading.value;
return activeTableInfoTab.value === "triggers" && triggersLoading.value;
});
const cellDetailPanelLayout = computed(() => settingsStore.editorSettings.cellDetailPanelLayout);
@@ -10550,6 +10562,30 @@ const mongoConnectionConfig = resolvedConnectionConfig;
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
const canShowTableIndexes = computed(() => tableMetadataCapabilities.value.indexes && (resolvedDatabaseType.value !== "mongodb" || mongoCollectionSupportsIndexes(props.tableMeta?.tableType)));
async function probeTablePartitionStatus() {
const connectionId = props.connectionId;
const database = props.database;
const schema = props.tableMeta?.schema || props.database || "";
const tableName = props.tableMeta?.tableName;
const identity = currentIndexTableIdentity.value;
if (!tableMetadataCapabilities.value.partitions || !connectionId || !database || !tableName || !identity) {
isPartitionedTable.value = false;
partitionStatusResolved.value = true;
return;
}
try {
const status = await api.getTablePartitionStatus(connectionId, database, schema, tableName);
if (identity !== currentIndexTableIdentity.value) return;
isPartitionedTable.value = status.isPartitionedParent || status.isPartition;
} catch {
// Fail closed: hide the tab rather than offering one that cannot load.
if (identity !== currentIndexTableIdentity.value) return;
isPartitionedTable.value = false;
} finally {
if (identity === currentIndexTableIdentity.value) partitionStatusResolved.value = true;
}
}
const metadataLoaders = useDataGridTableMetadataLoaders({
props,
state: {
@@ -10576,6 +10612,11 @@ const metadataLoaders = useDataGridTableMetadataLoaders({
constraintsLoaded,
constraintsLoading,
constraintsError,
partitioning,
partitioningLoaded,
partitioningLoading,
partitioningError,
partitioningRequestGeneration,
tableInfoColumnsRequestGeneration,
tableOwnerRequestGeneration,
indexesRequestGeneration,
@@ -10592,7 +10633,7 @@ const metadataLoaders = useDataGridTableMetadataLoaders({
toastMongoIndexRefreshError: (message) => toast(t("contextMenu.mongoIndexRefreshFailed", { message }), 5000),
});
const { fetchDdl, fetchTableInfoColumns, fetchTableOwner, currentIndexTableIdentity, fetchIndexes, refreshMongoIndexMetadataAfterMutation, currentForeignKeyTableIdentity, fetchForeignKeys: fetchForeignKeysMetadata, fetchTriggers, fetchConstraints } = metadataLoaders;
const { fetchDdl, fetchTableInfoColumns, fetchTableOwner, currentIndexTableIdentity, fetchIndexes, refreshMongoIndexMetadataAfterMutation, currentForeignKeyTableIdentity, fetchForeignKeys: fetchForeignKeysMetadata, fetchTriggers, fetchConstraints, fetchPartitions } = metadataLoaders;
tableMetadataLoaderFetchForeignKeys = fetchForeignKeysMetadata;
const tableInfoTabs = computed(() => {
const tabs: TableInfoTabItem[] = [];
@@ -10639,6 +10680,14 @@ const tableInfoTabs = computed(() => {
count: triggers.value.length,
});
}
if (tableMetadataCapabilities.value.partitions && isPartitionedTable.value) {
tabs.push({
id: "partitions",
label: t("structureEditor.partitions"),
icon: Network,
count: partitioning.value?.partitions.length,
});
}
return tabs;
});
const tableInfoTabListStyle = computed(() => ({
@@ -10662,6 +10711,10 @@ function toggleTableInfoDrawerPinned() {
}
async function selectTableInfoTab(tab: TableInfoTab) {
// The drawer is often opened after the table was already selected, so the
// partition status may never have been probed; do it here (once) before the
// tab list is consulted, or the Partitions tab would be missing entirely.
if (!partitionStatusResolved.value) await probeTablePartitionStatus();
const tabSupported = tableInfoTabs.value.some((item) => item.id === tab);
const nextTab = tabSupported ? tab : tableInfoTabs.value[0]?.id;
if (!nextTab) return;
@@ -10672,6 +10725,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
else if (nextTab === "foreignKeys") await fetchForeignKeys();
else if (nextTab === "constraints") await fetchConstraints();
else if (nextTab === "triggers") await fetchTriggers();
else if (nextTab === "partitions") await fetchPartitions();
}
watch(
@@ -10710,6 +10764,9 @@ async function refreshActiveTableInfo() {
} else if (activeTableInfoTab.value === "triggers") {
triggersLoaded.value = false;
await fetchTriggers();
} else if (activeTableInfoTab.value === "partitions") {
partitioningLoaded.value = false;
await fetchPartitions(true);
}
}
@@ -10742,6 +10799,13 @@ watch(
constraintsLoading.value = false;
constraintsError.value = "";
constraintsRequestGeneration.value += 1;
partitioning.value = null;
partitioningLoaded.value = false;
partitioningLoading.value = false;
partitioningError.value = "";
partitioningRequestGeneration.value += 1;
isPartitionedTable.value = false;
partitionStatusResolved.value = false;
// 表身份变更后,主动触发索引加载,确保索引指示器在切换表后立即可见
if (showIndexIndicatorsInHeader.value && canShowTableIndexes.value && currentIndexTableIdentity.value) {
void fetchIndexes();
@@ -13475,6 +13539,9 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:constraints="filteredConstraints"
:constraints-loading="constraintsLoading"
:constraints-error="constraintsError"
:partitioning="partitioning"
:partitions-loading="partitioningLoading"
:partitions-error="partitioningError"
:is-protected-mongo-index="isProtectedMongoIndex"
:format-column-type="gaussdbMColumnType"
@table-info-column-click="onTableInfoColumnClick"
@@ -2,8 +2,9 @@
import { KeyRound, Loader2, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import TablePartitionsPanel from "@/components/structure/TablePartitionsPanel.vue";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
import type { ColumnInfo, ConstraintInfo, ForeignKeyInfo, IndexInfo, TableInfoTab, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConstraintInfo, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TableInfoTab, TriggerInfo } from "@/types/database";
interface DataGridTableInfoPanelsProps {
activeTab: TableInfoTab;
@@ -23,6 +24,9 @@ interface DataGridTableInfoPanelsProps {
constraints: ConstraintInfo[];
constraintsLoading: boolean;
constraintsError: string;
partitioning: PgTablePartitioning | null;
partitionsLoading: boolean;
partitionsError: string;
isProtectedMongoIndex: (index: IndexInfo) => boolean;
formatColumnType: (dataType: string) => string;
}
@@ -214,6 +218,8 @@ const { t } = useI18n();
</div>
</div>
</div>
<TablePartitionsPanel v-else-if="props.activeTab === 'partitions'" :partitioning="props.partitioning" :loading="props.partitionsLoading" :error="props.partitionsError" :search-query="props.searchQuery" />
</template>
<style scoped>
@@ -69,9 +69,10 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import TablePartitionsPanel from "@/components/structure/TablePartitionsPanel.vue";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, PgTablePartitioning, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsAiAssistantContext, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
@@ -79,6 +80,7 @@ import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionTableSq
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { PARTITION_TREE_INDENT_PX } from "@/lib/table/pgPartitionPresentation";
import {
buildDropObjectSql,
buildDropTableSql,
@@ -144,6 +146,7 @@ import {
initialObjectBrowserSortDirection,
objectBrowserRowLegacyPinnedTreeNodeIds,
objectBrowserRowMatchesPinnedTreeNode,
groupObjectBrowserRows,
objectBrowserRowPinnedTreeNodeIdentity,
sortObjectBrowserRows,
summarizeObjectBrowserSearch,
@@ -265,6 +268,13 @@ const tableTriggersLoaded = ref(false);
const tableConstraints = ref<ConstraintInfo[]>([]);
const tableConstraintsLoading = ref(false);
const tableConstraintsLoaded = ref(false);
const tablePartitions = ref<PgTablePartitioning | null>(null);
const tablePartitionsLoading = ref(false);
const tablePartitionsLoaded = ref(false);
// Only tables that actually are partitioned get the Partitions tab; the cheap
// partition-status probe decides before the full tree is fetched.
const tableIsPartitioned = ref(false);
const tablePartitionStatusResolved = ref(false);
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/TableStructureEditor.
const tableConstraintsForTab = computed(() => constraintsForConstraintsTab(tableConstraints.value, tableMetadataCapabilities.value.foreignKeys));
@@ -275,6 +285,7 @@ const activeTableInfoLoading = computed(() => {
if (tableInfoTab.value === "columns") return tableColumnsLoading.value;
if (tableInfoTab.value === "indexes") return tableIndexesLoading.value;
if (tableInfoTab.value === "foreignKeys") return tableForeignKeysLoading.value;
if (tableInfoTab.value === "partitions") return tablePartitionsLoading.value;
return tableInfoTab.value === "triggers" && tableTriggersLoading.value;
});
const SIDE_PANEL_MIN_WIDTH = 280;
@@ -624,17 +635,14 @@ const gridTemplateColumns = computed(() => {
const objectGridMinWidth = computed(() => {
return objectBrowserColumns.value.reduce((total, key) => total + objectColumnWidths.value[key], 0) + Math.max(0, objectBrowserColumns.value.length - 1) * 12 + 24;
});
const partitionRowsByParentId = computed(() => {
const groups = new Map<string, ObjectBrowserRow[]>();
for (const row of rows.value) {
if (!row.partitionParentId) continue;
const group = groups.get(row.partitionParentId) ?? [];
group.push(row);
groups.set(row.partitionParentId, group);
}
return groups;
});
const filteredRows = computed(() => groupedFilteredRows());
const groupedRows = computed(() => groupedFilteredRows());
const filteredRows = computed(() => groupedRows.value.rows);
/** Nesting level of a partition row (0 for a top-level object). */
function partitionRowDepth(row: ObjectBrowserRow): number {
return groupedRows.value.depths.get(row.id) ?? 0;
}
const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE"));
// ---- Grid (tile) view virtualization ----
@@ -924,32 +932,13 @@ function removePinnedObjectBrowserRows(rows: readonly ObjectBrowserRow[]) {
}
function groupedFilteredRows() {
const query = search.value.trim();
const candidateRows = rows.value.filter(rowMatchesObjectFilter);
const candidateIds = new Set(candidateRows.map((row) => row.id));
const matchingRows = objectSearchSummary.value.matchingRows.filter(rowMatchesObjectFilter);
const matchingIds = new Set(matchingRows.map((row) => row.id));
const parentIdsWithMatchingPartitions = new Set(matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : [])));
const rootRows = candidateRows.filter((row) => {
if (row.partitionParentId) return false;
if (!query) return true;
return matchingIds.has(row.id) || parentIdsWithMatchingPartitions.has(row.id);
return groupObjectBrowserRows({
rows: rows.value.filter(rowMatchesObjectFilter),
matchingRows: objectSearchSummary.value.matchingRows.filter(rowMatchesObjectFilter),
query: search.value.trim(),
expandedPartitionParentIds: expandedPartitionParentIds.value,
sortRows: sortObjectBrowserRowsWithPins,
});
const sortedRoots = sortObjectBrowserRowsWithPins(rootRows);
const result: ObjectBrowserRow[] = [];
for (const row of sortedRoots) {
result.push(row);
const partitions = partitionRowsByParentId.value.get(row.id)?.filter((partition) => candidateIds.has(partition.id));
if (!partitions?.length) continue;
const parentMatches = matchingIds.has(row.id);
const shouldShowPartitions = expandedPartitionParentIds.value.has(row.id) || !!query;
if (!shouldShowPartitions) continue;
const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions;
result.push(...sortObjectBrowserRowsWithPins(visiblePartitions));
}
return result;
}
function iconClass(type: ObjectBrowserRow["type"]) {
@@ -1092,6 +1081,9 @@ const tableInfoTabs = computed<TableInfoTabItem[]>(() => {
if (tableMetadataCapabilities.value.triggers) {
tabs.push({ id: "triggers", label: t("grid.tableInfoTriggers"), icon: RotateCcw, count: tableTriggers.value.length });
}
if (tableMetadataCapabilities.value.partitions && tableIsPartitioned.value) {
tabs.push({ id: "partitions", label: t("structureEditor.partitions"), icon: Network, count: tablePartitions.value?.partitions.length });
}
return tabs;
});
@@ -1153,6 +1145,11 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
tableForeignKeys.value = [];
tableTriggers.value = [];
tableConstraints.value = [];
tablePartitions.value = null;
tablePartitionsLoaded.value = false;
tablePartitionsLoading.value = false;
tableIsPartitioned.value = false;
tablePartitionStatusResolved.value = false;
tableColumnsLoaded.value = false;
tableDdlLoaded.value = false;
tableIndexesLoaded.value = false;
@@ -1160,12 +1157,18 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
tableTriggersLoaded.value = false;
tableConstraintsLoaded.value = false;
tableInfoSearchQuery.value = "";
// Determine initial tab: explicit request > previously activated
// Determine initial tab: explicit request > previously activated. Resolve the
// partition status first so a persisted `partitions` tab is not rejected (and
// overwritten) before the probe lands.
await probeTablePartitionStatus();
const firstTab = initialTab ?? tableInfoTab.value;
await selectTableInfoTab(firstTab);
}
async function selectTableInfoTab(tab: TableInfoTab) {
// The panel can be re-selected (or restored) without `openTableInfo`, so make
// sure the partition status has been probed before the tab list is consulted.
if (!tablePartitionStatusResolved.value) await probeTablePartitionStatus();
const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : tableInfoTabs.value[0]?.id;
if (!nextTab) return;
tableInfoTab.value = nextTab;
@@ -1176,6 +1179,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
else if (nextTab === "foreignKeys") await fetchTableForeignKeys();
else if (nextTab === "constraints") await fetchTableConstraints();
else if (nextTab === "triggers") await fetchTableTriggers();
else if (nextTab === "partitions") await fetchTablePartitions();
}
function tableMetadataRequest(row: ObjectBrowserRow): ObjectDdlRequest {
@@ -1309,6 +1313,53 @@ async function fetchTableTriggers(force = false) {
}
}
async function probeTablePartitionStatus() {
const row = sidePanelRow.value;
if (!row || !tableMetadataCapabilities.value.partitions) {
tableIsPartitioned.value = false;
tablePartitionStatusResolved.value = true;
return;
}
const epoch = sidePanelGuard.capture();
try {
const request = tableMetadataRequest(row);
const status = await api.getTablePartitionStatus(request.connectionId, request.database, request.schema || request.database, request.tableName);
if (sidePanelGuard.isStale(epoch)) return;
tableIsPartitioned.value = status.isPartitionedParent || status.isPartition;
} catch {
// Fail closed: hide the tab rather than offering one that cannot load.
if (sidePanelGuard.isStale(epoch)) return;
tableIsPartitioned.value = false;
} finally {
if (sidePanelGuard.isFresh(epoch)) tablePartitionStatusResolved.value = true;
}
}
async function fetchTablePartitions(force = false) {
const row = sidePanelRow.value;
if (!row || (tablePartitionsLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tablePartitionsLoading.value = true;
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
// Live catalog read: partition metadata has no persisted cache facet.
const value = await api.getTablePartitioning(request.connectionId, request.database, request.schema || request.database, request.tableName);
if (sidePanelGuard.isStale(epoch)) return;
tablePartitions.value = value;
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tablePartitions.value = null;
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) {
tablePartitionsLoaded.value = loadedSuccessfully;
tablePartitionsLoading.value = false;
}
}
}
async function fetchTableConstraints(force = false) {
const row = sidePanelRow.value;
if (!row || (tableConstraintsLoaded.value && !force)) return;
@@ -1361,6 +1412,10 @@ async function refreshActiveTableInfo() {
tableTriggers.value = [];
tableTriggersLoaded.value = false;
await fetchTableTriggers(true);
} else if (tableInfoTab.value === "partitions") {
tablePartitions.value = null;
tablePartitionsLoaded.value = false;
await fetchTablePartitions(true);
}
}
@@ -3665,6 +3720,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="flex min-w-0 items-center gap-2">
<span v-if="partitionRowDepth(item) > 0" :data-partition-depth="partitionRowDepth(item)" :style="{ width: `${partitionRowDepth(item) * PARTITION_TREE_INDENT_PX}px` }" class="h-5 shrink-0" aria-hidden="true" />
<button
v-if="item.partitionCount"
type="button"
@@ -3675,7 +3731,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<ChevronDown v-if="isPartitionParentExpanded(item)" class="h-3.5 w-3.5" />
<ChevronRight v-else class="h-3.5 w-3.5" />
</button>
<span v-else-if="item.partitionParentId" class="ml-4 h-5 w-5 shrink-0" />
<span v-else-if="item.partitionParentId" class="h-5 w-5 shrink-0" />
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
<span class="truncate text-[13px] font-medium text-foreground" :title="item.displayName">{{ item.displayName }}</span>
<span v-if="item.partitionCount" class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
@@ -3943,6 +3999,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
</div>
<TablePartitionsPanel v-else-if="tableInfoTab === 'partitions'" :partitioning="tablePartitions" :loading="tablePartitionsLoading" :error="''" :search-query="tableInfoSearchQuery" />
<pre
v-else-if="tableInfoTab === 'ddl' && !tableDdlLoading"
ref="tableInfoDdlPreRef"
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ChevronDown, ChevronRight, Loader2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { PARTITION_TREE_INDENT_PX, flattenPgPartitionNodes, pgPartitionBoundText, pgPartitionKindLabelKey, pgPartitionNodeBoundText, pgPartitionRowHint, visiblePgPartitionRows } from "@/lib/table/pgPartitionPresentation";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { PgPartitionKind, PgPartitionNode, PgTablePartitioning } from "@/types/database";
/**
* Read-only partition summary shared by the DataGrid / ObjectBrowser
* "table properties" drawers. The full structure editor keeps its own
* editable partitions pane.
*/
interface TablePartitionsPanelProps {
partitioning: PgTablePartitioning | null;
loading: boolean;
error: string;
searchQuery?: string;
}
const props = defineProps<TablePartitionsPanelProps>();
const { t } = useI18n();
function partitionStrategyLabel(kind?: PgPartitionKind): string {
const key = pgPartitionKindLabelKey(kind);
return key ? t(key) : "";
}
/** Size/count estimates live in the row's hover hint so the row stays two lines. */
function partitionRowHint(node: PgPartitionNode): string | undefined {
return pgPartitionRowHint(node, t, formatBytes);
}
const allRows = computed(() => flattenPgPartitionNodes(props.partitioning?.partitions ?? []));
// Folded parents, by row key. Reset whenever a different tree is shown.
const collapsedPartitionKeys = ref<Set<string>>(new Set());
watch(
() => props.partitioning,
() => {
collapsedPartitionKeys.value = new Set();
},
);
function togglePartitionRow(key: string) {
const next = new Set(collapsedPartitionKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedPartitionKeys.value = next;
}
const rows = computed(() => {
const query = props.searchQuery?.trim().toLowerCase();
// While searching, ignore the fold so a matching sub-partition is never hidden.
if (query) return allRows.value.filter((row) => row.node.name.toLowerCase().includes(query));
return visiblePgPartitionRows(allRows.value, collapsedPartitionKeys.value);
});
</script>
<template>
<div class="flex-1 min-h-0 overflow-auto">
<div v-if="props.loading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="props.error" class="p-3 text-xs text-destructive">
{{ props.error }}
</div>
<div v-else-if="!props.partitioning || (!props.partitioning.isPartitioned && !props.partitioning.isPartition)" class="p-6 text-center text-xs text-muted-foreground">
{{ t("structureEditor.partitionsEmpty") }}
</div>
<div v-else class="divide-y">
<div class="p-3 text-xs">
<div class="flex flex-wrap items-center gap-1.5">
<span v-if="props.partitioning.isPartitioned" class="rounded border px-1.5 py-0.5 text-[10px] text-muted-foreground">{{ partitionStrategyLabel(props.partitioning.strategy) }}</span>
<span v-else class="rounded border px-1.5 py-0.5 text-[10px] text-muted-foreground">{{ t("structureEditor.partitionMemberBadge") }}</span>
<span v-if="props.partitioning.keyDefinition" class="min-w-0 truncate font-mono text-[11px]">{{ props.partitioning.keyDefinition }}</span>
</div>
<div v-if="props.partitioning.parent" class="mt-1 truncate font-mono text-[11px] text-muted-foreground">{{ t("structureEditor.partitionsParent") }}: {{ props.partitioning.parent }}</div>
<div v-if="props.partitioning.ownBound" class="mt-1 truncate font-mono text-[11px] text-muted-foreground">{{ t("structureEditor.partitionsOwnBound") }}: {{ pgPartitionBoundText(props.partitioning.ownBound) }}</div>
<div v-if="props.partitioning.defaultPartition" class="mt-1 truncate font-mono text-[11px] text-muted-foreground">{{ t("structureEditor.partitionsDefault") }}: {{ props.partitioning.defaultPartition }}</div>
</div>
<div v-if="rows.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ props.searchQuery ? t("grid.tableInfoNoResults") : t("structureEditor.partitionsEmptyChildren") }}
</div>
<div v-for="row in rows" :key="row.key" :title="partitionRowHint(row.node)" class="flex items-start gap-1 px-3 py-2 text-xs">
<span :data-partition-depth="row.depth" :style="{ width: `${row.depth * PARTITION_TREE_INDENT_PX}px` }" class="shrink-0 self-stretch" aria-hidden="true"></span>
<button
v-if="row.node.children.length"
type="button"
class="mt-0.5 h-4 w-4 shrink-0 self-start text-muted-foreground transition-colors hover:text-foreground"
:aria-label="collapsedPartitionKeys.has(row.key) ? t('structureEditor.partitionExpand') : t('structureEditor.partitionCollapse')"
:title="collapsedPartitionKeys.has(row.key) ? t('structureEditor.partitionExpand') : t('structureEditor.partitionCollapse')"
@click="togglePartitionRow(row.key)"
>
<ChevronRight v-if="collapsedPartitionKeys.has(row.key)" class="h-3.5 w-3.5" />
<ChevronDown v-else class="h-3.5 w-3.5" />
</button>
<span v-else class="mt-0.5 h-4 w-4 shrink-0 self-start" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium truncate">{{ row.node.name }}</span>
<span v-if="row.node.children.length" class="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{{ t("structureEditor.partitionChildCount", { count: row.node.children.length }) }}</span>
<span v-if="row.depth > 0" class="rounded border border-dashed px-1 py-px text-[10px] text-muted-foreground">{{ t("structureEditor.partitionSubPartitionBadge") }}</span>
<span v-if="row.node.strategy" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ partitionStrategyLabel(row.node.strategy) }}</span>
<span v-if="row.node.bound?.kind === 'default'" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("structureEditor.partitionBoundDefault") }}</span>
</div>
<div v-if="pgPartitionNodeBoundText(row.node)" class="mt-0.5 font-mono text-[11px] text-muted-foreground break-all">
{{ pgPartitionNodeBoundText(row.node) }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -41,7 +41,17 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { invalidateObjectMetadataCache, loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
import {
type BuildTableStructureChangeSqlOptions,
type EditableStructureColumn,
type EditableStructureForeignKey,
type EditableStructureIndex,
type EditableStructureTrigger,
type TablePartitionBoundDraft,
type TablePartitionOperation,
type TablePartitionOperationKind,
type TablePartitionSqlOptions,
} from "@/lib/table/tableStructureEditorSql";
import { buildMysqlAutoIncrementCounterStatement, canEditMysqlAutoIncrementCounter, refreshMysqlAutoIncrementCounterDraft } from "@/lib/table/mysqlAutoIncrementCounter";
import { mysqlTableCollationSql, parseMysqlTableCollation } from "@/lib/table/mysqlTableCollation";
import { MYSQL_STORAGE_ENGINES_SQL, mysqlTableEngineSql, mysqlTableEngineSqlOption, parseMysqlTableEngineMetadata, refreshMysqlTableEngineDraft, supportsMysqlTableEngine } from "@/lib/table/mysqlTableEngine";
@@ -51,6 +61,8 @@ import { getPostgresDataTypeHelp, gaussdbMTypeDisplayName } from "@/lib/table/po
import { getSqliteDataTypeHelp } from "@/lib/table/sqliteDataTypeHelp";
import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { PARTITION_TREE_INDENT_PX, flattenPgPartitionNodes, pgPartitionBoundText, pgPartitionKindLabelKey, pgPartitionNodeBoundText, pgPartitionRowHint, splitPgPartitionBoundValues, visiblePgPartitionRows, type PgPartitionTreeRow } from "@/lib/table/pgPartitionPresentation";
import { formatBytes } from "@/lib/database/serverMetrics";
import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visibleTableStructureRefreshScope, type TableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading";
import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, sanitizeStructureIndexesForCapabilities, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, normalizeUnsupportedConcurrentIndexes, type ConcurrentIndexAvailability } from "@/lib/table/concurrentIndexAvailability";
@@ -58,7 +70,7 @@ import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataG
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { codeMirrorSqlDialectForConnection, connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { postgresListRolesSql, usersFromPostgresRolesResult } from "@/lib/database/databaseUserAdmin";
import type { ColumnInfo, ConstraintInfo, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database";
import type { ColumnInfo, ConstraintInfo, PgPartitionKind, PgPartitionNode, PgTablePartitioning, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database";
import {
applyManticoreDdlColumnExtras,
buildStructureTargetLabel,
@@ -132,6 +144,7 @@ const indexesScrollerRef = ref<StructureScrollerRef>();
const foreignKeysScrollerRef = ref<StructureScrollerRef>();
const constraintsScrollerRef = ref<StructureScrollerRef>();
const triggersScrollerRef = ref<StructureScrollerRef>();
const partitionsScrollerRef = ref<StructureScrollerRef>();
const ddlScrollerRef = ref<StructureScrollerRef>();
const structureHorizontalScrollbarTrackRef = ref<HTMLDivElement>();
const columnsTableRef = ref<HTMLElement | null>(null);
@@ -431,6 +444,51 @@ const indexes = ref<EditableStructureIndex[]>([]);
* is rejected by the server on such tables, so the option is disabled here and
* the SQL builder refuses any concurrent request on it (fail closed). */
const isPartitionedParent = ref(false);
// True when the edited table is itself a member partition of another table.
const isTablePartition = ref(false);
// The partition-status probe has settled (success or failure), so the tab can
// be hidden without hiding it merely because the probe is still in flight.
const partitionStatusResolved = ref(false);
/**
* Standalone partition-status probe for entry points that intentionally skip
* `loadStructure` (opening directly on the DDL tab). It only decides whether the
* Partitions tab is offered; the Concurrent-index normalization still runs from
* `loadStructure` when an editable tab loads.
*/
let partitionTabProbeRequestId = 0;
async function probePartitionsTabVisibility() {
if (!tableMetadataCapabilities.value.partitions || isCreateMode.value) {
isPartitionedParent.value = false;
isTablePartition.value = false;
partitionStatusResolved.value = true;
return;
}
const connectionId = props.connectionId;
const database = props.database;
const tableName = props.tableName;
if (!connectionId || !database || !tableName) {
partitionStatusResolved.value = true;
return;
}
const requestId = ++partitionTabProbeRequestId;
try {
const status = await api.getTablePartitionStatus(connectionId, database, metadataSchema.value, tableName);
if (requestId !== partitionTabProbeRequestId) return;
isPartitionedParent.value = status.isPartitionedParent;
isTablePartition.value = status.isPartition;
partitionStatusResolved.value = true;
} catch {
if (requestId !== partitionTabProbeRequestId) return;
isPartitionedParent.value = false;
isTablePartition.value = false;
// A failed probe leaves the concurrent-index availability unknown, so keep
// the documented fail-closed behavior (disable Concurrent) instead of
// assuming the table is a plain, non-partitioned one.
partitionStatusKnown.value = false;
partitionStatusResolved.value = true;
}
}
/** Whether the last partition-status probe succeeded. When it cannot be
* verified (probe failed), Concurrent is disabled — we must not assume a
* non-partitioned table we could not check. */
@@ -448,6 +506,269 @@ const sqliteSchemaRevision = ref<string>();
const foreignKeys = ref<EditableStructureForeignKey[]>([]);
const constraints = ref<ConstraintInfo[]>([]);
const constraintsLoaded = ref(false);
// Structured PostgreSQL partitioning view (partitioned parent, its descendant
// partitions, or a member partition's parent/bound). Read-only for now.
const partitioning = ref<PgTablePartitioning | null>(null);
const partitionsLoading = ref(false);
const partitionsError = ref("");
// Pending partition operations (not yet saved). Unlike columns/indexes these
// are explicit actions on the live catalog, so they are never diffed — they
// accumulate here until Save, then are cleared and the tree reloaded.
const partitionOperations = ref<TablePartitionOperation[]>([]);
// Create-mode partitioning declaration (`CREATE TABLE ... PARTITION BY ...`).
const createPartitioningEnabled = ref(false);
const createPartitioningKind = ref<PgPartitionKind>("range");
const createPartitioningColumns = ref<string[]>([]);
const createPartitioningExpression = ref("");
const partitionTreeRows = computed(() =>
flattenPgPartitionNodes(partitioning.value?.partitions ?? [], {
schema: metadataSchema.value,
name: props.tableName || "",
}),
);
// Folded parents, so a large partition hierarchy can be navigated by level.
const collapsedPartitionKeys = ref<Set<string>>(new Set());
function togglePartitionRow(key: string) {
const next = new Set(collapsedPartitionKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedPartitionKeys.value = next;
}
watch(
() => partitioning.value,
() => {
collapsedPartitionKeys.value = new Set();
},
);
const partitionVisibleRows = computed(() => visiblePgPartitionRows(partitionTreeRows.value, collapsedPartitionKeys.value));
const partitionCreatableColumns = computed(() => columns.value.filter((column) => !column.markedForDrop && !!column.name.trim()).map((column) => column.name.trim()));
function partitionStrategyLabel(kind?: PgPartitionKind): string {
const key = pgPartitionKindLabelKey(kind);
return key ? t(key) : "";
}
// --- Partition maintenance -------------------------------------------------
const partitionDialogOpen = ref(false);
const partitionDialogMode = ref<TablePartitionOperationKind>("create");
const partitionDialogName = ref("");
const partitionDialogSchema = ref("");
const partitionDialogParentSchema = ref("");
const partitionDialogParentTable = ref("");
const partitionDialogBoundKind = ref<PgPartitionKind | "default">("range");
const partitionDialogRangeFrom = ref("");
const partitionDialogRangeTo = ref("");
const partitionDialogListValues = ref("");
const partitionDialogModulus = ref("2");
const partitionDialogRemainder = ref("0");
const partitionDialogConcurrently = ref(false);
const partitionDialogError = ref("");
const canManagePartitions = computed(() => {
if (!tableMetadataCapabilities.value.partitions) return false;
if (isCreateMode.value) return createPartitioningEnabled.value;
return !!partitioning.value && (partitioning.value.isPartitioned || partitioning.value.isPartition);
});
const partitionSupportsConcurrentDetach = computed(() => databaseType.value === "postgres" && (partitioning.value?.serverVersionNum ?? 0) >= 140000 && !partitioning.value?.defaultPartition);
const partitionDialogNeedsBound = computed(() => partitionDialogMode.value === "create" || partitionDialogMode.value === "attach");
function resetPartitionDialog() {
partitionDialogName.value = "";
partitionDialogSchema.value = "";
partitionDialogParentSchema.value = "";
partitionDialogParentTable.value = "";
partitionDialogBoundKind.value = isCreateMode.value ? createPartitioningKind.value : "range";
partitionDialogRangeFrom.value = "";
partitionDialogRangeTo.value = "";
partitionDialogListValues.value = "";
partitionDialogModulus.value = "2";
partitionDialogRemainder.value = "0";
partitionDialogConcurrently.value = false;
partitionDialogError.value = "";
}
function openPartitionDialog(mode: TablePartitionOperationKind, boundKind?: PgPartitionKind) {
resetPartitionDialog();
partitionDialogMode.value = mode;
partitionDialogBoundKind.value = boundKind ?? (isCreateMode.value ? createPartitioningKind.value : partitioning.value?.strategy) ?? "range";
partitionDialogOpen.value = true;
}
function openPartitionRowOperation(mode: TablePartitionOperationKind, row: PgPartitionTreeRow) {
openPartitionDialog(mode);
partitionDialogName.value = row.node.name;
partitionDialogSchema.value = row.node.schema;
partitionDialogParentSchema.value = row.parentSchema ?? "";
partitionDialogParentTable.value = row.parentName ?? "";
}
/** Detaching the partition currently being edited: the parent is not the
* edited table, so it must be named explicitly. */
function openDetachSelfPartitionDialog() {
openPartitionDialog("detach");
partitionDialogName.value = props.tableName || "";
partitionDialogSchema.value = metadataSchema.value;
partitionDialogParentSchema.value = partitioning.value?.parentSchema ?? "";
partitionDialogParentTable.value = partitioning.value?.parentTable ?? "";
}
/** `reportError` separates the confirm path (which surfaces validation
* messages) from the live SQL preview (which stays quiet while the user is
* still typing). */
function partitionDialogBound(reportError: boolean): TablePartitionBoundDraft | undefined {
const kind = partitionDialogBoundKind.value;
if (kind === "default") return { kind: "default" };
if (kind === "range") {
const from = splitPgPartitionBoundValues(partitionDialogRangeFrom.value);
const to = splitPgPartitionBoundValues(partitionDialogRangeTo.value);
if (!from.length || from.length !== to.length) {
if (reportError) partitionDialogError.value = t("structureEditor.partitionRangeBoundInvalid");
return undefined;
}
return { kind: "range", from, to };
}
if (kind === "list") {
const values = splitPgPartitionBoundValues(partitionDialogListValues.value);
if (!values.length) {
if (reportError) partitionDialogError.value = t("structureEditor.partitionListBoundInvalid");
return undefined;
}
return { kind: "list", values };
}
const modulus = Number.parseInt(partitionDialogModulus.value, 10);
const remainder = Number.parseInt(partitionDialogRemainder.value, 10);
if (!Number.isInteger(modulus) || modulus <= 0 || !Number.isInteger(remainder) || remainder < 0 || remainder >= modulus) {
if (reportError) partitionDialogError.value = t("structureEditor.partitionHashBoundInvalid");
return undefined;
}
return { kind: "hash", modulus, remainder };
}
function partitionDialogOperation(reportError: boolean, id: string): TablePartitionOperation | undefined {
const mode = partitionDialogMode.value;
const name = partitionDialogName.value.trim();
if (!name) {
if (reportError) partitionDialogError.value = t("structureEditor.partitionNameRequired");
return undefined;
}
let bound: TablePartitionBoundDraft | undefined;
if (partitionDialogNeedsBound.value) {
bound = partitionDialogBound(reportError);
if (!bound) return undefined;
}
return {
id,
kind: mode,
parentSchema: partitionDialogParentSchema.value.trim(),
parentTable: partitionDialogParentTable.value.trim(),
schema: partitionDialogSchema.value.trim(),
name,
bound,
concurrently: mode === "detach" && partitionDialogConcurrently.value && partitionSupportsConcurrentDetach.value,
};
}
function confirmPartitionDialog() {
partitionDialogError.value = "";
const operation = partitionDialogOperation(true, `partition-op:${uuid()}`);
if (!operation) return;
partitionOperations.value = [...partitionOperations.value, operation];
partitionDialogOpen.value = false;
scheduleSqlPreviewRefresh();
syncDraftToParent();
}
const partitionDialogSql = ref("");
const partitionDialogSqlWarnings = ref<string[]>([]);
let partitionDialogSqlTimer: ReturnType<typeof setTimeout> | undefined;
let partitionDialogSqlRequestId = 0;
/** Rebuilds the statement the dialog would queue, using the same backend
* builder the save path uses, so the preview can never drift from execution. */
async function refreshPartitionDialogSql() {
if (!partitionDialogOpen.value) {
partitionDialogSql.value = "";
partitionDialogSqlWarnings.value = [];
return;
}
const operation = partitionDialogOperation(false, "partition-preview");
if (!operation) {
partitionDialogSql.value = "";
partitionDialogSqlWarnings.value = [];
return;
}
const requestId = ++partitionDialogSqlRequestId;
try {
const result = await api.buildTablePartitionOperationSql({ ...partitionSqlOptions(), operations: [operation] });
if (requestId !== partitionDialogSqlRequestId) return;
partitionDialogSql.value = result.statements.join("\n");
partitionDialogSqlWarnings.value = result.warnings;
} catch (error: any) {
if (requestId !== partitionDialogSqlRequestId) return;
partitionDialogSql.value = "";
partitionDialogSqlWarnings.value = [error?.message || String(error)];
}
}
function schedulePartitionDialogSqlRefresh() {
if (partitionDialogSqlTimer) clearTimeout(partitionDialogSqlTimer);
partitionDialogSqlTimer = setTimeout(() => {
partitionDialogSqlTimer = undefined;
void refreshPartitionDialogSql();
}, 150);
}
function removePartitionOperation(id: string) {
partitionOperations.value = partitionOperations.value.filter((operation) => operation.id !== id);
scheduleSqlPreviewRefresh();
syncDraftToParent();
}
watch(
[
partitionDialogOpen,
partitionDialogMode,
partitionDialogName,
partitionDialogSchema,
partitionDialogParentSchema,
partitionDialogParentTable,
partitionDialogBoundKind,
partitionDialogRangeFrom,
partitionDialogRangeTo,
partitionDialogListValues,
partitionDialogModulus,
partitionDialogRemainder,
partitionDialogConcurrently,
],
() => schedulePartitionDialogSqlRefresh(),
{ flush: "post" },
);
function partitionOperationLabel(kind: TablePartitionOperationKind): string {
switch (kind) {
case "create":
return t("structureEditor.partitionAdd");
case "attach":
return t("structureEditor.partitionAttach");
case "detach":
return t("structureEditor.partitionDetach");
case "drop":
return t("structureEditor.partitionDrop");
}
}
function partitionOperationSummary(operation: TablePartitionOperation): string {
return `${partitionOperationLabel(operation.kind)}: ${operation.name}`;
}
/** Size/count estimates live in the row's hover hint so the row stays two lines. */
function partitionRowHint(node: PgPartitionNode): string | undefined {
return pgPartitionRowHint(node, t, formatBytes);
}
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/ObjectBrowser.
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
@@ -482,7 +803,7 @@ watch(
function selectTrigger(trigger: EditableStructureTrigger) {
selectedTriggerId.value = trigger.id;
}
const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || constraintsLoading.value || triggersLoading.value);
const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || constraintsLoading.value || triggersLoading.value || partitionsLoading.value);
function sameList(left: string[] | null | undefined, right: string[] | null | undefined): boolean {
const a = left ?? [];
@@ -556,6 +877,9 @@ function captureStructureRefreshScope(): TableStructureRefreshScope {
// so refresh the tab if it was ever loaded rather than leaving it stale.
constraints: constraintsLoaded.value,
triggers: triggers.value.some(triggerChanged),
// Read-only tab with no editable draft; refresh it whenever it was loaded
// so a save that rebuilds partitions can't leave stale rows behind.
partitions: loadedMetadataFacets.has("partitions"),
tableComment: tableComment.value !== originalTableComment.value,
};
}
@@ -1167,6 +1491,10 @@ const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
const isCreateMode = computed(() => !props.tableName);
// Hidden for an existing table that is not partitioned: the tab could only ever
// render an empty state. Create mode keeps it so partitioning can be declared.
const showPartitionsTab = computed(() => tableMetadataCapabilities.value.partitions && (isCreateMode.value || isPartitionedParent.value || isTablePartition.value));
/**
* Whether the edited table already stores a lowercase or mixed-case identifier.
* Oracle folds bare identifiers to uppercase, so dequoting such a name points at
@@ -1315,6 +1643,7 @@ function structureScrollerForTab(tab: TableInfoTab): HTMLElement | undefined {
if (tab === "foreignKeys") return structureScrollerElement(foreignKeysScrollerRef.value);
if (tab === "constraints") return structureScrollerElement(constraintsScrollerRef.value);
if (tab === "triggers") return structureScrollerElement(triggersScrollerRef.value);
if (tab === "partitions") return structureScrollerElement(partitionsScrollerRef.value);
if (tab === "ddl") return structureScrollerElement(ddlScrollerRef.value);
return undefined;
}
@@ -1483,6 +1812,11 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft {
constraintsLoaded: constraintsLoaded.value,
triggers: cloneDraftValue(triggers.value),
triggersLoaded: triggersLoaded.value,
partitionOperations: cloneDraftValue(partitionOperations.value),
createPartitioningEnabled: createPartitioningEnabled.value,
createPartitioningKind: createPartitioningKind.value,
createPartitioningColumns: cloneDraftValue(createPartitioningColumns.value),
createPartitioningExpression: createPartitioningExpression.value,
loadedMetadataFacets: [...loadedMetadataFacets],
scrollPositions: cloneDraftValue(structureScrollPositions.value),
appliedInitialTabRequestId: lastAppliedInitialTabRequestId,
@@ -1537,6 +1871,11 @@ function restoreDraft(draft: TableStructureEditorDraft) {
triggers.value = cloneDraftValue(draft.triggers || []);
// Drafts created before lazy trigger loading always contained live trigger metadata.
triggersLoaded.value = draft.triggersLoaded ?? true;
partitionOperations.value = cloneDraftValue(draft.partitionOperations || []);
createPartitioningEnabled.value = draft.createPartitioningEnabled ?? false;
createPartitioningKind.value = draft.createPartitioningKind ?? "range";
createPartitioningColumns.value = cloneDraftValue(draft.createPartitioningColumns || []);
createPartitioningExpression.value = draft.createPartitioningExpression ?? "";
loadedMetadataFacets.clear();
if (draft.loadedMetadataFacets) {
for (const facet of draft.loadedMetadataFacets) loadedMetadataFacets.add(facet);
@@ -1603,7 +1942,17 @@ function markDraftHydratedAndSync() {
function hasPendingStructureChanges(): boolean {
if (isCreateMode.value) {
return !!newTableName.value.trim() || !!tableComment.value.trim() || mysqlTableEngine.value !== originalMysqlTableEngine.value || columns.value.length > 0 || indexes.value.length > 0 || foreignKeys.value.length > 0 || triggers.value.length > 0;
return (
!!newTableName.value.trim() ||
!!tableComment.value.trim() ||
mysqlTableEngine.value !== originalMysqlTableEngine.value ||
columns.value.length > 0 ||
indexes.value.length > 0 ||
foreignKeys.value.length > 0 ||
triggers.value.length > 0 ||
createPartitioningEnabled.value ||
partitionOperations.value.length > 0
);
}
const scope = captureStructureRefreshScope();
return (
@@ -1612,6 +1961,7 @@ function hasPendingStructureChanges(): boolean {
scope.foreignKeys ||
scope.triggers ||
scope.tableComment ||
partitionOperations.value.length > 0 ||
mysqlTableEngine.value.toLowerCase() !== originalMysqlTableEngine.value.toLowerCase() ||
(canBuildMysqlAutoIncrement.value && mysqlAutoIncrementValue.value !== originalMysqlAutoIncrementValue.value) ||
(supportsTableOwner.value && tableOwner.value.trim() !== originalTableOwner.value.trim())
@@ -1772,6 +2122,20 @@ function structureChangeOptions(): BuildTableStructureChangeSqlOptions {
};
}
function partitionSqlOptions(): TablePartitionSqlOptions {
return {
databaseType: databaseType.value,
driverProfile: connection.value?.driver_profile,
// `metadataSchema` normalizes the fallback (database as schema, empty → public
// on the backend), so DDL stays schema-qualified like every other statement.
schema: metadataSchema.value,
// Create mode has no table yet: the pending partition operations target the
// new table's name.
tableName: isCreateMode.value ? newTableName.value.trim() : props.tableName || "",
operations: partitionOperations.value,
};
}
async function refreshSqlPreview() {
const requestId = ++sqlPreviewRequestId;
if (concurrentAvailabilityInvalidated.value) {
@@ -1819,9 +2183,16 @@ async function refreshSqlPreview() {
}
sqlPreviewLoading.value = true;
const options = structureChangeOptions();
const partitionResultPromise = partitionOperations.value.length > 0 ? api.buildTablePartitionOperationSql(partitionSqlOptions()) : Promise.resolve({ statements: [], warnings: [] });
try {
const [result, ownerResult, mysqlAutoIncrementStatement] = await Promise.all([
isCreateMode.value ? api.buildCreateTableSql(options) : hasSqliteTypeChange.value ? api.previewSqliteTableStructureChange(props.connectionId, props.database, options) : api.buildTableStructureChangeSql(options),
const [result, ownerResult, mysqlAutoIncrementStatement, partitionResult] = await Promise.all([
isCreateMode.value
? createPartitioningEnabled.value
? api.buildCreatePartitionedTableSql({ options, partitioning: { kind: createPartitioningKind.value, columns: createPartitioningColumns.value, expression: createPartitioningExpression.value } })
: api.buildCreateTableSql(options)
: hasSqliteTypeChange.value
? api.previewSqliteTableStructureChange(props.connectionId, props.database, options)
: api.buildTableStructureChangeSql(options),
supportsTableOwner.value
? api.buildTableOwnerChangeSql({
databaseType: databaseType.value,
@@ -1841,12 +2212,13 @@ async function refreshSqlPreview() {
tableName: props.tableName || "",
buildSql: api.buildMysqlAutoIncrementSql,
}),
partitionResultPromise,
]);
if (requestId !== sqlPreviewRequestId) return;
const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : [])];
const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : []), ...partitionResult.statements];
// SQLite type-change apply regenerates this revision-checked plan, so its preview must stay byte-for-byte aligned.
pendingStatements.value = hasSqliteTypeChange.value ? statements : statements.map((statement) => formatDdlForDisplay(statement, sqlFormatDialectForDbType(databaseType.value), true));
warnings.value = [...result.warnings, ...ownerResult.warnings];
warnings.value = [...result.warnings, ...ownerResult.warnings, ...partitionResult.warnings];
sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined;
} catch (e: any) {
if (requestId !== sqlPreviewRequestId) return;
@@ -1893,9 +2265,13 @@ function resetState() {
foreignKeysLoading.value = false;
constraintsLoading.value = false;
triggersLoading.value = false;
partitionsLoading.value = false;
partitionsError.value = "";
errorMessage.value = "";
secondaryMetadataErrors.value = {};
isPartitionedParent.value = false;
isTablePartition.value = false;
partitionStatusResolved.value = false;
partitionStatusKnown.value = true;
concurrentAvailabilityInvalidated.value = false;
columns.value = [];
@@ -1906,8 +2282,14 @@ function resetState() {
foreignKeys.value = [];
constraints.value = [];
constraintsLoaded.value = false;
partitioning.value = null;
triggers.value = [];
triggersLoaded.value = false;
partitionOperations.value = [];
createPartitioningEnabled.value = false;
createPartitioningKind.value = "range";
createPartitioningColumns.value = [];
createPartitioningExpression.value = "";
clearColumnSelection();
rawDdlContent.value = "";
ddlDraft.value = null;
@@ -1957,6 +2339,12 @@ async function reloadStructureFromDatabase() {
constraints.value = [];
constraintsLoaded.value = false;
}
if (activeTab.value !== "partitions") {
partitioning.value = null;
}
// Refreshing from the database discards every other draft; pending partition
// operations must not survive it and still be applied on save.
partitionOperations.value = [];
const refreshDdl = activeTab.value === "ddl";
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
invalidateTableMetadataCache(metadataMatch);
@@ -1979,6 +2367,7 @@ function setSecondaryMetadataLoading(scope: TableStructureRefreshScope, value: b
if (scope.foreignKeys && tableMetadataCapabilities.value.foreignKeys) foreignKeysLoading.value = value;
if (scope.constraints && tableMetadataCapabilities.value.constraints) constraintsLoading.value = value;
if (scope.triggers && tableMetadataCapabilities.value.triggers) triggersLoading.value = value;
if (scope.partitions && tableMetadataCapabilities.value.partitions) partitionsLoading.value = value;
}
function withRequiredPostgresPrimaryKeyMetadata(scope: TableStructureRefreshScope): TableStructureRefreshScope {
@@ -2174,6 +2563,7 @@ async function loadStructure(
setSecondaryMetadataLoading(effectiveScope, true);
errorMessage.value = "";
secondaryMetadataErrors.value = {};
partitionsError.value = "";
let secondaryMetadataScheduled = false;
let loadedSuccessfully = false;
let columnsServedFromCache = false;
@@ -2185,7 +2575,7 @@ async function loadStructure(
const metadataRequest = ddlRequest();
const forceMetadata = options.forceMetadata === true;
const partitionStatusPromise =
databaseType.value === "postgres" && !isCreateMode.value
tableMetadataCapabilities.value.partitions && !isCreateMode.value
? api
.getTablePartitionStatus(connectionId, database, schema, tableName)
// No reactive mutation inside the catch: a stale request must not
@@ -2223,6 +2613,11 @@ async function loadStructure(
? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value)
: Promise.resolve([])
: Promise.resolve(undefined);
// Partition metadata is a live catalog read (not persisted), so it is
// fetched directly rather than through the object metadata cache.
// Create mode has no catalog entry to read; the Partitions tab edits a
// local `PARTITION BY` declaration instead.
const partitioningPromise = effectiveScope.partitions && !isCreateMode.value ? (tableMetadataCapabilities.value.partitions ? api.getTablePartitioning(connectionId, database, schema, tableName) : Promise.resolve(undefined)) : Promise.resolve(undefined);
const tableCommentLoad = effectiveScope.tableComment && structureCapabilities.value.comment ? loadCachedTableComment(metadataRequest, forceMetadata) : undefined;
const tableCommentPromise = tableCommentLoad
? tableCommentLoad.then((result) => {
@@ -2269,6 +2664,8 @@ async function loadStructure(
if (requestId === structureLoadRequestId) {
partitionStatusKnown.value = partitionStatus.known;
isPartitionedParent.value = partitionStatus.status.isPartitionedParent;
isTablePartition.value = partitionStatus.status.isPartition;
partitionStatusResolved.value = true;
// Availability inputs changed: fail closed while the status is unknown,
// but preserve the user's Concurrent intent so a later successful probe
// can regenerate the same SQL. Definitive unsupported states still clear
@@ -2280,7 +2677,7 @@ async function loadStructure(
}
}
const applySecondaryMetadata = async () => {
const [indexesResult, foreignKeysResult, constraintsResult, triggersResult] = await Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
const [indexesResult, foreignKeysResult, constraintsResult, triggersResult, partitioningResult] = await Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise, partitioningPromise]);
if (requestId !== structureLoadRequestId) return;
type SecondaryMetadataResult = { facet: ObjectMetadataFacet; result: PromiseSettledResult<unknown> };
@@ -2322,6 +2719,17 @@ async function loadStructure(
triggersLoaded.value = true;
loadedMetadataFacets.add("triggers");
}
if (partitioningResult.status === "fulfilled" && partitioningResult.value !== undefined) {
partitioning.value = partitioningResult.value;
loadedMetadataFacets.add("partitions");
} else if (partitioningResult.status === "rejected") {
console.warn("[DBX][structure-editor:partitions-metadata-failed]", partitioningResult.reason);
if (showErrors) partitionsError.value = partitioningResult.reason?.message || String(partitioningResult.reason);
// Mark the facet loaded even on failure: the tab-activation watcher
// only refetches unloaded facets, so leaving it unloaded would retry in
// a loop. The toolbar Refresh clears the facet and retries explicitly.
loadedMetadataFacets.add("partitions");
}
};
secondaryMetadataScheduled = true;
@@ -3803,11 +4211,18 @@ async function applyChanges() {
// A hand-written DDL script can change anything about the table, and the
// structure draft it was applied from is clean, so the change-derived scope
// would be empty: reload every facet instead of leaving the tabs stale.
const refreshScope = ddlDirty.value ? { columns: true, indexes: true, foreignKeys: true, constraints: true, triggers: true, tableComment: true } : captureStructureRefreshScope();
const refreshScope = ddlDirty.value ? { columns: true, indexes: true, foreignKeys: true, constraints: true, triggers: true, partitions: true, tableComment: true } : captureStructureRefreshScope();
// Plan A guard: concurrent builds only run with a long-enough query timeout
// (a cancelled build leaves an INVALID index behind), and are blocked
// up-front when a same-name INVALID index already exists.
const hasConcurrentIndexBuild = pendingStatements.value.some((statement) => statement.includes("CONCURRENTLY"));
// Partition DDL (create parent + initial partitions, attach/detach/drop
// chains, ...) must land atomically: a mid-batch failure would otherwise
// leave a half-created hierarchy behind. CONCURRENTLY statements cannot run
// inside a transaction block, so their presence keeps the batch on the
// auto-commit path (the core also refuses that combination).
const partitionDdlPending = partitionOperations.value.length > 0 || (isCreateMode.value && createPartitioningEnabled.value);
const useTransaction = !hasConcurrentIndexBuild && partitionDdlPending;
if (hasConcurrentIndexBuild && !isCreateMode.value && databaseType.value === "postgres" && props.tableName) {
const concurrentIndexNames = concurrentIndexNamesInStatements(pendingStatements.value);
if (concurrentIndexNames.length > 0) {
@@ -3845,7 +4260,7 @@ async function applyChanges() {
const result =
hasSqliteTypeChange.value && !ddlDirty.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs, useTransaction);
await recordStructureHistory(sql, startedAt, true, result);
if (!isCreateMode.value && props.tableName) {
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
@@ -3860,6 +4275,9 @@ async function applyChanges() {
pendingStatements.value = [];
warnings.value = [];
sqliteSchemaRevision.value = undefined;
// Partition operations are applied in place; drop them now so the reload
// below reflects the new catalog state instead of replaying them.
partitionOperations.value = [];
ddlFetched.value = false;
rawDdlContent.value = "";
ddlDraft.value = null;
@@ -4028,7 +4446,7 @@ onMounted(() => {
} else if (isCreateMode.value) {
markDraftHydratedAndSync();
} else if (activeTab.value === "ddl") {
void Promise.all([fetchDdl(), loadVisibleTableComment()]).then(markDraftHydratedAndSync);
void Promise.all([fetchDdl(), loadVisibleTableComment(), probePartitionsTabVisibility()]).then(markDraftHydratedAndSync);
} else {
void loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget());
}
@@ -4065,6 +4483,7 @@ onDeactivated(() => {
});
onBeforeUnmount(() => {
clearCopySourceTableSearchTimer();
if (partitionDialogSqlTimer) clearTimeout(partitionDialogSqlTimer);
stopColumnDragTracking();
stopStructureHorizontalScrollbarDrag();
structureHorizontalScrollbarObserverGeneration += 1;
@@ -4151,6 +4570,14 @@ watch(tableMetadataCapabilities, (capabilities) => {
if (!localIsStructureMetadataTabSupported(activeTab.value, capabilities)) activeTab.value = localFirstStructureMetadataTab(capabilities);
});
// The Partitions tab vanishes for a non-partitioned table, so an active (or
// navigation-requested) partitions tab must fall back once the probe settles.
watch([activeTab, partitionStatusResolved, isPartitionedParent, isTablePartition], () => {
if (activeTab.value !== "partitions" || isCreateMode.value || !partitionStatusResolved.value) return;
if (isPartitionedParent.value || isTablePartition.value) return;
activeTab.value = localFirstStructureMetadataTab();
});
watch(structureCapabilities, () => {
// Capability loss (e.g. PostgreSQL < 11 without concurrent index support)
// invalidates any selected Concurrent flag the same way a probe failure
@@ -4198,6 +4625,11 @@ watch(
indexes,
foreignKeys,
triggers,
partitionOperations,
createPartitioningEnabled,
createPartitioningKind,
createPartitioningColumns,
createPartitioningExpression,
],
() => {
scheduleSqlPreviewRefresh();
@@ -4261,13 +4693,17 @@ watch(refreshVersion, (version, previous) => {
constraints.value = [];
constraintsLoaded.value = false;
}
if (activeTab.value !== "partitions") {
partitioning.value = null;
loadedMetadataFacets.delete("partitions");
}
void loadStructure(true, visibleTableStructureRefreshScope(activeTab.value));
});
async function loadActiveTableStructureMetadataIfNeeded() {
if (!structureEditorReady || isCreateMode.value) return;
if (activeTab.value === "ddl") {
await Promise.all([ddlLoading.value ? Promise.resolve() : fetchDdl(), loadVisibleTableComment(false, true)]);
await Promise.all([ddlLoading.value ? Promise.resolve() : fetchDdl(), loadVisibleTableComment(false, true), probePartitionsTabVisibility()]);
return;
}
if (loading.value || secondaryMetadataLoading.value) return;
@@ -4412,6 +4848,7 @@ watch(
<TabsTrigger v-if="tableMetadataCapabilities.foreignKeys" value="foreignKeys">{{ t("structureEditor.foreignKeys") }}</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.constraints" value="constraints">{{ t("structureEditor.constraints") }}</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.triggers" value="triggers">{{ t("structureEditor.triggers") }}</TabsTrigger>
<TabsTrigger v-if="showPartitionsTab" value="partitions">{{ t("structureEditor.partitions") }}</TabsTrigger>
</TabsList>
<div class="flex shrink-0 items-center gap-1.5">
<Button v-if="!isCreateMode" size="sm" variant="outline" :class="structureToolbarButtonClass" data-structure-view-data @click="emit('viewData')">
@@ -5290,6 +5727,149 @@ watch(
</div>
</TabsContent>
<TabsContent ref="partitionsScrollerRef" v-if="showPartitionsTab" value="partitions" class="col-start-1 row-start-2 structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]" @scroll.passive="onStructureContentScroll('partitions', $event)">
<div v-if="isCreateMode" class="space-y-3">
<label class="flex items-center gap-2 text-[length:var(--structure-font-size)]">
<input v-model="createPartitioningEnabled" type="checkbox" />
{{ t("structureEditor.partitionEnable") }}
</label>
<template v-if="createPartitioningEnabled">
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionKind") }}</label>
<Select v-model="createPartitioningKind">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="range">{{ t("structureEditor.partitionKindRange") }}</SelectItem>
<SelectItem value="list">{{ t("structureEditor.partitionKindList") }}</SelectItem>
<SelectItem value="hash">{{ t("structureEditor.partitionKindHash") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionKeyColumns") }}</label>
<div class="flex flex-wrap gap-3">
<label v-for="column in partitionCreatableColumns" :key="column" class="flex items-center gap-1 text-sm">
<input v-model="createPartitioningColumns" type="checkbox" :value="column" :disabled="!!createPartitioningExpression.trim()" />
<span class="font-mono">{{ column }}</span>
</label>
</div>
<p v-if="partitionCreatableColumns.length === 0" class="text-sm text-muted-foreground">{{ t("structureEditor.partitionKeyNoColumns") }}</p>
</div>
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionKeyExpression") }}</label>
<Input v-model="createPartitioningExpression" class="font-mono" placeholder="date_trunc('month', ts)" />
</div>
<div class="flex items-center justify-between">
<span class="text-[length:var(--structure-font-size)] font-medium">{{ t("structureEditor.partitionInitialPartitions") }}</span>
<Button variant="outline" size="sm" :class="structureToolbarButtonClass" @click="openPartitionDialog('create', createPartitioningKind)">
<Plus :class="[structureIconClass, 'mr-1']" />
{{ t("structureEditor.partitionAdd") }}
</Button>
</div>
<div v-if="partitionOperations.length" class="rounded-md border border-primary/40 bg-primary/5 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
<div v-for="operation in partitionOperations" :key="operation.id" class="flex items-center justify-between gap-2 py-0.5">
<span class="truncate font-mono">{{ partitionOperationSummary(operation) }}</span>
<Button variant="ghost" size="sm" :class="structureIconButtonClass" :title="t('structureEditor.partitionRemoveOperation')" @click="removePartitionOperation(operation.id)">
<Trash2 :class="structureIconClass" />
</Button>
</div>
</div>
<p v-else class="text-sm text-muted-foreground">{{ t("structureEditor.partitionInitialPartitionsEmpty") }}</p>
</template>
</div>
<template v-else>
<div v-if="partitionsLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="partitionsError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ partitionsError }}
</div>
<div v-else-if="!partitioning || (!partitioning.isPartitioned && !partitioning.isPartition)" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.partitionsEmpty") }}
</div>
<div v-else class="space-y-2">
<div class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<Badge v-if="partitioning.isPartitioned" variant="outline">{{ partitionStrategyLabel(partitioning.strategy) }}</Badge>
<Badge v-else variant="outline">{{ t("structureEditor.partitionMemberBadge") }}</Badge>
<span v-if="partitioning.keyDefinition" class="truncate font-mono">{{ partitioning.keyDefinition }}</span>
</div>
<div v-if="canManagePartitions" class="flex shrink-0 items-center gap-1.5">
<Button v-if="partitioning.isPartitioned" variant="outline" size="sm" :class="structureToolbarButtonClass" @click="openPartitionDialog('create')">
<Plus :class="[structureIconClass, 'mr-1']" />
{{ t("structureEditor.partitionAdd") }}
</Button>
<Button v-if="partitioning.isPartitioned" variant="outline" size="sm" :class="structureToolbarButtonClass" @click="openPartitionDialog('attach')">
<ListChevronsUpDown :class="[structureIconClass, 'mr-1']" />
{{ t("structureEditor.partitionAttach") }}
</Button>
<Button v-if="partitioning.isPartition" variant="outline" size="sm" :class="structureToolbarButtonClass" @click="openDetachSelfPartitionDialog">
<X :class="[structureIconClass, 'mr-1']" />
{{ t("structureEditor.partitionDetachSelf") }}
</Button>
</div>
</div>
<div v-if="partitioning.parent" class="mt-1 truncate font-mono text-muted-foreground">{{ t("structureEditor.partitionsParent") }}: {{ partitioning.parent }}</div>
<div v-if="partitioning.ownBound" class="mt-1 truncate font-mono text-muted-foreground">{{ t("structureEditor.partitionsOwnBound") }}: {{ pgPartitionBoundText(partitioning.ownBound) }}</div>
<div v-if="partitioning.defaultPartition" class="mt-1 truncate font-mono text-muted-foreground">{{ t("structureEditor.partitionsDefault") }}: {{ partitioning.defaultPartition }}</div>
</div>
<div v-if="partitionOperations.length" class="rounded-md border border-primary/40 bg-primary/5 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
<div class="mb-1 font-medium">{{ t("structureEditor.partitionPendingOperations") }}</div>
<div v-for="operation in partitionOperations" :key="operation.id" class="flex items-center justify-between gap-2 py-0.5">
<span class="truncate font-mono">{{ partitionOperationSummary(operation) }}</span>
<Button variant="ghost" size="sm" :class="structureIconButtonClass" :title="t('structureEditor.partitionRemoveOperation')" @click="removePartitionOperation(operation.id)">
<Trash2 :class="structureIconClass" />
</Button>
</div>
</div>
<div v-if="partitionTreeRows.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.partitionsEmptyChildren") }}
</div>
<div v-if="partitionTreeRows.length > 0" class="divide-y overflow-hidden rounded-md border">
<div v-for="row in partitionVisibleRows" :key="row.key" :title="partitionRowHint(row.node)" class="flex items-start gap-1 px-[var(--structure-cell-px)] py-1.5 text-[length:var(--structure-font-size)]">
<span :data-partition-depth="row.depth" :style="{ width: `${row.depth * PARTITION_TREE_INDENT_PX}px` }" class="shrink-0 self-stretch" aria-hidden="true"></span>
<button
v-if="row.node.children.length"
type="button"
class="mt-px h-4 w-4 shrink-0 self-start text-muted-foreground transition-colors hover:text-foreground"
:aria-label="collapsedPartitionKeys.has(row.key) ? t('structureEditor.partitionExpand') : t('structureEditor.partitionCollapse')"
:title="collapsedPartitionKeys.has(row.key) ? t('structureEditor.partitionExpand') : t('structureEditor.partitionCollapse')"
@click="togglePartitionRow(row.key)"
>
<ChevronRight v-if="collapsedPartitionKeys.has(row.key)" :class="structureIconClass" />
<ChevronDown v-else :class="structureIconClass" />
</button>
<span v-else class="mt-px h-4 w-4 shrink-0 self-start" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono font-medium">{{ row.node.name }}</span>
<Badge v-if="row.node.children.length" variant="outline" class="text-muted-foreground">{{ t("structureEditor.partitionChildCount", { count: row.node.children.length }) }}</Badge>
<Badge v-if="row.depth > 0" variant="outline" class="border-dashed text-muted-foreground">{{ t("structureEditor.partitionSubPartitionBadge") }}</Badge>
<Badge v-if="row.node.strategy" variant="outline">{{ partitionStrategyLabel(row.node.strategy) }}</Badge>
<Badge v-if="row.node.bound?.kind === 'default'" variant="outline" class="text-muted-foreground">{{ t("structureEditor.partitionBoundDefault") }}</Badge>
</div>
<div v-if="canManagePartitions && partitioning.isPartitioned" class="flex shrink-0 items-center gap-0.5">
<Button variant="ghost" size="sm" :class="structureIconButtonClass" :title="t('structureEditor.partitionDetach')" @click="openPartitionRowOperation('detach', row)">
<X :class="structureIconClass" />
</Button>
<Button variant="ghost" size="sm" :class="structureIconButtonClass" :title="t('structureEditor.partitionDrop')" @click="openPartitionRowOperation('drop', row)">
<Trash2 :class="structureIconClass" />
</Button>
</div>
</div>
<div v-if="pgPartitionNodeBoundText(row.node)" class="mt-0.5 truncate font-mono text-muted-foreground">{{ pgPartitionNodeBoundText(row.node) }}</div>
</div>
</div>
</div>
</div>
</template>
</TabsContent>
<TabsContent
ref="ddlScrollerRef"
v-if="tableMetadataCapabilities.ddl"
@@ -5495,6 +6075,76 @@ watch(
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="partitionDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ partitionOperationLabel(partitionDialogMode) }}</DialogTitle>
</DialogHeader>
<div class="space-y-3">
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionName") }}</label>
<Input v-model="partitionDialogName" class="font-mono" :disabled="partitionDialogMode === 'detach' || partitionDialogMode === 'drop'" :placeholder="t('structureEditor.partitionNamePlaceholder')" />
</div>
<template v-if="partitionDialogNeedsBound">
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundKind") }}</label>
<Select v-model="partitionDialogBoundKind">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="range">{{ t("structureEditor.partitionKindRange") }}</SelectItem>
<SelectItem value="list">{{ t("structureEditor.partitionKindList") }}</SelectItem>
<SelectItem value="hash">{{ t("structureEditor.partitionKindHash") }}</SelectItem>
<SelectItem value="default">{{ t("structureEditor.partitionBoundDefault") }}</SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="partitionDialogBoundKind === 'range'">
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundFrom") }}</label>
<Input v-model="partitionDialogRangeFrom" class="font-mono" placeholder="MINVALUE / '2025-01-01'" />
</div>
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundTo") }}</label>
<Input v-model="partitionDialogRangeTo" class="font-mono" placeholder="'2026-01-01' / MAXVALUE" />
</div>
</template>
<div v-else-if="partitionDialogBoundKind === 'list'" class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundValues") }}</label>
<Input v-model="partitionDialogListValues" class="font-mono" placeholder="1, 2, 3" />
</div>
<div v-else-if="partitionDialogBoundKind === 'hash'" class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundModulus") }}</label>
<Input v-model="partitionDialogModulus" class="font-mono" inputmode="numeric" />
</div>
<div class="space-y-1">
<label class="text-sm">{{ t("structureEditor.partitionBoundRemainder") }}</label>
<Input v-model="partitionDialogRemainder" class="font-mono" inputmode="numeric" />
</div>
</div>
</template>
<label v-else-if="partitionDialogMode === 'detach' && partitionSupportsConcurrentDetach" class="flex items-center gap-2 text-sm">
<input v-model="partitionDialogConcurrently" type="checkbox" />
{{ t("structureEditor.partitionDetachConcurrently") }}
</label>
<p v-if="partitionDialogMode === 'detach'" class="text-sm text-muted-foreground">{{ t("structureEditor.partitionDetachWarning") }}</p>
<p v-if="partitionDialogMode === 'drop'" class="text-sm text-destructive">{{ t("structureEditor.partitionDropWarning") }}</p>
<div v-if="partitionDialogSql || partitionDialogSqlWarnings.length" class="space-y-1">
<span class="text-sm text-muted-foreground">{{ t("structureEditor.partitionSqlPreview") }}</span>
<pre v-if="partitionDialogSql" class="max-h-40 overflow-auto rounded-md border bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">{{ partitionDialogSql }}</pre>
<p v-for="warning in partitionDialogSqlWarnings" :key="warning" class="text-xs text-destructive">{{ warning }}</p>
</div>
<p v-if="partitionDialogError" class="text-sm text-destructive">{{ partitionDialogError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="partitionDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :variant="partitionDialogMode === 'drop' ? 'destructive' : 'default'" @click="confirmPartitionDialog">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
}));
import TablePartitionsPanel from "@/components/structure/TablePartitionsPanel.vue";
import type { PgTablePartitioning } from "@/types/database";
const mountedApps: App[] = [];
async function mount(props: Record<string, unknown>) {
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TablePartitionsPanel, props);
mountedApps.push(app);
app.mount(root);
for (let i = 0; i < 10; i += 1) {
await nextTick();
await Promise.resolve();
}
return root;
}
const partitioned: PgTablePartitioning = {
isPartitioned: true,
isPartition: false,
strategy: "range",
keyDefinition: "RANGE (sold_on)",
keyColumns: ["sold_on"],
defaultPartition: "sales_default",
partitions: [
{
schema: "public",
name: "sales_2024",
isLeaf: true,
bound: { kind: "range", from: ["'2024-01-01'"], to: ["'2025-01-01'"] },
rowEstimate: 366,
totalBytes: 1048576,
children: [],
},
{
schema: "public",
name: "sales_nested",
strategy: "list",
isLeaf: false,
bound: { kind: "range", from: ["'2025-01-01'"], to: ["'2026-01-01'"] },
children: [{ schema: "public", name: "sales_nested_cn", isLeaf: true, bound: { kind: "list", values: ["'cn'"] }, children: [] }],
},
],
};
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("TablePartitionsPanel", () => {
it("renders the strategy, key, default partition and flattened rows", async () => {
const root = await mount({ partitioning: partitioned, loading: false, error: "" });
const text = root.textContent ?? "";
expect(text).toContain("structureEditor.partitionKindRange");
expect(text).toContain("RANGE (sold_on)");
expect(text).toContain("structureEditor.partitionsDefault");
expect(text).toContain("sales_default");
expect(text).toContain("sales_2024");
expect(text).toContain("FROM ('2024-01-01') TO ('2025-01-01')");
// Nested child appears with its own bound, and the parent shows its strategy.
expect(text).toContain("sales_nested_cn");
expect(text).toContain("IN ('cn')");
expect(text).toContain("structureEditor.partitionKindList");
// The size/count estimates moved out of the row body into its hover hint.
expect(text).not.toContain("structureEditor.partitionsRowEstimate");
const hinted = Array.from(root.querySelectorAll("[title]")).find((element) => element.getAttribute("title")?.includes("structureEditor.partitionsRowEstimate"));
expect(hinted?.getAttribute("title")).toContain("structureEditor.partitionsSize");
});
it("indents sub-partitions and labels them, with no guide lines at all", async () => {
const root = await mount({ partitioning: partitioned, loading: false, error: "" });
const text = root.textContent ?? "";
expect(text).not.toContain("├─");
expect(text).not.toContain("└─");
expect(root.querySelectorAll("[data-partition-guide]")).toHaveLength(0);
// One indent spacer per row: 0 for top-level rows, 16px for a sub-partition.
const indents = Array.from(root.querySelectorAll<HTMLElement>("[data-partition-depth]"));
expect(indents.map((cell) => cell.dataset.partitionDepth)).toEqual(["0", "0", "1"]);
expect(indents.map((cell) => cell.style.width)).toEqual(["0px", "0px", "16px"]);
expect(text).toContain("structureEditor.partitionSubPartitionBadge");
});
it("folds and unfolds sub-partitions from the parent toggle", async () => {
const root = await mount({ partitioning: partitioned, loading: false, error: "" });
expect(root.textContent ?? "").toContain("sales_nested_cn");
expect(root.textContent ?? "").toContain("structureEditor.partitionChildCount");
const collapse = root.querySelector('button[title="structureEditor.partitionCollapse"]');
expect(collapse).not.toBeNull();
collapse!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
for (let i = 0; i < 5; i += 1) await nextTick();
expect(root.textContent ?? "").not.toContain("sales_nested_cn");
// The parent stays visible with its fold marker flipped to expand.
expect(root.textContent ?? "").toContain("sales_nested");
expect(root.querySelector('button[title="structureEditor.partitionExpand"]')).not.toBeNull();
});
it("ignores the fold while searching so a matching child is never hidden", async () => {
const root = await mount({ partitioning: partitioned, loading: false, error: "", searchQuery: "sales" });
expect(root.textContent ?? "").toContain("sales_nested_cn");
const collapse = root.querySelector('button[title="structureEditor.partitionCollapse"]');
expect(collapse).not.toBeNull();
collapse!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
for (let i = 0; i < 5; i += 1) await nextTick();
// The fold is recorded, but an active search keeps every match visible.
expect(root.querySelector('button[title="structureEditor.partitionExpand"]')).not.toBeNull();
expect(root.textContent ?? "").toContain("sales_nested_cn");
});
it("filters rows by the search query", async () => {
const root = await mount({ partitioning: partitioned, loading: false, error: "", searchQuery: "2024" });
const text = root.textContent ?? "";
expect(text).toContain("sales_2024");
expect(text).not.toContain("sales_nested_cn");
});
it("shows the empty state for a non-partitioned table", async () => {
const root = await mount({
partitioning: { isPartitioned: false, isPartition: false, keyColumns: [], partitions: [] },
loading: false,
error: "",
});
expect(root.textContent ?? "").toContain("structureEditor.partitionsEmpty");
});
it("shows the error instead of the empty state", async () => {
const root = await mount({ partitioning: null, loading: false, error: "permission denied" });
expect(root.textContent ?? "").toContain("permission denied");
expect(root.textContent ?? "").not.toContain("structureEditor.partitionsEmpty");
});
});
@@ -448,6 +448,8 @@ describe("TableStructureEditor DDL tab", () => {
buttonWithText(root, "structureEditor.apply").click();
await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(mocks.executeBatch.mock.calls[0][2]).toEqual(["ALTER TABLE `users` ADD COLUMN `nickname` varchar(64)", "ALTER TABLE `users` ADD INDEX `idx_email` (`email`)"]);
// No partition DDL in this batch: it keeps the auto-commit path.
expect(mocks.executeBatch.mock.calls[0][5]).toBe(false);
// The structure builder must not have contributed statements to that batch.
expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled();
});
@@ -0,0 +1,644 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const TABLE_DDL = "CREATE TABLE `users` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB";
const mocks = vi.hoisted(() => ({
connection: {
id: "structure-partitions-tab",
name: "PostgreSQL",
db_type: "postgres",
driver_label: "PostgreSQL",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
executeBatch: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
buildTablePartitionOperationSql: vi.fn(),
buildCreatePartitionedTableSql: vi.fn(),
buildMysqlAutoIncrementSql: vi.fn(),
buildTableOwnerChangeSql: vi.fn(),
getTablePartitionStatus: vi.fn(),
getTablePartitioning: vi.fn(),
getTableOwner: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") });
return {
AlertTriangle: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronLeft: Icon,
ChevronRight: Icon,
ChevronUp: Icon,
ClipboardList: Icon,
Copy: Icon,
Database: Icon,
Info: Icon,
Keyboard: Icon,
KeyRound: Icon,
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Pencil: Icon,
Plus: Icon,
RefreshCw: Icon,
RotateCcw: Icon,
Rows3: Icon,
Save: Icon,
Search: Icon,
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
name: "Button",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
name: "Input",
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
}),
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
name: "Badge",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("span", attrs, slots.default?.()),
}),
};
});
// Tabs mock that reproduces the one detail this regression depends on: reka-ui's
// TabsContent renders its slot through Presence, and `usePresence` awaits a
// `nextTick` before dispatching MOUNT. So the pane (and every template ref
// inside it) mounts one tick *after* the tab became active. A mock that renders
// panes unconditionally — like the other structure specs use — cannot see the
// bug at all.
vi.mock("@/components/ui/tabs", async () => {
const { computed, defineComponent, h, inject, nextTick, provide, ref, watch } = await import("vue");
const TabsSelectKey = Symbol("tabs:select");
const TabsActiveKey = Symbol("tabs:active");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Tabs = defineComponent({
name: "MockTabs",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup: (props, { attrs, slots, emit }) => {
provide(TabsSelectKey, (value: string) => emit("update:modelValue", value));
provide(
TabsActiveKey,
computed(() => props.modelValue),
);
return () => h("div", attrs, slots.default?.());
},
});
const TabsContent = defineComponent({
name: "MockTabsContent",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup: (props, { attrs, slots }) => {
const active = inject<{ value: string }>(TabsActiveKey, ref(""));
const selected = computed(() => active.value === props.value);
const present = ref(selected.value);
watch(selected, async (isSelected) => {
if (!isSelected) {
present.value = false;
return;
}
await nextTick();
present.value = true;
});
return () => h("div", attrs, present.value ? slots.default?.() : undefined);
},
});
const TabsTrigger = defineComponent({
name: "MockTabsTrigger",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup: (props, { attrs, slots }) => {
const select = inject<(value: string) => void>(TabsSelectKey, () => {});
return () => h("button", { ...attrs, type: "button", "data-tab-trigger": props.value, onClick: () => select(props.value) }, slots.default?.());
},
});
return { Tabs, TabsContent, TabsList: Div, TabsTrigger };
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Button = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
});
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div };
});
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div };
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div };
});
vi.mock("@/components/ui/searchable-select", async () => {
const { defineComponent, h } = await import("vue");
return {
SearchableSelect: defineComponent({
name: "SearchableSelect",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs }) =>
() =>
h("button", { ...attrs, type: "button", "data-model-value": props.modelValue }),
}),
};
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div };
});
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Dialog = defineComponent({
name: "MockDialog",
props: { open: { type: Boolean, default: false } },
emits: ["update:open"],
setup:
(props, { slots }) =>
() =>
props.open ? h("div", { "data-dialog": "open" }, slots.default?.()) : null,
});
return { Dialog, DialogContent: Div, DialogFooter: Div, DialogHeader: Div, DialogTitle: Div };
});
vi.mock("@/components/editor/EditorSearchPanel.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
name: "MockEditorSearchPanel",
setup: () => ({ openSearch: () => false, closeSearch: () => false }),
render: () => h("div", { "data-editor-search-panel": "true" }),
}),
};
});
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: mocks.ensureConnected,
getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined),
}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) }));
vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default", generateSqlQuoteIdentifiers: true },
updateEditorSettings: mocks.updateEditorSettings,
}),
}));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) }));
vi.mock("@/lib/sql/sqlFormatter", () => ({
formatSqlForDisplay: vi.fn(async (sql: string) => sql),
sqlFormatDialectForDbType: vi.fn(() => "mysql"),
}));
vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: vi.fn(async () => []), editorFontTheme: vi.fn(() => []) }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeBatch: mocks.executeBatch,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
getTablePartitioning: mocks.getTablePartitioning,
buildTablePartitionOperationSql: mocks.buildTablePartitionOperationSql,
buildCreatePartitionedTableSql: mocks.buildCreatePartitionedTableSql,
getTableOwner: mocks.getTableOwner,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
const mountedApps: App[] = [];
function structureDraft(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
dirty: false,
activeTab: "partitions",
newTableName: "",
tableComment: "",
originalTableComment: "",
columns: [],
indexes: [],
foreignKeys: [],
constraints: [],
triggers: [],
initialized: true,
...overrides,
};
}
async function mountStructureEditor(props: Record<string, unknown> = {}) {
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
tableName: "users",
...props,
});
mountedApps.push(app);
app.mount(root);
await vi.waitFor(
() => {
expect(root.querySelector('[data-tab-trigger="foreignKeys"]')).not.toBeNull();
expect(root.textContent).toContain("structureEditor.noChanges");
},
{ timeout: 3000 },
);
await settle();
return root;
}
/** Let every already-queued load/preview microtask land before touching the DOM. */
async function settle() {
for (let i = 0; i < 30; i++) {
await nextTick();
await Promise.resolve();
}
}
function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Missing ${text} button`);
return button as HTMLButtonElement;
}
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 });
mocks.listDataTypes.mockResolvedValue([]);
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: true, isPartition: false });
mocks.buildTablePartitionOperationSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.buildCreatePartitionedTableSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.getTablePartitioning.mockResolvedValue({
isPartitioned: true,
isPartition: false,
strategy: "range",
keyDefinition: "RANGE (sold_on)",
keyColumns: ["sold_on"],
defaultPartition: "sales_default",
partitions: [
{ schema: "public", name: "sales_2024", isLeaf: true, bound: { kind: "range", from: ["'2024-01-01'"], to: ["'2025-01-01'"] }, children: [] },
{ schema: "public", name: "sales_default", isLeaf: true, bound: { kind: "default" }, children: [] },
],
});
mocks.getTableOwner.mockResolvedValue("");
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.loadObjectDdl.mockResolvedValue({ ddl: TABLE_DDL, cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({
value:
facet === "comment"
? ""
: facet === "columns"
? [
{ name: "id", data_type: "bigint", nullable: false, default_value: null, comment: "" },
{ name: "email", data_type: "varchar(255)", nullable: true, default_value: null, comment: "" },
]
: [],
cacheStatus: "remote",
}));
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("TableStructureEditor partitions tab", () => {
it("renders the Partitions tab for PostgreSQL and loads the partition tree on activation", async () => {
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
expect(root.querySelector('[data-tab-trigger="partitions"]')).not.toBeNull();
await settle();
expect(mocks.getTablePartitioning).toHaveBeenCalledWith(mocks.connection.id, "test", "", "users");
const text = root.textContent ?? "";
expect(text).toContain("structureEditor.partitionKindRange");
expect(text).toContain("sales_2024");
expect(text).toContain("sales_default");
expect(text).toContain("structureEditor.partitionsDefault");
});
it("shows the empty state for a table that is not partitioned", async () => {
mocks.getTablePartitioning.mockResolvedValue({ isPartitioned: false, isPartition: false, keyColumns: [], partitions: [] });
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
await settle();
expect(root.textContent ?? "").toContain("structureEditor.partitionsEmpty");
});
it("submits pending partition operations through the dedicated builder", async () => {
const operation = {
id: "op:1",
kind: "create",
parentSchema: "",
parentTable: "",
schema: "",
name: "sales_2025",
bound: { kind: "default" },
concurrently: false,
};
mocks.buildTablePartitionOperationSql.mockResolvedValue({
statements: ['CREATE TABLE "public"."sales_2025" PARTITION OF "public"."sales" DEFAULT;'],
warnings: [],
});
const root = await mountStructureEditor({
initialTab: "partitions",
initialTabRequestId: 1,
draft: structureDraft({ activeTab: "partitions", partitionOperations: [operation] }),
});
await settle();
// The SQL preview is debounced, so wait for the builder rather than assuming
// it ran within the microtask settle loop.
await vi.waitFor(() => expect(mocks.buildTablePartitionOperationSql).toHaveBeenCalled(), { timeout: 3000 });
await settle();
const options = mocks.buildTablePartitionOperationSql.mock.calls.at(-1)?.[0] as { operations: unknown[] };
expect(options.operations).toHaveLength(1);
expect(root.textContent ?? "").toContain("structureEditor.partitionPendingOperations");
expect(root.textContent ?? "").toContain('CREATE TABLE "public"."sales_2025"');
});
it("generates a partitioned CREATE TABLE from a create-mode draft", async () => {
mocks.buildCreatePartitionedTableSql.mockResolvedValue({
statements: ['CREATE TABLE "public"."users" (\n "id" integer\n) PARTITION BY RANGE ("id");'],
warnings: [],
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
tableName: "",
initialTab: "partitions",
initialTabRequestId: 1,
draft: structureDraft({
activeTab: "partitions",
newTableName: "users",
columns: [{ id: "id", name: "id", dataType: "integer", isNullable: false, defaultValue: "", comment: "", isPrimaryKey: false, extra: {}, markedForDrop: false }],
createPartitioningEnabled: true,
createPartitioningKind: "range",
createPartitioningColumns: ["id"],
createPartitioningExpression: "",
}),
});
mountedApps.push(app);
app.mount(root);
await vi.waitFor(() => expect(root.querySelector('[data-tab-trigger="partitions"]')).not.toBeNull(), { timeout: 3000 });
await vi.waitFor(() => expect(mocks.buildCreatePartitionedTableSql).toHaveBeenCalled(), { timeout: 3000 });
const args = mocks.buildCreatePartitionedTableSql.mock.calls.at(-1)?.[0] as {
partitioning: { kind: string; columns: string[] };
options: { tableName: string };
};
expect(args.partitioning).toEqual({ kind: "range", columns: ["id"], expression: "" });
expect(args.options.tableName).toBe("users");
});
it("shows the Partitions tab for KingbaseES connections", async () => {
// Regression: the status probe used to be gated on db_type === "postgres",
// which hid the tab on every other PostgreSQL-family engine.
const previous = mocks.connection.db_type;
mocks.connection.db_type = "kingbase";
try {
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
await settle();
expect(root.querySelector('[data-tab-trigger="partitions"]')).not.toBeNull();
expect(mocks.getTablePartitioning).toHaveBeenCalled();
} finally {
mocks.connection.db_type = previous;
}
});
it("still resolves the Partitions tab when the editor opens on the DDL tab", async () => {
// Regression: the DDL entry point skips loadStructure, so the status probe
// never ran and the tab was missing on a partitioned table.
const root = await mountStructureEditor({ initialTab: "ddl", initialTabRequestId: 1 });
await settle();
expect(root.querySelector('[data-tab-trigger="partitions"]')).not.toBeNull();
});
it("hides the Partitions tab for a table that is not partitioned", async () => {
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
const root = await mountStructureEditor();
await settle();
expect(root.querySelector('[data-tab-trigger="partitions"]')).toBeNull();
expect(mocks.getTablePartitioning).not.toHaveBeenCalled();
});
it("folds sub-partitions when the parent row is collapsed", async () => {
mocks.getTablePartitioning.mockResolvedValue({
isPartitioned: true,
isPartition: false,
strategy: "range",
keyDefinition: "RANGE (year)",
keyColumns: ["year"],
partitions: [
{
schema: "public",
name: "logs_2024",
strategy: "list",
isLeaf: false,
bound: { kind: "range", from: ["2024"], to: ["2025"] },
children: [{ schema: "public", name: "logs_2024_cn", isLeaf: true, bound: { kind: "list", values: ["'cn'"] }, children: [] }],
},
],
});
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
await settle();
expect(root.textContent ?? "").toContain("logs_2024_cn");
expect(root.textContent ?? "").toContain("structureEditor.partitionChildCount");
const collapse = root.querySelector('button[title="structureEditor.partitionCollapse"]');
expect(collapse).not.toBeNull();
collapse!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle();
expect(root.textContent ?? "").not.toContain("logs_2024_cn");
expect(root.textContent ?? "").toContain("logs_2024");
expect(root.querySelector('button[title="structureEditor.partitionExpand"]')).not.toBeNull();
});
it("previews the SQL of the operation being edited in its dialog", async () => {
mocks.buildTablePartitionOperationSql.mockResolvedValue({
statements: ['ALTER TABLE "public"."sales" DETACH PARTITION "public"."sales_2024";'],
warnings: [],
});
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
await settle();
const detachButton = root.querySelector('button[title="structureEditor.partitionDetach"]');
expect(detachButton).not.toBeNull();
detachButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.buildTablePartitionOperationSql).toHaveBeenCalled(), { timeout: 3000 });
await vi.waitFor(() => expect(root.textContent ?? "").toContain("ALTER TABLE"), { timeout: 3000 });
expect(root.textContent ?? "").toContain("structureEditor.partitionSqlPreview");
const options = mocks.buildTablePartitionOperationSql.mock.calls.at(-1)?.[0] as {
operations: { kind: string; name: string }[];
};
expect(options.operations).toHaveLength(1);
expect(options.operations[0]).toMatchObject({ kind: "detach", name: "sales_2024" });
});
it("surfaces a load failure instead of an empty state", async () => {
mocks.getTablePartitioning.mockRejectedValue(new Error("permission denied for table pg_partitioned_table"));
const root = await mountStructureEditor({ initialTab: "partitions", initialTabRequestId: 1 });
await settle();
expect(root.textContent ?? "").toContain("permission denied");
});
it("applies pending partition operations inside a single transaction", async () => {
const operation = {
id: "op:1",
kind: "create",
parentSchema: "",
parentTable: "",
schema: "",
name: "sales_2025",
bound: { kind: "default" },
concurrently: false,
};
mocks.buildTablePartitionOperationSql.mockResolvedValue({
statements: ['CREATE TABLE "public"."sales_2025" PARTITION OF "public"."sales" DEFAULT;'],
warnings: [],
});
const root = await mountStructureEditor({
initialTab: "partitions",
initialTabRequestId: 1,
draft: structureDraft({ activeTab: "partitions", partitionOperations: [operation] }),
});
await vi.waitFor(() => expect(mocks.buildTablePartitionOperationSql).toHaveBeenCalled(), { timeout: 3000 });
await settle();
await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false), { timeout: 3000 });
buttonWithText(root, "structureEditor.apply").click();
await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(1), { timeout: 3000 });
// A failure must not leave a half-created hierarchy behind, so the batch is
// sent as one transaction (6th argument).
const call = mocks.executeBatch.mock.calls[0];
expect(call[2]).toEqual(['CREATE TABLE "public"."sales_2025" PARTITION OF "public"."sales" DEFAULT;']);
expect(call[5]).toBe(true);
});
});
@@ -8,7 +8,7 @@ import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { columnIndexMetadataRequestCurrent, columnIndexTableIdentity } from "@/lib/dataGrid/dataGridColumnIndexIcon";
import { foreignKeyMetadataRequestCurrent, foreignKeyTableIdentity } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import type { ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TriggerInfo } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
type SettingsStore = ReturnType<typeof import("@/stores/settingsStore").useSettingsStore>;
@@ -55,6 +55,11 @@ interface TableMetadataState {
constraintsLoaded: Ref<boolean>;
constraintsLoading: Ref<boolean>;
constraintsError: Ref<string>;
partitioning: Ref<PgTablePartitioning | null>;
partitioningLoaded: Ref<boolean>;
partitioningLoading: Ref<boolean>;
partitioningError: Ref<string>;
partitioningRequestGeneration: Ref<number>;
tableInfoColumnsRequestGeneration: Ref<number>;
tableOwnerRequestGeneration: Ref<number>;
indexesRequestGeneration: Ref<number>;
@@ -248,6 +253,26 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
}
}
async function fetchPartitions(force = false) {
const request = tableRequest();
if (!request || state.partitioningLoading.value) return;
if (state.partitioningLoaded.value && !force) return;
const requestGeneration = ++state.partitioningRequestGeneration.value;
state.partitioningLoading.value = true;
state.partitioningError.value = "";
try {
const value = await api.getTablePartitioning(request.connectionId, request.database, request.schema, request.tableName);
if (requestGeneration !== state.partitioningRequestGeneration.value) return;
state.partitioning.value = value;
state.partitioningLoaded.value = true;
} catch (error: any) {
if (requestGeneration !== state.partitioningRequestGeneration.value) return;
state.partitioningError.value = String(error?.message || error);
} finally {
if (requestGeneration === state.partitioningRequestGeneration.value) state.partitioningLoading.value = false;
}
}
async function fetchConstraints() {
const request = tableRequest();
const requestIdentity = currentTableIdentity("constraints");
@@ -286,6 +311,7 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
fetchForeignKeys,
fetchTriggers,
fetchConstraints,
fetchPartitions,
};
}
+47
View File
@@ -4565,6 +4565,53 @@ export default {
constraintDisabled: "Disabled",
constraintNotValidated: "Not validated",
triggers: "Triggers",
partitions: "Partitions",
partitionsEmpty: "This table is not partitioned",
partitionsEmptyChildren: "This partitioned table has no partitions yet",
partitionsParent: "Parent table",
partitionsOwnBound: "Partition bound",
partitionsDefault: "Default partition",
partitionsRowEstimate: "Estimated rows: {count}",
partitionsSize: "Size: {size}",
partitionChildCount: "{count} sub-partition | {count} sub-partitions",
partitionExpand: "Expand",
partitionCollapse: "Collapse",
partitionSubPartitionBadge: "Sub-partition",
partitionMemberBadge: "Partition",
partitionKindRange: "Range",
partitionKindList: "List",
partitionKindHash: "Hash",
partitionBoundDefault: "Default",
partitionEnable: "Enable partitioning",
partitionKind: "Partition strategy",
partitionKeyColumns: "Partition key columns",
partitionKeyNoColumns: "Add columns first, or use an expression.",
partitionKeyExpression: "Partition key expression (overrides columns)",
partitionInitialPartitions: "Initial partitions",
partitionInitialPartitionsEmpty: "No initial partitions; you can add them after the table is created.",
partitionAdd: "Add Partition",
partitionAttach: "Attach Partition",
partitionDetach: "Detach",
partitionDrop: "Drop",
partitionDetachSelf: "Detach This Partition",
partitionDetachConcurrently: "Detach concurrently",
partitionDetachWarning: "Detaching keeps the partition's data in its own table; it stops being part of the parent.",
partitionDropWarning: "Dropping a partition permanently deletes the partition table and all of its data.",
partitionPendingOperations: "Pending partition operations (applied on save)",
partitionRemoveOperation: "Remove",
partitionName: "Partition name",
partitionNamePlaceholder: "e.g. sales_2025",
partitionBoundKind: "Bound type",
partitionBoundFrom: "From",
partitionBoundTo: "To",
partitionBoundValues: "Values",
partitionBoundModulus: "Modulus",
partitionBoundRemainder: "Remainder",
partitionRangeBoundInvalid: "RANGE needs a FROM and TO tuple with the same number of values.",
partitionListBoundInvalid: "LIST needs at least one value.",
partitionHashBoundInvalid: "HASH needs Modulus > 0 and 0 <= Remainder < Modulus.",
partitionSqlPreview: "SQL to run",
partitionNameRequired: "Enter a partition name.",
ddl: "DDL",
addColumn: "Add Column",
copyColumns: "Copy Fields from Another Table",
+47
View File
@@ -4483,6 +4483,53 @@ export default withEnglishFallback({
identitySeed: "Semilla",
identityIncrement: "Incremento",
sqlServerIdentityTypeHint: "La identidad de SQL Server solo admite tinyint, smallint, int, bigint y decimal/numeric con escala 0",
partitions: "Particiones",
partitionsEmpty: "La tabla actual no es una tabla particionada",
partitionsEmptyChildren: "La tabla particionada actual aún no tiene particiones",
partitionsParent: "Tabla padre",
partitionsOwnBound: "Límite de partición",
partitionsDefault: "Partición predeterminada",
partitionsRowEstimate: "Filas estimadas: {count}",
partitionsSize: "Tamaño: {size}",
partitionChildCount: "{count} subparticiones",
partitionExpand: "Expandir",
partitionCollapse: "Contraer",
partitionSubPartitionBadge: "Subpartición",
partitionMemberBadge: "Partición",
partitionKindRange: "Rango",
partitionKindList: "Lista",
partitionKindHash: "Hash",
partitionBoundDefault: "Predeterminado",
partitionEnable: "Habilitar particionado",
partitionKind: "Estrategia de partición",
partitionKeyColumns: "Columnas de clave de partición",
partitionKeyNoColumns: "Agrega primero columnas, o usa una expresión.",
partitionKeyExpression: "Expresión de clave de partición (tiene prioridad sobre las columnas)",
partitionInitialPartitions: "Particiones iniciales",
partitionInitialPartitionsEmpty: "No hay particiones iniciales; se pueden agregar después de crear la tabla.",
partitionAdd: "Agregar partición",
partitionAttach: "Adjuntar partición",
partitionDetach: "Separar",
partitionDrop: "Eliminar",
partitionDetachSelf: "Separar esta partición",
partitionDetachConcurrently: "Separar concurrentemente",
partitionDetachWarning: "Después de separar, los datos de la partición permanecen en una tabla independiente, pero ya no pertenecen a la tabla padre.",
partitionDropWarning: "Eliminar una partición eliminará permanentemente la tabla de partición y todos sus datos.",
partitionPendingOperations: "Operaciones de partición pendientes (se ejecutan al guardar)",
partitionRemoveOperation: "Quitar",
partitionName: "Nombre de la partición",
partitionNamePlaceholder: "p. ej., sales_2025",
partitionBoundKind: "Tipo de límite",
partitionBoundFrom: "Valor inicial",
partitionBoundTo: "Valor final",
partitionBoundValues: "Valores de partición",
partitionBoundModulus: "Módulo",
partitionBoundRemainder: "Resto",
partitionRangeBoundInvalid: "RANGE requiere tuplas FROM y TO con la misma cantidad de valores.",
partitionListBoundInvalid: "LIST requiere al menos un valor.",
partitionHashBoundInvalid: "HASH requiere módulo > 0 y 0 <= resto < módulo.",
partitionSqlPreview: "SQL que se ejecutará",
partitionNameRequired: "Introduce el nombre de la partición.",
},
diagram: {
title: "Diagrama de relaciones",
+47
View File
@@ -4480,6 +4480,53 @@ export default withEnglishFallback({
identitySeed: "Seed",
identityIncrement: "Incremento",
sqlServerIdentityTypeHint: "Identity di SQL Server supporta solo tinyint, smallint, int, bigint e decimal/numeric con scala 0",
partitions: "Partizioni",
partitionsEmpty: "La tabella corrente non è una tabella partizionata",
partitionsEmptyChildren: "La tabella partizionata corrente non ha ancora partizioni",
partitionsParent: "Tabella padre",
partitionsOwnBound: "Confine di partizione",
partitionsDefault: "Partizione predefinita",
partitionsRowEstimate: "Righe stimate: {count}",
partitionsSize: "Dimensione: {size}",
partitionChildCount: "{count} sottopartizioni",
partitionExpand: "Espandi",
partitionCollapse: "Comprimi",
partitionSubPartitionBadge: "Sottopartizione",
partitionMemberBadge: "Partizione",
partitionKindRange: "Intervallo",
partitionKindList: "Lista",
partitionKindHash: "Hash",
partitionBoundDefault: "Predefinito",
partitionEnable: "Abilita partizionamento",
partitionKind: "Strategia di partizionamento",
partitionKeyColumns: "Colonne chiave di partizione",
partitionKeyNoColumns: "Aggiungi prima dei campi o usa un'espressione.",
partitionKeyExpression: "Espressione della chiave di partizione (prevale sui campi)",
partitionInitialPartitions: "Partizioni iniziali",
partitionInitialPartitionsEmpty: "Nessuna partizione iniziale; potrai aggiungerle dopo aver creato la tabella.",
partitionAdd: "Aggiungi partizione",
partitionAttach: "Collega partizione",
partitionDetach: "Separa",
partitionDrop: "Elimina",
partitionDetachSelf: "Separa questa partizione",
partitionDetachConcurrently: "Separa in modo concorrente",
partitionDetachWarning: "Dopo la separazione, i dati della partizione rimarranno in una tabella indipendente, ma non faranno più parte della tabella padre.",
partitionDropWarning: "L'eliminazione della partizione eliminerà definitivamente la tabella della partizione e tutti i suoi dati.",
partitionPendingOperations: "Operazioni di partizione in attesa (eseguite al salvataggio)",
partitionRemoveOperation: "Rimuovi",
partitionName: "Nome partizione",
partitionNamePlaceholder: "es. sales_2025",
partitionBoundKind: "Tipo di confine",
partitionBoundFrom: "Valore iniziale",
partitionBoundTo: "Valore finale",
partitionBoundValues: "Valori della partizione",
partitionBoundModulus: "Modulo",
partitionBoundRemainder: "Resto",
partitionRangeBoundInvalid: "RANGE richiede tuple FROM e TO con lo stesso numero di valori.",
partitionListBoundInvalid: "LIST richiede almeno un valore.",
partitionHashBoundInvalid: "HASH richiede modulo > 0 e 0 <= resto < modulo.",
partitionSqlPreview: "SQL da eseguire",
partitionNameRequired: "Inserisci il nome della partizione.",
},
diagram: {
title: "Diagramma delle Relazioni",
+47
View File
@@ -4546,6 +4546,53 @@ export default withEnglishFallback({
addEnumValue: "値を追加",
removeEnumValue: "列挙型の値を削除",
enumValuePlaceholder: "値(空文字可)",
partitions: "パーティション",
partitionsEmpty: "現在のテーブルはパーティションテーブルではありません",
partitionsEmptyChildren: "現在のパーティションテーブルにはまだパーティションがありません",
partitionsParent: "親テーブル",
partitionsOwnBound: "パーティション境界",
partitionsDefault: "デフォルトパーティション",
partitionsRowEstimate: "推定行数:{count}",
partitionsSize: "サイズ:{size}",
partitionChildCount: "{count} 個のサブパーティション",
partitionExpand: "展開",
partitionCollapse: "折りたたみ",
partitionSubPartitionBadge: "サブパーティション",
partitionMemberBadge: "パーティション",
partitionKindRange: "範囲",
partitionKindList: "リスト",
partitionKindHash: "ハッシュ",
partitionBoundDefault: "デフォルト",
partitionEnable: "パーティションを有効にする",
partitionKind: "パーティション戦略",
partitionKeyColumns: "パーティションキー列",
partitionKeyNoColumns: "まずフィールドを追加するか、式を使用してください。",
partitionKeyExpression: "パーティションキー式(フィールドより優先)",
partitionInitialPartitions: "初期パーティション",
partitionInitialPartitionsEmpty: "初期パーティションはありません。テーブル作成後に追加できます。",
partitionAdd: "パーティションを追加",
partitionAttach: "パーティションをアタッチ",
partitionDetach: "デタッチ",
partitionDrop: "削除",
partitionDetachSelf: "このパーティションをデタッチ",
partitionDetachConcurrently: "同時デタッチ",
partitionDetachWarning: "デタッチ後、パーティションデータは独立したテーブルに残りますが、親テーブルには属さなくなります。",
partitionDropWarning: "パーティションを削除すると、そのパーティションテーブルとすべてのデータが完全に削除されます。",
partitionPendingOperations: "適用待ちのパーティション操作(保存時に実行)",
partitionRemoveOperation: "削除",
partitionName: "パーティション名",
partitionNamePlaceholder: "例:sales_2025",
partitionBoundKind: "境界タイプ",
partitionBoundFrom: "開始値",
partitionBoundTo: "終了値",
partitionBoundValues: "パーティション値",
partitionBoundModulus: "モジュラス",
partitionBoundRemainder: "剰余",
partitionRangeBoundInvalid: "RANGE には FROM と TO のタプルが必要で、値の数が同じである必要があります。",
partitionListBoundInvalid: "LIST には少なくとも1つの値が必要です。",
partitionHashBoundInvalid: "HASH にはモジュラス > 0 が必要で、0 <= 剰余 < モジュラス である必要があります。",
partitionSqlPreview: "実行される SQL",
partitionNameRequired: "パーティション名を入力してください。",
},
diagram: {
title: "リレーション図",
+47
View File
@@ -4467,6 +4467,53 @@ export default withEnglishFallback({
identitySeed: "시드",
identityIncrement: "증가분",
sqlServerIdentityTypeHint: "SQL Server ID는 tinyint, smallint, int, bigint 및 스케일이 0인 decimal/numeric만 지원합니다",
partitions: "파티션",
partitionsEmpty: "현재 테이블은 파티션 테이블이 아닙니다",
partitionsEmptyChildren: "현재 파티션 테이블에는 아직 파티션이 없습니다",
partitionsParent: "부모 테이블",
partitionsOwnBound: "파티션 경계",
partitionsDefault: "기본 파티션",
partitionsRowEstimate: "예상 행 수: {count}",
partitionsSize: "크기: {size}",
partitionChildCount: "{count}개의 하위 파티션",
partitionExpand: "펼치기",
partitionCollapse: "접기",
partitionSubPartitionBadge: "하위 파티션",
partitionMemberBadge: "파티션",
partitionKindRange: "범위",
partitionKindList: "목록",
partitionKindHash: "해시",
partitionBoundDefault: "기본",
partitionEnable: "파티션 활성화",
partitionKind: "파티션 전략",
partitionKeyColumns: "파티션 키 필드",
partitionKeyNoColumns: "먼저 필드를 추가하거나 표현식을 사용하세요.",
partitionKeyExpression: "파티션 키 표현식(필드보다 우선)",
partitionInitialPartitions: "초기 파티션",
partitionInitialPartitionsEmpty: "초기 파티션이 없습니다. 테이블 생성 후 추가할 수 있습니다.",
partitionAdd: "파티션 추가",
partitionAttach: "파티션 연결",
partitionDetach: "분리",
partitionDrop: "삭제",
partitionDetachSelf: "이 파티션 분리",
partitionDetachConcurrently: "동시 분리",
partitionDetachWarning: "분리 후에도 파티션 데이터는 독립 테이블에 유지되지만, 더 이상 부모 테이블에 속하지 않습니다.",
partitionDropWarning: "파티션을 삭제하면 해당 파티션 테이블과 모든 데이터가 영구적으로 삭제됩니다.",
partitionPendingOperations: "적용 대기 중인 파티션 작업(저장 시 실행)",
partitionRemoveOperation: "제거",
partitionName: "파티션 이름",
partitionNamePlaceholder: "예: sales_2025",
partitionBoundKind: "경계 유형",
partitionBoundFrom: "시작 값",
partitionBoundTo: "종료 값",
partitionBoundValues: "파티션 값",
partitionBoundModulus: "모듈러스",
partitionBoundRemainder: "나머지",
partitionRangeBoundInvalid: "RANGE에는 FROM과 TO 튜플이 필요하며, 값 개수가 같아야 합니다.",
partitionListBoundInvalid: "LIST에는 값이 하나 이상 필요합니다.",
partitionHashBoundInvalid: "HASH에는 모듈러스 > 0이 필요하며, 0 <= 나머지 < 모듈러스여야 합니다.",
partitionSqlPreview: "실행될 SQL",
partitionNameRequired: "파티션 이름을 입력하세요.",
},
diagram: {
title: "관계 다이어그램",
+47
View File
@@ -4482,6 +4482,53 @@ export default withEnglishFallback({
identitySeed: "Valor inicial",
identityIncrement: "Incremento",
sqlServerIdentityTypeHint: "A identidade do SQL Server só aceita tinyint, smallint, int, bigint e decimal/numeric com escala 0",
partitions: "Partições",
partitionsEmpty: "A tabela atual não é uma tabela particionada",
partitionsEmptyChildren: "A tabela particionada atual ainda não tem partições",
partitionsParent: "Tabela pai",
partitionsOwnBound: "Limite da partição",
partitionsDefault: "Partição padrão",
partitionsRowEstimate: "Estimativa de linhas: {count}",
partitionsSize: "Tamanho: {size}",
partitionChildCount: "{count} subpartições",
partitionExpand: "Expandir",
partitionCollapse: "Recolher",
partitionSubPartitionBadge: "Subpartição",
partitionMemberBadge: "Partição",
partitionKindRange: "RANGE",
partitionKindList: "LIST",
partitionKindHash: "HASH",
partitionBoundDefault: "DEFAULT",
partitionEnable: "Ativar particionamento",
partitionKind: "Estratégia de particionamento",
partitionKeyColumns: "Colunas da chave de partição",
partitionKeyNoColumns: "Adicione campos primeiro ou use uma expressão.",
partitionKeyExpression: "Expressão da chave de partição (tem prioridade sobre campos)",
partitionInitialPartitions: "Partições iniciais",
partitionInitialPartitionsEmpty: "Nenhuma partição inicial; você pode adicioná-las após criar a tabela.",
partitionAdd: "Adicionar partição",
partitionAttach: "Anexar partição",
partitionDetach: "Desanexar",
partitionDrop: "Excluir",
partitionDetachSelf: "Desanexar esta partição",
partitionDetachConcurrently: "Desanexar concorrentemente",
partitionDetachWarning: "Após desanexar, os dados da partição permanecem em uma tabela independente, mas ela não pertence mais à tabela pai.",
partitionDropWarning: "Excluir a partição removerá permanentemente a tabela de partição e todos os seus dados.",
partitionPendingOperations: "Operações de partição pendentes (executadas ao salvar)",
partitionRemoveOperation: "Remover",
partitionName: "Nome da partição",
partitionNamePlaceholder: "ex.: sales_2025",
partitionBoundKind: "Tipo de limite",
partitionBoundFrom: "Valor inicial",
partitionBoundTo: "Valor final",
partitionBoundValues: "Valores da partição",
partitionBoundModulus: "Módulo",
partitionBoundRemainder: "Resto",
partitionRangeBoundInvalid: "RANGE requer tuplas FROM e TO com a mesma quantidade de valores.",
partitionListBoundInvalid: "LIST requer pelo menos um valor.",
partitionHashBoundInvalid: "HASH requer módulo > 0 e 0 <= resto < módulo.",
partitionSqlPreview: "SQL a ser executado",
partitionNameRequired: "Digite o nome da partição.",
},
diagram: {
title: "Diagrama de relacionamentos",
+47
View File
@@ -4539,6 +4539,53 @@ export default withEnglishFallback({
constraintDisabled: "已禁用",
constraintNotValidated: "未验证",
triggers: "触发器",
partitions: "分区",
partitionsEmpty: "当前表不是分区表",
partitionsEmptyChildren: "当前分区表还没有分区",
partitionsParent: "父表",
partitionsOwnBound: "分区边界",
partitionsDefault: "默认分区",
partitionsRowEstimate: "估计行数:{count}",
partitionsSize: "大小:{size}",
partitionChildCount: "{count} 个子分区",
partitionExpand: "展开",
partitionCollapse: "折叠",
partitionSubPartitionBadge: "子分区",
partitionMemberBadge: "分区",
partitionKindRange: "范围",
partitionKindList: "列表",
partitionKindHash: "哈希",
partitionBoundDefault: "默认",
partitionEnable: "启用分区",
partitionKind: "分区策略",
partitionKeyColumns: "分区键字段",
partitionKeyNoColumns: "请先添加字段,或使用表达式。",
partitionKeyExpression: "分区键表达式(优先于字段)",
partitionInitialPartitions: "初始分区",
partitionInitialPartitionsEmpty: "暂无初始分区;可在建表后添加。",
partitionAdd: "新增分区",
partitionAttach: "附加分区",
partitionDetach: "分离",
partitionDrop: "删除",
partitionDetachSelf: "分离此分区",
partitionDetachConcurrently: "并发分离",
partitionDetachWarning: "分离后分区数据仍保留在独立表中,但不再属于父表。",
partitionDropWarning: "删除分区会永久删除该分区表及其全部数据。",
partitionPendingOperations: "待应用的分区操作(保存时执行)",
partitionRemoveOperation: "移除",
partitionName: "分区名称",
partitionNamePlaceholder: "例如 sales_2025",
partitionBoundKind: "边界类型",
partitionBoundFrom: "起始值",
partitionBoundTo: "结束值",
partitionBoundValues: "分区值",
partitionBoundModulus: "模数",
partitionBoundRemainder: "余数",
partitionRangeBoundInvalid: "RANGE 需要 FROM 与 TO 元组,且值数量相同。",
partitionListBoundInvalid: "LIST 至少需要一个值。",
partitionHashBoundInvalid: "HASH 需要模数 > 0,且 0 <= 余数 < 模数。",
partitionSqlPreview: "将执行的 SQL",
partitionNameRequired: "请输入分区名称。",
ddl: "DDL",
addColumn: "新增字段",
copyColumns: "复制其他表字段",
+47
View File
@@ -4220,6 +4220,53 @@ export default withEnglishFallback({
charsetPlaceholder: "字元集",
collationPlaceholder: "排序規則",
collation: "排序規則",
partitions: "分割區",
partitionsEmpty: "目前資料表不是分割資料表",
partitionsEmptyChildren: "目前分割資料表還沒有分割區",
partitionsParent: "父資料表",
partitionsOwnBound: "分割區邊界",
partitionsDefault: "預設分割區",
partitionsRowEstimate: "估計資料列數:{count}",
partitionsSize: "大小:{size}",
partitionChildCount: "{count} 個子分割區",
partitionExpand: "展開",
partitionCollapse: "摺疊",
partitionSubPartitionBadge: "子分割區",
partitionMemberBadge: "分割區",
partitionKindRange: "範圍",
partitionKindList: "清單",
partitionKindHash: "雜湊",
partitionBoundDefault: "預設",
partitionEnable: "啟用分割",
partitionKind: "分割策略",
partitionKeyColumns: "分割索引鍵欄位",
partitionKeyNoColumns: "請先新增欄位,或使用運算式。",
partitionKeyExpression: "分割索引鍵運算式(優先於欄位)",
partitionInitialPartitions: "初始分割區",
partitionInitialPartitionsEmpty: "尚無初始分割區;可在建立資料表後新增。",
partitionAdd: "新增分割區",
partitionAttach: "附加分割區",
partitionDetach: "分離",
partitionDrop: "刪除",
partitionDetachSelf: "分離此分割區",
partitionDetachConcurrently: "並行分離",
partitionDetachWarning: "分離後,分割區資料仍會保留在獨立資料表中,但不再屬於父資料表。",
partitionDropWarning: "刪除分割區會永久刪除該分割區資料表及其全部資料。",
partitionPendingOperations: "待套用的分割區操作(儲存時執行)",
partitionRemoveOperation: "移除",
partitionName: "分割區名稱",
partitionNamePlaceholder: "例如 sales_2025",
partitionBoundKind: "邊界類型",
partitionBoundFrom: "起始值",
partitionBoundTo: "結束值",
partitionBoundValues: "分割區值",
partitionBoundModulus: "模數",
partitionBoundRemainder: "餘數",
partitionRangeBoundInvalid: "RANGE 需要 FROM 與 TO 元組,且值數量相同。",
partitionListBoundInvalid: "LIST 至少需要一個值。",
partitionHashBoundInvalid: "HASH 需要模數 > 0,且 0 <= 餘數 < 模數。",
partitionSqlPreview: "將執行的 SQL",
partitionNameRequired: "請輸入分割區名稱。",
},
diagram: {
title: "關係圖",
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { flattenPgPartitionNodes, pgPartitionBoundText, pgPartitionKindLabelKey, pgPartitionNodeBoundText, pgPartitionRowHint, splitPgPartitionBoundValues, visiblePgPartitionRows } from "@/lib/table/pgPartitionPresentation";
import type { PgPartitionNode } from "@/types/database";
function node(name: string, children: PgPartitionNode[] = []): PgPartitionNode {
return { schema: "public", name, isLeaf: children.length === 0, children };
}
describe("pgPartitionPresentation", () => {
it("flattens a multi-level tree depth-first with nesting depth", () => {
const tree = [node("logs", [node("logs_2024", [node("logs_2024_us")])]), node("logs_default")];
expect(flattenPgPartitionNodes(tree).map(({ depth, node: rowNode }) => `${depth}:${rowNode.name}`)).toEqual(["0:logs", "1:logs_2024", "2:logs_2024_us", "0:logs_default"]);
});
it("carries the direct parent identity for nested partition operations", () => {
const rows = flattenPgPartitionNodes([node("logs_2024", [node("logs_2024_cn")])], { schema: "public", name: "logs" });
expect(rows.map((row) => ({ name: row.node.name, parentSchema: row.parentSchema, parentName: row.parentName }))).toEqual([
{ name: "logs_2024", parentSchema: "public", parentName: "logs" },
{ name: "logs_2024_cn", parentSchema: "public", parentName: "logs_2024" },
]);
});
it("gives every row a unique key", () => {
const rows = flattenPgPartitionNodes([node("a"), node("b", [node("b1")])]);
expect(new Set(rows.map((row) => row.key)).size).toBe(rows.length);
});
it("tracks depth and the ancestor key chain used to fold and indent a parent", () => {
const rows = flattenPgPartitionNodes([node("p1", [node("p1a", [node("p1a1")])]), node("p2")]);
expect(rows.map((row) => [row.node.name, row.depth, row.ancestorKeys])).toEqual([
["p1", 0, []],
["p1a", 1, ["public.p1"]],
["p1a1", 2, ["public.p1", "public.p1/public.p1a"]],
["p2", 0, []],
]);
});
it("hides every descendant of a collapsed parent", () => {
const rows = flattenPgPartitionNodes([node("p1", [node("p1a"), node("p1b")]), node("p2")]);
expect(visiblePgPartitionRows(rows, new Set()).map((row) => row.node.name)).toEqual(["p1", "p1a", "p1b", "p2"]);
expect(visiblePgPartitionRows(rows, new Set(["public.p1"])).map((row) => row.node.name)).toEqual(["p1", "p2"]);
});
it("builds a row hover hint from the size and count estimates", () => {
const t = (key: string, params?: Record<string, unknown>) => `${key}(${JSON.stringify(params)})`;
const bytes = (value: number) => `${value}B`;
expect(pgPartitionRowHint({ ...node("p"), rowEstimate: 366, totalBytes: 1048576 }, t, bytes)).toBe('structureEditor.partitionsRowEstimate({"count":366}) · structureEditor.partitionsSize({"size":"1048576B"})');
expect(pgPartitionRowHint({ ...node("p"), rowEstimate: 5 }, t, bytes)).toBe('structureEditor.partitionsRowEstimate({"count":5})');
expect(pgPartitionRowHint({ ...node("p"), totalBytes: 10 }, t, bytes)).toBe('structureEditor.partitionsSize({"size":"10B"})');
// No estimates -> no tooltip at all.
expect(pgPartitionRowHint(node("p"), t, bytes)).toBeUndefined();
});
it("renders each bound kind as its SQL fragment", () => {
expect(pgPartitionBoundText({ kind: "range", from: ["'2024-01-01'"], to: ["'2025-01-01'"] })).toBe("FROM ('2024-01-01') TO ('2025-01-01')");
expect(pgPartitionBoundText({ kind: "range", from: ["MINVALUE", "'a'"], to: ["MAXVALUE", "'b'"] })).toBe("FROM (MINVALUE, 'a') TO (MAXVALUE, 'b')");
expect(pgPartitionBoundText({ kind: "list", values: ["'a'", "'b'"] })).toBe("IN ('a', 'b')");
expect(pgPartitionBoundText({ kind: "hash", modulus: 2, remainder: 1 })).toBe("MODULUS 2 REMAINDER 1");
expect(pgPartitionBoundText({ kind: "default" })).toBe("DEFAULT");
expect(pgPartitionBoundText(undefined)).toBe("");
});
it("falls back to the raw catalog definition when the bound was not parsed", () => {
expect(pgPartitionNodeBoundText({ ...node("p"), boundDefinition: "FOR VALUES IN (1)" })).toBe("FOR VALUES IN (1)");
expect(pgPartitionNodeBoundText({ ...node("p"), bound: { kind: "default" }, boundDefinition: "DEFAULT" })).toBe("DEFAULT");
expect(pgPartitionNodeBoundText(node("p"))).toBe("");
});
it("splits bound values on top-level commas only", () => {
expect(splitPgPartitionBoundValues("1, 2, 3")).toEqual(["1", "2", "3"]);
expect(splitPgPartitionBoundValues("'a,b', 'c'")).toEqual(["'a,b'", "'c'"]);
expect(splitPgPartitionBoundValues("lower('A''B'), lower('C')")).toEqual(["lower('A''B')", "lower('C')"]);
expect(splitPgPartitionBoundValues(" '2024-01-01' ")).toEqual(["'2024-01-01'"]);
expect(splitPgPartitionBoundValues("")).toEqual([]);
expect(splitPgPartitionBoundValues(" , ")).toEqual([]);
});
it("maps strategies to translation keys", () => {
expect(pgPartitionKindLabelKey("range")).toBe("structureEditor.partitionKindRange");
expect(pgPartitionKindLabelKey("list")).toBe("structureEditor.partitionKindList");
expect(pgPartitionKindLabelKey("hash")).toBe("structureEditor.partitionKindHash");
expect(pgPartitionKindLabelKey(undefined)).toBeUndefined();
});
});
@@ -3,12 +3,13 @@ import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visib
describe("table structure metadata loading", () => {
it.each([
["columns", { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
["indexes", { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true }],
["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true }],
["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }],
["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
["columns", { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
["indexes", { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, partitions: false, tableComment: true }],
["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: true }],
["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: true }],
["partitions", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false }],
["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
] as const)("requests only the metadata required by the %s tab", (tab, expected) => {
expect(visibleTableStructureRefreshScope(tab)).toEqual(expected);
});
@@ -16,18 +17,24 @@ describe("table structure metadata loading", () => {
it("requests only index metadata after columns and comments are already loaded", () => {
const scope = unloadedTableStructureRefreshScope("indexes", new Set(["columns", "comment"]));
expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: false });
expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: false });
expect(hasTableStructureRefreshWork(scope)).toBe(true);
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("indexes", new Set(["columns", "indexes", "comment"])))).toBe(false);
});
it("requests trigger metadata only until that facet is loaded", () => {
expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: false });
expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: false });
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("triggers", new Set(["triggers", "comment"])))).toBe(false);
});
it("requests constraint metadata only until that facet is loaded", () => {
expect(unloadedTableStructureRefreshScope("constraints", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: false });
expect(unloadedTableStructureRefreshScope("constraints", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: false });
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("constraints", new Set(["constraints", "comment"])))).toBe(false);
});
it("tracks the partitions facet through the loaded set so re-activating the tab does not refetch", () => {
expect(unloadedTableStructureRefreshScope("partitions", new Set())).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false });
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("partitions", new Set()))).toBe(true);
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("partitions", new Set(["partitions"])))).toBe(false);
});
});
+22
View File
@@ -518,4 +518,26 @@ describe("TDengine table hierarchy", () => {
expect(nodes[0].children?.[0]).toMatchObject({ label: "tree.partitions", isExpanded: false });
});
it("nests second-level partitions under Kingbase-style lowercase parent names", () => {
// KingbaseES returns lowercase relation names and a direct `parent_name`;
// a partition that is itself a partitioned parent must nest recursively.
const nodes = buildTableTreeNodes({
...context,
schema: "partition_demo",
tables: [
{ name: "catalog_nested", table_type: "BASE TABLE", comment: null },
{ name: "catalog_nested_2024", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
{ name: "catalog_nested_2024_asia", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
{ name: "catalog_nested_2024_eu", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
{ name: "catalog_nested_2025", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
],
});
expect(nodes.map((node) => node.label)).toEqual(["catalog_nested"]);
const level1 = tablePartitionGroups(nodes[0])[0].children ?? [];
expect(level1.map((node) => node.label)).toEqual(["catalog_nested_2024", "catalog_nested_2025"]);
const yearPartition = level1.find((node) => node.label === "catalog_nested_2024");
expect(tablePartitionGroups(yearPartition!)[0].children?.map((node) => node.label)).toEqual(["catalog_nested_2024_asia", "catalog_nested_2024_eu"]);
});
});
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCreatePartitionedTableSql, buildTablePartitionOperationSql, getTablePartitioning } from "@/lib/backend/http";
describe("PostgreSQL table partitioning web API", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("loads the structured partitioning view from the schema endpoint", async () => {
const payload = {
isPartitioned: true,
isPartition: false,
strategy: "range",
keyDefinition: "RANGE (sold_on)",
keyColumns: ["sold_on"],
partitions: [{ schema: "public", name: "sales_2024", isLeaf: true, children: [] }],
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(payload),
});
vi.stubGlobal("fetch", fetchMock);
await expect(getTablePartitioning("connection 1", "sales/db", "public", "order items")).resolves.toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith("/api/schema/table-partitioning?connection_id=connection+1&database=sales%2Fdb&schema=public&table=order+items");
});
it("builds partition maintenance SQL through the query endpoint", async () => {
const options = {
databaseType: "postgres" as const,
schema: "public",
tableName: "sales",
operations: [
{
id: "op:1",
kind: "create" as const,
parentSchema: "",
parentTable: "",
schema: "",
name: "sales_2025",
bound: { kind: "default" as const },
concurrently: false,
},
],
};
const payload = {
statements: ['CREATE TABLE "public"."sales_2025" PARTITION OF "public"."sales" DEFAULT;'],
warnings: [],
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(payload),
});
vi.stubGlobal("fetch", fetchMock);
await expect(buildTablePartitionOperationSql(options)).resolves.toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith("/api/query/build-table-partition-operation-sql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ options }),
});
});
it("builds a partitioned CREATE TABLE through the query endpoint", async () => {
const options = { databaseType: "postgres" as const, schema: "public", tableName: "sales", columns: [] } as never;
const partitioning = { kind: "range" as const, columns: ["sold_on"], expression: "" };
const payload = { statements: ['CREATE TABLE "public"."sales" (\n "sold_on" date\n) PARTITION BY RANGE ("sold_on");'], warnings: [] };
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(payload),
});
vi.stubGlobal("fetch", fetchMock);
await expect(buildCreatePartitionedTableSql({ options, partitioning })).resolves.toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith("/api/query/build-create-partitioned-table-sql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ options, partitioning }),
});
});
});
+3
View File
@@ -220,6 +220,7 @@ export const listTriggers = forward("listTriggers");
export const listConstraints = forward("listConstraints");
export const listPartitions = forward("listPartitions");
export const getTablePartitionStatus = forward("getTablePartitionStatus");
export const getTablePartitioning = forward("getTablePartitioning");
export const listInvalidIndexes = forward("listInvalidIndexes");
export const listSubpartitions = forward("listSubpartitions");
export const getTableDdl = forward("getTableDdl");
@@ -299,6 +300,8 @@ export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRen
export const buildViewDdlSql = forward("buildViewDdlSql");
export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql");
export const buildTableOwnerChangeSql = forward("buildTableOwnerChangeSql");
export const buildTablePartitionOperationSql = forward("buildTablePartitionOperationSql");
export const buildCreatePartitionedTableSql = forward("buildCreatePartitionedTableSql");
export const previewSqliteTableStructureChange = forward("previewSqliteTableStructureChange");
export const applySqliteTableStructureChange = forward("applySqliteTableStructureChange");
export const buildCreateTableSql = forward("buildCreateTableSql");
+15 -2
View File
@@ -189,7 +189,7 @@ import type {
} from "@/lib/dataGrid/dataGridSql";
import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/lib/sql/dmlChangePreview";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
@@ -1179,6 +1179,10 @@ export async function getTablePartitionStatus(connectionId: string, database: st
return get(`/api/schema/table-partition-status?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function getTablePartitioning(connectionId: string, database: string, schema: string, table: string): Promise<import("@/types/database").PgTablePartitioning> {
return get(`/api/schema/table-partitioning?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
return get(`/api/schema/invalid-indexes?${qs({ connection_id: connectionId, database, schema, table })}`);
}
@@ -1458,13 +1462,14 @@ export async function closeClientConnectionSession(connectionId: string, databas
});
}
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise<QueryResult> {
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number, useTransaction?: boolean): Promise<QueryResult> {
return post("/api/query/execute-batch", {
connectionId,
database,
statements,
schema,
timeoutSecs,
useTransaction,
});
}
@@ -1712,6 +1717,14 @@ export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSql
return post("/api/query/build-table-owner-change-sql", { options });
}
export async function buildTablePartitionOperationSql(options: TablePartitionSqlOptions): Promise<TableStructureChangeSql> {
return post("/api/query/build-table-partition-operation-sql", { options });
}
export async function buildCreatePartitionedTableSql(options: BuildCreatePartitionedTableSqlOptions): Promise<TableStructureChangeSql> {
return post("/api/query/build-create-partitioned-table-sql", { options: options.options, partitioning: options.partitioning });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
return post("/api/query/preview-sqlite-table-structure-change", {
connectionId,
+20 -2
View File
@@ -122,7 +122,7 @@ import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/li
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSqlPlan, SelectedSchemaDiffInput, GenerateSchemaSyncPlanOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
@@ -1718,13 +1718,14 @@ export async function closeClientConnectionSession(connectionId: string, databas
});
}
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise<QueryResult> {
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number, useTransaction?: boolean): Promise<QueryResult> {
return invoke("execute_batch", {
connectionId,
database,
statements,
schema,
timeoutSecs,
useTransaction,
});
}
@@ -1961,6 +1962,14 @@ export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSql
return invoke("build_table_owner_change_sql", { options });
}
export async function buildTablePartitionOperationSql(options: TablePartitionSqlOptions): Promise<TableStructureChangeSql> {
return invoke("build_table_partition_operation_sql", { options });
}
export async function buildCreatePartitionedTableSql(options: BuildCreatePartitionedTableSqlOptions): Promise<TableStructureChangeSql> {
return invoke("build_create_partitioned_table_sql", { options: options.options, partitioning: options.partitioning });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
return invoke("preview_sqlite_table_structure_change", {
connectionId,
@@ -2174,6 +2183,15 @@ export async function getTablePartitionStatus(connectionId: string, database: st
});
}
export async function getTablePartitioning(connectionId: string, database: string, schema: string, table: string): Promise<import("@/types/database").PgTablePartitioning> {
return invoke("get_table_partitioning", {
connectionId,
database,
schema,
table,
});
}
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
return invoke("list_invalid_indexes", {
connectionId,
@@ -42,7 +42,7 @@ async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<
}
}
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment" | "owner";
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "partitions" | "comment" | "owner";
function cacheSegment(value: string | undefined): string {
return encodeURIComponent(value ?? "");
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableMetadataCapabilities, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
describe("tableMetadataCapabilities", () => {
it("exposes only collection indexes for MongoDB table information", () => {
@@ -9,6 +9,7 @@ describe("tableMetadataCapabilities", () => {
foreignKeys: false,
constraints: false,
triggers: false,
partitions: false,
ddl: false,
});
});
@@ -23,4 +24,28 @@ describe("tableMetadataCapabilities", () => {
expect(getTableMetadataCapabilities("mysql").constraints).toBe(false);
expect(getTableMetadataCapabilities(undefined).constraints).toBe(false);
});
it("exposes partitions only for dialects with declarative partitioning metadata", () => {
expect(getTableMetadataCapabilities("postgres").partitions).toBe(true);
expect(getTableMetadataCapabilities("kingbase").partitions).toBe(true);
expect(getTableMetadataCapabilities("mysql").partitions).toBe(false);
expect(getTableMetadataCapabilities("oracle").partitions).toBe(false);
// openGauss-based engines use a different partition catalog and stay off.
expect(getTableMetadataCapabilities("opengauss").partitions).toBe(false);
expect(getTableMetadataCapabilities("vastbase").partitions).toBe(false);
expect(getTableMetadataCapabilities("gaussdb").partitions).toBe(false);
expect(getTableMetadataCapabilities(undefined).partitions).toBe(false);
});
it("shows the Partitions tab for PostgreSQL in both edit and create mode only", () => {
const postgres = getTableMetadataCapabilities("postgres");
expect(isStructureMetadataTabSupported("partitions", postgres, false)).toBe(true);
// Create mode declares `PARTITION BY` instead of reading the catalog.
expect(isStructureMetadataTabSupported("partitions", postgres, true)).toBe(true);
const mysql = getTableMetadataCapabilities("mysql");
expect(isStructureMetadataTabSupported("partitions", mysql, false)).toBe(false);
// The DDL tab has no create-mode baseline; the partitions tab does.
expect(isStructureMetadataTabSupported("ddl", postgres, true)).toBe(false);
});
});
@@ -244,6 +244,78 @@ function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) {
}
}
export type ObjectBrowserRowSorter = (rows: ObjectBrowserRow[]) => ObjectBrowserRow[];
/**
* Flattens the object rows into render order: roots first, each followed by its
* partition children — recursively, because a PostgreSQL partition can itself be
* a partitioned parent (a second-level sub-partitioned table).
*
* `depths` carries each partition row's nesting level so the caller can indent
* it; roots are 0.
*
* `rows`/`matchingRows` are expected to be pre-filtered by the caller's type
* filter. When `query` is non-empty the tree is force-expanded so a deep match
* and the ancestors leading to it stay visible even while collapsed.
*/
export function groupObjectBrowserRows(options: { rows: readonly ObjectBrowserRow[]; matchingRows: readonly ObjectBrowserRow[]; query: string; expandedPartitionParentIds: ReadonlySet<string>; sortRows: ObjectBrowserRowSorter }): { rows: ObjectBrowserRow[]; depths: Map<string, number> } {
const { rows, matchingRows, query, expandedPartitionParentIds, sortRows } = options;
const candidateIds = new Set(rows.map((row) => row.id));
const matchingIds = new Set(matchingRows.map((row) => row.id));
const rowById = new Map(rows.map((row) => [row.id, row]));
const partitionRowsByParentId = new Map<string, ObjectBrowserRow[]>();
for (const row of rows) {
if (!row.partitionParentId) continue;
const group = partitionRowsByParentId.get(row.partitionParentId) ?? [];
group.push(row);
partitionRowsByParentId.set(row.partitionParentId, group);
}
// A deep match must be reachable: pull in every ancestor that leads to it, or
// the match would be filtered out as an orphaned sub-partition.
const ancestorIdsWithMatchingPartitions = new Set<string>();
if (query) {
for (const row of matchingRows) {
let ancestorId = row.partitionParentId;
while (ancestorId && !ancestorIdsWithMatchingPartitions.has(ancestorId)) {
ancestorIdsWithMatchingPartitions.add(ancestorId);
ancestorId = rowById.get(ancestorId)?.partitionParentId;
}
}
}
const rootRows = rows.filter((row) => {
if (row.partitionParentId) return false;
if (!query) return true;
return matchingIds.has(row.id) || ancestorIdsWithMatchingPartitions.has(row.id);
});
const result: ObjectBrowserRow[] = [];
const depths = new Map<string, number>();
const appendPartitions = (parent: ObjectBrowserRow, depth: number) => {
const partitions = partitionRowsByParentId.get(parent.id)?.filter((partition) => candidateIds.has(partition.id));
if (!partitions?.length) return;
// Search force-expands the tree so every match (and its ancestor chain) is visible.
if (!query && !expandedPartitionParentIds.has(parent.id)) return;
const parentMatches = matchingIds.has(parent.id);
const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id) || ancestorIdsWithMatchingPartitions.has(partition.id)) : partitions;
for (const partition of sortRows(visiblePartitions)) {
result.push(partition);
depths.set(partition.id, depth);
appendPartitions(partition, depth + 1);
}
};
for (const row of sortRows(rootRows)) {
result.push(row);
depths.set(row.id, 0);
appendPartitions(row, 1);
}
return { rows: result, depths };
}
function objectKey(row: Pick<ObjectBrowserRow, "schema" | "name" | "type">, fallbackSchema: string) {
return `${row.type}\0${(row.schema || fallbackSchema).toLowerCase()}\0${row.name.toLowerCase()}`;
}
@@ -0,0 +1,131 @@
import type { PgPartitionBound, PgPartitionKind, PgPartitionNode } from "@/types/database";
/** One flattened row of a partition tree, carrying what a renderer needs. */
export interface PgPartitionTreeRow {
key: string;
/** 0 for a top-level partition of the table; +1 per nesting level. */
depth: number;
node: PgPartitionNode;
/** Direct parent identity, used by detach operations on nested partitions. */
parentSchema?: string;
parentName?: string;
/** Keys of every ancestor above this row, outermost first (for collapse). */
ancestorKeys: string[];
}
export interface PgPartitionParentIdentity {
schema?: string;
name?: string;
}
/**
* Depth-first flatten of a partition tree so a renderer can show multi-level
* hierarchies without recursive components. Nesting is conveyed by indentation
* and the fold control; no tree guide lines are drawn.
*/
export function flattenPgPartitionNodes(nodes: PgPartitionNode[], rootParent: PgPartitionParentIdentity = {}): PgPartitionTreeRow[] {
return flattenPgPartitionNodesAtDepth(nodes, rootParent, 0, "", []);
}
function flattenPgPartitionNodesAtDepth(nodes: PgPartitionNode[], parent: PgPartitionParentIdentity, depth: number, keyPrefix: string, ancestorKeys: string[]): PgPartitionTreeRow[] {
const rows: PgPartitionTreeRow[] = [];
for (const node of nodes) {
const key = `${keyPrefix}${node.schema}.${node.name}`;
rows.push({ key, depth, node, parentSchema: parent.schema, parentName: parent.name, ancestorKeys: [...ancestorKeys] });
if (node.children.length > 0) {
rows.push(...flattenPgPartitionNodesAtDepth(node.children, { schema: node.schema, name: node.name }, depth + 1, `${key}/`, [...ancestorKeys, key]));
}
}
return rows;
}
/** Pixels of indentation added per nesting level. */
export const PARTITION_TREE_INDENT_PX = 16;
/**
* Drops rows hidden behind a collapsed ancestor, so a renderer can show a
* folded tree without re-walking it.
*/
export function visiblePgPartitionRows(rows: PgPartitionTreeRow[], collapsedKeys: ReadonlySet<string>): PgPartitionTreeRow[] {
if (collapsedKeys.size === 0) return rows;
return rows.filter((row) => !row.ancestorKeys.some((key) => collapsedKeys.has(key)));
}
export type PartitionTranslate = (key: string, params?: Record<string, unknown>) => string;
/**
* Hover hint for a partition row. The estimated row count and size are kept out
* of the row body (they doubled every row's height) and surfaced here instead.
*/
export function pgPartitionRowHint(node: PgPartitionNode, t: PartitionTranslate, formatBytes: (value: number) => string): string | undefined {
const parts: string[] = [];
if (node.rowEstimate != null) parts.push(t("structureEditor.partitionsRowEstimate", { count: node.rowEstimate }));
if (node.totalBytes != null) parts.push(t("structureEditor.partitionsSize", { size: formatBytes(node.totalBytes) }));
return parts.length > 0 ? parts.join(" · ") : undefined;
}
/** Renders a parsed bound as the SQL fragment PostgreSQL uses in `CREATE TABLE ... PARTITION OF`. */
export function pgPartitionBoundText(bound?: PgPartitionBound): string {
if (!bound) return "";
if (bound.kind === "default") return "DEFAULT";
if (bound.kind === "range") return `FROM (${bound.from.join(", ")}) TO (${bound.to.join(", ")})`;
if (bound.kind === "list") return `IN (${bound.values.join(", ")})`;
return `MODULUS ${bound.modulus} REMAINDER ${bound.remainder}`;
}
/**
* Bound text for a tree node, falling back to the raw catalog definition when
* the bound shape was not recognized by the backend parser.
*/
export function pgPartitionNodeBoundText(node: PgPartitionNode): string {
return node.bound ? pgPartitionBoundText(node.bound) : node.boundDefinition || "";
}
/**
* Splits user-entered partition bound values on top-level commas only. Commas
* inside single quotes (with `''` escapes) or nested parentheses stay in the
* value, so `lower('a,b'), 2` yields two values.
*/
export function splitPgPartitionBoundValues(input: string): string[] {
const values: string[] = [];
let current = "";
let depth = 0;
let inSingle = false;
let inDouble = false;
for (let index = 0; index < input.length; index += 1) {
const ch = input[index];
if (ch === "'" && !inDouble) {
if (inSingle && input[index + 1] === "'") {
current += "''";
index += 1;
continue;
}
inSingle = !inSingle;
current += ch;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if (!inSingle && !inDouble && ch === "(") depth += 1;
if (!inSingle && !inDouble && ch === ")") depth -= 1;
if (ch === "," && !inSingle && !inDouble && depth === 0) {
values.push(current.trim());
current = "";
continue;
}
current += ch;
}
values.push(current.trim());
return values.filter((value) => value.length > 0);
}
/** Translation key for a partition strategy label. */
export function pgPartitionKindLabelKey(kind?: PgPartitionKind): string | undefined {
if (kind === "range") return "structureEditor.partitionKindRange";
if (kind === "list") return "structureEditor.partitionKindList";
if (kind === "hash") return "structureEditor.partitionKindHash";
return undefined;
}
@@ -6,6 +6,7 @@ export interface TableMetadataCapabilities {
foreignKeys: boolean;
constraints: boolean;
triggers: boolean;
partitions: boolean;
ddl: boolean;
}
@@ -19,6 +20,9 @@ const defaultCapabilities: TableMetadataCapabilities = {
// empty tab.
constraints: false,
triggers: true,
// Declarative partitioning metadata (pg_partitioned_table / pg_get_partkeydef)
// is PostgreSQL-only for now; other dialects leave the tab hidden.
partitions: false,
ddl: true,
};
@@ -26,8 +30,10 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
oracle: {
constraints: true,
},
// KingbaseES V9 shares PostgreSQL's declarative partition catalog and DDL.
kingbase: {
constraints: true,
partitions: true,
},
vastbase: {
constraints: true,
@@ -39,6 +45,7 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
// EXCLUDE/NOT NULL) through list_constraints.
postgres: {
constraints: true,
partitions: true,
},
// SQL Server reports PK/UNIQUE/FOREIGN KEY/CHECK/DEFAULT constraints from the
// sys.* catalog views through list_constraints.
@@ -155,6 +162,7 @@ export function isStructureMetadataTabSupported(tab: TableInfoTab, capabilities:
(tab === "foreignKeys" && capabilities.foreignKeys) ||
(tab === "constraints" && capabilities.constraints) ||
(tab === "triggers" && capabilities.triggers) ||
(tab === "partitions" && capabilities.partitions) ||
(tab === "ddl" && capabilities.ddl && !isCreateMode)
);
}
@@ -108,6 +108,48 @@ export interface TableStructureChangeSql {
warnings: string[];
}
/** Explicit partition maintenance operations (PostgreSQL only). */
export type TablePartitionOperationKind = "create" | "attach" | "detach" | "drop";
/** Partition bound as entered in the UI; values are SQL literal text. */
export type TablePartitionBoundDraft = { kind: "range"; from: string[]; to: string[] } | { kind: "list"; values: string[] } | { kind: "hash"; modulus: number; remainder: number } | { kind: "default" };
export interface TablePartitionOperation {
id: string;
kind: TablePartitionOperationKind;
/** Partitioned parent's schema; empty falls back to the edited table's schema. */
parentSchema: string;
/** Partitioned parent; empty falls back to the edited table. */
parentTable: string;
/** Schema of the partition relation; empty falls back to the parent's schema. */
schema: string;
name: string;
/** Required for `create`/`attach`; must be absent for `detach`/`drop`. */
bound?: TablePartitionBoundDraft;
/** Emit `DETACH PARTITION ... CONCURRENTLY` (PostgreSQL 14+). */
concurrently: boolean;
}
export interface TablePartitionSqlOptions {
databaseType?: DatabaseType;
driverProfile?: string | null;
schema?: string;
tableName: string;
operations: TablePartitionOperation[];
}
/** Declarative partitioning for a table being created. */
export interface TablePartitionDefinition {
kind: "range" | "list" | "hash";
columns: string[];
expression: string;
}
export interface BuildCreatePartitionedTableSqlOptions {
options: BuildTableStructureChangeSqlOptions;
partitioning: TablePartitionDefinition;
}
export interface BuildTableOwnerChangeSqlOptions {
databaseType?: DatabaseType;
schema?: string;
@@ -7,23 +7,26 @@ export interface TableStructureRefreshScope {
foreignKeys: boolean;
constraints: boolean;
triggers: boolean;
partitions: boolean;
tableComment: boolean;
}
export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): TableStructureRefreshScope {
switch (activeTab) {
case "columns":
return { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
return { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
case "indexes":
return { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
return { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
case "foreignKeys":
return { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true };
return { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, partitions: false, tableComment: true };
case "constraints":
return { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true };
return { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: true };
case "triggers":
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true };
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: true };
case "partitions":
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false };
case "ddl":
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
}
}
@@ -35,10 +38,14 @@ export function unloadedTableStructureRefreshScope(activeTab: TableInfoTab, load
foreignKeys: visibleScope.foreignKeys && !loadedFacets.has("foreign-keys"),
constraints: visibleScope.constraints && !loadedFacets.has("constraints"),
triggers: visibleScope.triggers && !loadedFacets.has("triggers"),
// Partition metadata has no persisted cache facet; "partitions" is only
// tracked in the loaded-facet set so re-activating the tab does not
// re-fetch in a loop.
partitions: visibleScope.partitions && !loadedFacets.has("partitions"),
tableComment: visibleScope.tableComment && !loadedFacets.has("comment"),
};
}
export function hasTableStructureRefreshWork(scope: TableStructureRefreshScope): boolean {
return scope.columns || scope.indexes || scope.foreignKeys || scope.constraints || scope.triggers || scope.tableComment;
return scope.columns || scope.indexes || scope.foreignKeys || scope.constraints || scope.triggers || scope.partitions || scope.tableComment;
}
+1 -1
View File
@@ -1498,7 +1498,7 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
};
}
const TABLE_INFO_TABS = new Set<TableInfoTab>(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers"]);
const TABLE_INFO_TABS = new Set<TableInfoTab>(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers", "partitions"]);
function normalizeTableInfoTab(value: unknown): TableInfoTab {
return typeof value === "string" && TABLE_INFO_TABS.has(value as TableInfoTab) ? (value as TableInfoTab) : DEFAULT_EDITOR_SETTINGS.tableInfoActiveTab;
+44 -1
View File
@@ -1525,7 +1525,43 @@ export interface TableNameFilter {
excludePatterns: string[];
}
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "ddl";
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "partitions" | "ddl";
/** PostgreSQL declarative partitioning strategy (`pg_partitioned_table.partstrat`). */
export type PgPartitionKind = "range" | "list" | "hash";
/** Structured form of a partition's `pg_get_expr(relpartbound)` definition. Values are the SQL literal text PostgreSQL reported (`'2024-01-01'`, `MINVALUE`, `0`). */
export type PgPartitionBound = { kind: "range"; from: string[]; to: string[] } | { kind: "list"; values: string[] } | { kind: "hash"; modulus: number; remainder: number } | { kind: "default" };
export interface PgPartitionNode {
schema: string;
name: string;
strategy?: PgPartitionKind;
keyDefinition?: string;
bound?: PgPartitionBound;
boundDefinition?: string;
isLeaf: boolean;
rowEstimate?: number;
totalBytes?: number;
children: PgPartitionNode[];
}
export interface PgTablePartitioning {
isPartitioned: boolean;
isPartition: boolean;
parent?: string;
parentSchema?: string;
parentTable?: string;
ownBound?: PgPartitionBound;
strategy?: PgPartitionKind;
keyDefinition?: string;
keyColumns: string[];
keyExpression?: string;
defaultPartition?: string;
partitions: PgPartitionNode[];
/** `server_version_num`, used to gate `DETACH PARTITION CONCURRENTLY` (14+). */
serverVersionNum?: number;
}
export interface TableStructureEditorTarget {
kind: "column" | "index";
@@ -1558,6 +1594,13 @@ export interface TableStructureEditorDraft {
constraintsLoaded?: boolean;
triggers: import("@/lib/table/tableStructureEditorSql").EditableStructureTrigger[];
triggersLoaded?: boolean;
/** Pending PostgreSQL partition operations (create/attach/detach/drop). */
partitionOperations?: import("@/lib/table/tableStructureEditorSql").TablePartitionOperation[];
/** Create-mode `PARTITION BY` declaration. */
createPartitioningEnabled?: boolean;
createPartitioningKind?: import("@/types/database").PgPartitionKind;
createPartitioningColumns?: string[];
createPartitioningExpression?: string;
loadedMetadataFacets?: import("@/lib/metadata/objectMetadataCache").ObjectMetadataFacet[];
scrollPositions?: Partial<Record<TableInfoTab, TableStructureEditorViewport>>;
/** Request id of the structureInitialTab the editor already applied; remounts must not replay a consumed initial tab over the restored draft. */