feat(pg,kingbase): partition table operations and detail queries
This commit is contained in:
1 parent
882bd130ab
commit
1000aa07b6
62 files changed
+6180
-133
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
+151
@@ -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");
|
||||
});
|
||||
});
|
||||
+2
@@ -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();
|
||||
});
|
||||
|
||||
+644
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user