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
@@ -32,6 +32,8 @@ public final class AgentProtocol {
|
||||
public static final String METHOD_LIST_CONSTRAINTS = "list_constraints";
|
||||
public static final String METHOD_LIST_PARTITIONS = "list_partitions";
|
||||
public static final String METHOD_LIST_SUBPARTITIONS = "list_subpartitions";
|
||||
public static final String METHOD_GET_TABLE_PARTITION_STATUS = "get_table_partition_status";
|
||||
public static final String METHOD_GET_TABLE_PARTITIONING = "get_table_partitioning";
|
||||
public static final String METHOD_EXECUTE_QUERY = "execute_query";
|
||||
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
|
||||
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
|
||||
@@ -207,6 +209,8 @@ public final class AgentProtocol {
|
||||
METHOD_LIST_CONSTRAINTS,
|
||||
METHOD_LIST_PARTITIONS,
|
||||
METHOD_LIST_SUBPARTITIONS,
|
||||
METHOD_GET_TABLE_PARTITION_STATUS,
|
||||
METHOD_GET_TABLE_PARTITIONING,
|
||||
METHOD_EXECUTE_QUERY,
|
||||
METHOD_EXECUTE_QUERY_PAGE,
|
||||
METHOD_FETCH_QUERY_PAGE,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"list_constraints",
|
||||
"list_partitions",
|
||||
"list_subpartitions",
|
||||
"get_table_partition_status",
|
||||
"get_table_partitioning",
|
||||
"execute_query",
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"list_constraints",
|
||||
"list_partitions",
|
||||
"list_subpartitions",
|
||||
"get_table_partition_status",
|
||||
"get_table_partitioning",
|
||||
"execute_query",
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.com/kingbase/gokb"
|
||||
)
|
||||
@@ -61,9 +63,58 @@ type databaseInfo struct {
|
||||
}
|
||||
|
||||
type tableInfo struct {
|
||||
Name string `json:"name"`
|
||||
TableType string `json:"table_type"`
|
||||
Comment *string `json:"comment"`
|
||||
Name string `json:"name"`
|
||||
TableType string `json:"table_type"`
|
||||
Comment *string `json:"comment"`
|
||||
ParentSchema *string `json:"parent_schema,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
}
|
||||
|
||||
type pgPartitionBound struct {
|
||||
Kind string `json:"kind"`
|
||||
From []string `json:"from,omitempty"`
|
||||
To []string `json:"to,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Modulus *int `json:"modulus,omitempty"`
|
||||
Remainder *int `json:"remainder,omitempty"`
|
||||
}
|
||||
|
||||
type pgPartitionNode struct {
|
||||
Schema string `json:"schema"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
KeyDefinition string `json:"keyDefinition,omitempty"`
|
||||
Bound *pgPartitionBound `json:"bound,omitempty"`
|
||||
BoundDefinition string `json:"boundDefinition,omitempty"`
|
||||
IsLeaf bool `json:"isLeaf"`
|
||||
Children []pgPartitionNode `json:"children"`
|
||||
}
|
||||
|
||||
type pgTablePartitioning struct {
|
||||
IsPartitioned bool `json:"isPartitioned"`
|
||||
IsPartition bool `json:"isPartition"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
ParentSchema string `json:"parentSchema,omitempty"`
|
||||
ParentTable string `json:"parentTable,omitempty"`
|
||||
OwnBound *pgPartitionBound `json:"ownBound,omitempty"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
KeyDefinition string `json:"keyDefinition,omitempty"`
|
||||
KeyColumns []string `json:"keyColumns"`
|
||||
KeyExpression string `json:"keyExpression,omitempty"`
|
||||
DefaultPartition string `json:"defaultPartition,omitempty"`
|
||||
Partitions []pgPartitionNode `json:"partitions"`
|
||||
}
|
||||
|
||||
type partitionRelation struct {
|
||||
OID int64
|
||||
Schema string
|
||||
Name string
|
||||
ParentOID sql.NullInt64
|
||||
ParentSchema sql.NullString
|
||||
ParentName sql.NullString
|
||||
Relkind string
|
||||
Bound sql.NullString
|
||||
KeyDefinition sql.NullString
|
||||
}
|
||||
|
||||
type objectInfo struct {
|
||||
@@ -321,9 +372,9 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
|
||||
if s.mode.postgresCatalog {
|
||||
catalog = "pg_catalog"
|
||||
}
|
||||
includeComment := !s.catalogOIDUnsupported
|
||||
rows, err := s.queryTables(effective, catalog, includeComment)
|
||||
if err != nil && includeComment && isUndefinedColumn(err, "c.oid") {
|
||||
includeCatalogMetadata := !s.catalogOIDUnsupported
|
||||
rows, err := s.queryTables(effective, catalog, includeCatalogMetadata)
|
||||
if err != nil && includeCatalogMetadata && isUndefinedColumn(err, "c.oid") {
|
||||
s.catalogOIDUnsupported = true
|
||||
rows, err = s.queryTables(effective, catalog, false)
|
||||
}
|
||||
@@ -334,11 +385,14 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
|
||||
result := []tableInfo{}
|
||||
for rows.Next() {
|
||||
var name, kind string
|
||||
var comment sql.NullString
|
||||
if err := rows.Scan(&name, &kind, &comment); err != nil {
|
||||
var comment, parentSchema, parentName sql.NullString
|
||||
if err := rows.Scan(&name, &kind, &comment, &parentSchema, &parentName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := tableInfo{Name: name, TableType: normalizeTableType(kind), Comment: nullStringPtr(comment)}
|
||||
item := tableInfo{
|
||||
Name: name, TableType: normalizeTableType(kind), Comment: nullStringPtr(comment),
|
||||
ParentSchema: nullStringPtr(parentSchema), ParentName: nullStringPtr(parentName),
|
||||
}
|
||||
if constraintsMatch(constraints, item.Name, item.TableType) {
|
||||
result = append(result, item)
|
||||
}
|
||||
@@ -346,17 +400,34 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
|
||||
return pageTables(result, constraints), rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) queryTables(schema, catalog string, includeComment bool) (*sql.Rows, error) {
|
||||
func (s *server) queryTables(schema, catalog string, includeCatalogMetadata bool) (*sql.Rows, error) {
|
||||
commentExpression := "NULL AS table_comment"
|
||||
if includeComment {
|
||||
parentSchemaExpression := "NULL AS parent_schema"
|
||||
parentNameExpression := "NULL AS parent_name"
|
||||
inheritanceJoins := ""
|
||||
if includeCatalogMetadata {
|
||||
commentExpression = "obj_description(c.oid) AS table_comment"
|
||||
parentSchemaExpression = "CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema"
|
||||
parentNameExpression = "CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name"
|
||||
prefix := catalogPrefix(catalog)
|
||||
inheritanceJoins = fmt.Sprintf(`
|
||||
LEFT JOIN %s.%s_inherits i ON i.inhrelid = c.oid
|
||||
LEFT JOIN %s.%s_class pc ON pc.oid = i.inhparent
|
||||
LEFT JOIN %s.%s_namespace pn ON pn.oid = pc.relnamespace`, catalog, prefix, catalog, prefix, catalog, prefix)
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT c.relname,
|
||||
prefix := catalogPrefix(catalog)
|
||||
// DISTINCT collapses the rows fanned out by the pg_inherits join when a
|
||||
// table has several legacy INHERITS parents (each such row is identical —
|
||||
// only declarative partition parents fill the parent columns).
|
||||
query := fmt.Sprintf(`SELECT DISTINCT c.relname,
|
||||
CASE c.relkind WHEN 'r' THEN 'TABLE' WHEN 'p' THEN 'TABLE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED_VIEW' WHEN 'f' THEN 'FOREIGN_TABLE' ELSE 'TABLE' END,
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
FROM %s.%s_class c
|
||||
JOIN %s.%s_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = %s AND c.relkind IN ('r','p','v','m','f') ORDER BY c.relname`, commentExpression, catalog, catalogPrefix(catalog), catalog, catalogPrefix(catalog), quoteLiteral(schema))
|
||||
JOIN %s.%s_namespace n ON n.oid = c.relnamespace%s
|
||||
WHERE n.nspname = %s AND c.relkind IN ('r','p','v','m','f')
|
||||
ORDER BY c.relname`, commentExpression, parentSchemaExpression, parentNameExpression, catalog, prefix, catalog, prefix, inheritanceJoins, quoteLiteral(schema))
|
||||
return s.metadataQuery(query)
|
||||
}
|
||||
|
||||
@@ -1095,6 +1166,472 @@ func (s *server) buildCustomTypeDDL(schema, name string, kind customTypeKind, in
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) getTablePartitioning(schema, table string) (pgTablePartitioning, error) {
|
||||
effective, err := s.effectiveSchema(schema)
|
||||
if err != nil {
|
||||
return pgTablePartitioning{}, err
|
||||
}
|
||||
catalog := "sys_catalog"
|
||||
if s.mode.postgresCatalog {
|
||||
catalog = "pg_catalog"
|
||||
}
|
||||
prefix := catalogPrefix(catalog)
|
||||
partKeyFunction := kingbaseCatalogFunction(catalog, "sys_get_partkeydef", "pg_get_partkeydef")
|
||||
boundFunction := kingbaseCatalogFunction(catalog, "sys_get_expr", "pg_get_expr")
|
||||
rows, err := s.queryPartitionTree(catalog, prefix, boundFunction, partKeyFunction, effective, table)
|
||||
if err != nil {
|
||||
return pgTablePartitioning{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
relations := make([]partitionRelation, 0)
|
||||
for rows.Next() {
|
||||
var relation partitionRelation
|
||||
if err := rows.Scan(&relation.OID, &relation.Schema, &relation.Name, &relation.ParentOID, &relation.ParentSchema, &relation.ParentName, &relation.Relkind, &relation.Bound, &relation.KeyDefinition); err != nil {
|
||||
return pgTablePartitioning{}, err
|
||||
}
|
||||
relations = append(relations, relation)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return pgTablePartitioning{}, err
|
||||
}
|
||||
if len(relations) == 0 {
|
||||
return emptyPgTablePartitioning(), nil
|
||||
}
|
||||
rootIndex := -1
|
||||
for index, relation := range relations {
|
||||
if relation.Schema == effective && relation.Name == table {
|
||||
rootIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if rootIndex < 0 {
|
||||
return emptyPgTablePartitioning(), nil
|
||||
}
|
||||
root := relations[rootIndex]
|
||||
result := emptyPgTablePartitioning()
|
||||
result.IsPartitioned = root.Relkind == "p"
|
||||
result.IsPartition = root.ParentOID.Valid
|
||||
if root.ParentSchema.Valid && root.ParentName.Valid {
|
||||
result.ParentSchema, result.ParentTable = root.ParentSchema.String, root.ParentName.String
|
||||
result.Parent = root.ParentSchema.String + "." + root.ParentName.String
|
||||
}
|
||||
if root.KeyDefinition.Valid {
|
||||
result.KeyDefinition = root.KeyDefinition.String
|
||||
result.Strategy = partitionKindFromKeyDefinition(root.KeyDefinition.String)
|
||||
result.KeyColumns = partitionKeyColumns(root.KeyDefinition.String)
|
||||
}
|
||||
result.OwnBound = parseKingbasePartitionBound(root.Bound)
|
||||
children := make(map[int64][]partitionRelation)
|
||||
for index, relation := range relations {
|
||||
if index == rootIndex || !relation.ParentOID.Valid {
|
||||
continue
|
||||
}
|
||||
children[relation.ParentOID.Int64] = append(children[relation.ParentOID.Int64], relation)
|
||||
}
|
||||
result.Partitions = buildKingbasePartitionNodes(root.OID, children)
|
||||
for _, node := range result.Partitions {
|
||||
if node.Bound != nil && node.Bound.Kind == "default" {
|
||||
result.DefaultPartition = node.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// partitionTreeQueryOptions is the catalog-compatibility tier for the
|
||||
// partition-tree query. Kingbase derivatives differ in which catalog columns and
|
||||
// deparser functions exist, so `queryPartitionTree` retries progressively
|
||||
// simpler variants instead of failing the whole RPC.
|
||||
type partitionTreeQueryOptions struct {
|
||||
relispartition bool
|
||||
partitionKey bool
|
||||
}
|
||||
|
||||
// Partition-tree query variants, most faithful first. `relispartition`
|
||||
// distinguishes declarative partitions from legacy INHERITS children (without
|
||||
// it, an inherited table would be mistaken for a partition and offered invalid
|
||||
// DETACH DDL); `partitionKey` needs sys_get_partkeydef/pg_get_partkeydef.
|
||||
func partitionTreeQueryVariants() []partitionTreeQueryOptions {
|
||||
return []partitionTreeQueryOptions{
|
||||
{relispartition: true, partitionKey: true},
|
||||
{relispartition: false, partitionKey: true},
|
||||
{relispartition: false, partitionKey: false},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) queryPartitionTree(catalog, prefix, boundFunction, partKeyFunction, schema, table string) (*sql.Rows, error) {
|
||||
var lastErr error
|
||||
for _, options := range partitionTreeQueryVariants() {
|
||||
rows, err := s.metadataQuery(buildPartitionTreeQuery(catalog, prefix, boundFunction, partKeyFunction, schema, table, options))
|
||||
if err == nil {
|
||||
return rows, nil
|
||||
}
|
||||
lastErr = err
|
||||
if options.relispartition && isUndefinedColumn(err, "relispartition") {
|
||||
continue
|
||||
}
|
||||
if options.partitionKey && isUndefinedFunction(err, "get_partkeydef") {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// buildPartitionTreeQuery renders the recursive partition-tree query. Bounds are
|
||||
// read with the two-argument deparser (sys_get_expr(relpartbound, oid)), which
|
||||
// Kingbase exposes on every supported release; the three-argument pretty form is
|
||||
// not guaranteed on sys_catalog. The recursion carries a path array and filters
|
||||
// on relispartition so a corrupted catalog cannot loop forever and a legacy
|
||||
// INHERITS child is never reported as a partition.
|
||||
func buildPartitionTreeQuery(catalog, prefix, boundFunction, partKeyFunction, schema, table string, options partitionTreeQueryOptions) string {
|
||||
classTable := fmt.Sprintf("%s.%s_class", catalog, prefix)
|
||||
namespaceTable := fmt.Sprintf("%s.%s_namespace", catalog, prefix)
|
||||
inheritsTable := fmt.Sprintf("%s.%s_inherits", catalog, prefix)
|
||||
|
||||
anchorColumns := "c.oid::bigint AS oid, n.nspname AS schema_name, c.relname AS table_name,\n i.inhparent::bigint AS parent_oid, pn.nspname AS parent_schema, pc.relname AS parent_name,\n c.relkind::text AS relkind, ARRAY[c.oid::bigint] AS path"
|
||||
anchorInheritsJoin := fmt.Sprintf("LEFT JOIN %s i ON i.inhrelid = c.oid", inheritsTable)
|
||||
recursiveChildJoin := fmt.Sprintf("JOIN %s c ON c.oid = i.inhrelid", classTable)
|
||||
recursivePredicate := "NOT c.oid = ANY(tree.path)"
|
||||
if options.relispartition {
|
||||
anchorInheritsJoin = fmt.Sprintf("LEFT JOIN %s i ON i.inhrelid = c.oid AND c.relispartition", inheritsTable)
|
||||
recursiveChildJoin = fmt.Sprintf("JOIN %s c ON c.oid = i.inhrelid AND c.relispartition", classTable)
|
||||
} else {
|
||||
// Older catalogs lack relispartition. A declarative partition's parent
|
||||
// always has relkind 'p' (traditional INHERITS parents are relkind 'r'),
|
||||
// so scope the parent edge to partitioned parents instead.
|
||||
anchorColumns = "c.oid::bigint AS oid, n.nspname AS schema_name, c.relname AS table_name,\n CASE WHEN pc.relkind = 'p' THEN i.inhparent::bigint END AS parent_oid,\n CASE WHEN pc.relkind = 'p' THEN pn.nspname END AS parent_schema,\n CASE WHEN pc.relkind = 'p' THEN pc.relname END AS parent_name,\n c.relkind::text AS relkind, ARRAY[c.oid::bigint] AS path"
|
||||
recursivePredicate = "tree.relkind = 'p' AND NOT c.oid = ANY(tree.path)"
|
||||
}
|
||||
|
||||
boundExpression := fmt.Sprintf("%s(c.relpartbound, c.oid)", boundFunction)
|
||||
partKeyExpression := "CAST(NULL AS text)"
|
||||
if options.partitionKey {
|
||||
partKeyExpression = fmt.Sprintf("CASE WHEN t.relkind = 'p' THEN %s(c.oid) ELSE NULL END", partKeyFunction)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`WITH RECURSIVE tree AS (
|
||||
SELECT %s
|
||||
FROM %s c
|
||||
JOIN %s n ON n.oid = c.relnamespace
|
||||
%s
|
||||
LEFT JOIN %s pc ON pc.oid = i.inhparent
|
||||
LEFT JOIN %s pn ON pn.oid = pc.relnamespace
|
||||
WHERE n.nspname = %s AND c.relname = %s AND c.relkind IN ('r','p','f')
|
||||
UNION ALL
|
||||
SELECT c.oid::bigint, n.nspname, c.relname, tree.oid, tree.schema_name, tree.table_name, c.relkind::text,
|
||||
tree.path || c.oid::bigint
|
||||
FROM %s i
|
||||
%s
|
||||
JOIN %s n ON n.oid = c.relnamespace
|
||||
JOIN tree ON tree.oid = i.inhparent
|
||||
WHERE %s
|
||||
)
|
||||
SELECT t.oid, t.schema_name, t.table_name, t.parent_oid, t.parent_schema, t.parent_name, t.relkind,
|
||||
%s AS partition_bound,
|
||||
%s AS partition_key
|
||||
FROM tree t
|
||||
JOIN %s c ON c.oid = t.oid
|
||||
ORDER BY t.oid`,
|
||||
anchorColumns,
|
||||
classTable, namespaceTable, anchorInheritsJoin,
|
||||
classTable, namespaceTable,
|
||||
quoteLiteral(schema), quoteLiteral(table),
|
||||
inheritsTable,
|
||||
recursiveChildJoin,
|
||||
namespaceTable,
|
||||
recursivePredicate,
|
||||
boundExpression, partKeyExpression,
|
||||
classTable,
|
||||
)
|
||||
}
|
||||
|
||||
// pgTablePartitionStatus mirrors the native table_partition_status_core result.
|
||||
type pgTablePartitionStatus struct {
|
||||
IsPartitionedParent bool `json:"isPartitionedParent"`
|
||||
IsPartition bool `json:"isPartition"`
|
||||
}
|
||||
|
||||
// getTablePartitionStatus answers the cheap "does this table participate in
|
||||
// declarative partitioning?" probe without materializing the whole tree. It
|
||||
// keys the parent edge on relkind 'p' (as listTables does), so it works even on
|
||||
// releases whose catalog lacks relispartition, and a legacy INHERITS child is
|
||||
// never reported as a partition.
|
||||
func (s *server) getTablePartitionStatus(schema, table string) (pgTablePartitionStatus, error) {
|
||||
effective, err := s.effectiveSchema(schema)
|
||||
if err != nil {
|
||||
return pgTablePartitionStatus{}, err
|
||||
}
|
||||
catalog := "sys_catalog"
|
||||
if s.mode.postgresCatalog {
|
||||
catalog = "pg_catalog"
|
||||
}
|
||||
prefix := catalogPrefix(catalog)
|
||||
query := fmt.Sprintf(`SELECT c.relkind::text,
|
||||
EXISTS (
|
||||
SELECT 1 FROM %s.%s_inherits i
|
||||
JOIN %s.%s_class pc ON pc.oid = i.inhparent
|
||||
WHERE i.inhrelid = c.oid AND pc.relkind = 'p'
|
||||
)
|
||||
FROM %s.%s_class c
|
||||
JOIN %s.%s_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = %s AND c.relname = %s AND c.relkind IN ('r','p','f')`,
|
||||
catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(table))
|
||||
rows, err := s.metadataQuery(query)
|
||||
if err != nil {
|
||||
return pgTablePartitionStatus{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return pgTablePartitionStatus{}, rows.Err()
|
||||
}
|
||||
var relkind string
|
||||
var isPartition bool
|
||||
if err := rows.Scan(&relkind, &isPartition); err != nil {
|
||||
return pgTablePartitionStatus{}, err
|
||||
}
|
||||
return pgTablePartitionStatus{IsPartitionedParent: relkind == "p", IsPartition: isPartition}, nil
|
||||
}
|
||||
|
||||
func emptyPgTablePartitioning() pgTablePartitioning {
|
||||
return pgTablePartitioning{KeyColumns: []string{}, Partitions: []pgPartitionNode{}}
|
||||
}
|
||||
|
||||
func partitionKeyColumns(definition string) []string {
|
||||
start := strings.Index(definition, "(")
|
||||
if start < 0 {
|
||||
return []string{}
|
||||
}
|
||||
body, _, ok := takeParenGroup(definition[start:])
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
columns := make([]string, 0)
|
||||
for _, item := range splitTopLevelCommas(body) {
|
||||
if column, ok := simplePartitionKeyColumn(item); ok {
|
||||
columns = append(columns, column)
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func simplePartitionKeyColumn(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||
return strings.ReplaceAll(value[1:len(value)-1], "\"\"", "\""), true
|
||||
}
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 0 {
|
||||
if character != '_' && !unicode.IsLetter(character) {
|
||||
return "", false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if character != '_' && character != '$' && !unicode.IsLetter(character) && !unicode.IsDigit(character) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func partitionKindFromKeyDefinition(definition string) string {
|
||||
fields := strings.Fields(strings.TrimSpace(definition))
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "range", "list", "hash":
|
||||
return strings.ToLower(fields[0])
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildKingbasePartitionNodes(parentOID int64, children map[int64][]partitionRelation) []pgPartitionNode {
|
||||
relations := children[parentOID]
|
||||
sort.SliceStable(relations, func(i, j int) bool { return relations[i].Name < relations[j].Name })
|
||||
nodes := make([]pgPartitionNode, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
childNodes := buildKingbasePartitionNodes(relation.OID, children)
|
||||
node := pgPartitionNode{Schema: relation.Schema, Name: relation.Name, IsLeaf: len(childNodes) == 0, Children: childNodes}
|
||||
if relation.KeyDefinition.Valid {
|
||||
node.KeyDefinition = relation.KeyDefinition.String
|
||||
node.Strategy = partitionKindFromKeyDefinition(relation.KeyDefinition.String)
|
||||
}
|
||||
node.BoundDefinition = relation.Bound.String
|
||||
node.Bound = parseKingbasePartitionBound(relation.Bound)
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func parseKingbasePartitionBound(value sql.NullString) *pgPartitionBound {
|
||||
if !value.Valid || strings.TrimSpace(value.String) == "" {
|
||||
return nil
|
||||
}
|
||||
definition := strings.TrimSpace(value.String)
|
||||
if strings.EqualFold(definition, "DEFAULT") {
|
||||
return &pgPartitionBound{Kind: "default"}
|
||||
}
|
||||
rest, ok := stripASCIICasePrefix(definition, "FOR VALUES")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if body, matched := stripASCIICasePrefix(rest, "FROM"); matched {
|
||||
from, tail, ok := takeParenGroup(body)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
toBody, matched := stripASCIICasePrefix(strings.TrimSpace(tail), "TO")
|
||||
if !matched {
|
||||
return nil
|
||||
}
|
||||
to, _, ok := takeParenGroup(toBody)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &pgPartitionBound{Kind: "range", From: splitBoundItems(from), To: splitBoundItems(to)}
|
||||
}
|
||||
if body, matched := stripASCIICasePrefix(rest, "IN"); matched {
|
||||
values, _, ok := takeParenGroup(body)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &pgPartitionBound{Kind: "list", Values: splitBoundItems(values)}
|
||||
}
|
||||
if body, matched := stripASCIICasePrefix(rest, "WITH"); matched {
|
||||
options, _, ok := takeParenGroup(body)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
modulus, remainder := -1, -1
|
||||
for _, option := range splitTopLevelCommas(options) {
|
||||
fields := strings.Fields(option)
|
||||
if len(fields) != 2 {
|
||||
return nil
|
||||
}
|
||||
number, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "modulus":
|
||||
modulus = number
|
||||
case "remainder":
|
||||
remainder = number
|
||||
}
|
||||
}
|
||||
if modulus < 0 || remainder < 0 {
|
||||
return nil
|
||||
}
|
||||
return &pgPartitionBound{Kind: "hash", Modulus: &modulus, Remainder: &remainder}
|
||||
}
|
||||
// Keep the raw catalog definition when a Kingbase version renders a bound
|
||||
// shape that this parser does not recognize.
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripASCIICasePrefix removes a case-insensitive prefix, requiring a token
|
||||
// boundary so `IN` never matches `INTO` and `TO` never matches `TOAST`.
|
||||
func stripASCIICasePrefix(input, prefix string) (string, bool) {
|
||||
if len(input) < len(prefix) || !strings.EqualFold(input[:len(prefix)], prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := input[len(prefix):]
|
||||
if rest == "" {
|
||||
return rest, true
|
||||
}
|
||||
runeValue, _ := utf8.DecodeRuneInString(rest)
|
||||
if unicode.IsSpace(runeValue) || runeValue == '(' {
|
||||
return rest, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// takeParenGroup splits `( ... )` off the front of `input`, honoring single
|
||||
// quotes, doubled-single-quote escapes, double quotes, and nested parentheses.
|
||||
// It returns the inner text and the remainder after the closing parenthesis.
|
||||
func takeParenGroup(input string) (string, string, bool) {
|
||||
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
if input == "" || input[0] != '(' {
|
||||
return "", "", false
|
||||
}
|
||||
depth := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for index := 0; index < len(input); index++ {
|
||||
switch ch := input[index]; {
|
||||
case ch == '\'' && !inDouble:
|
||||
if inSingle && index+1 < len(input) && input[index+1] == '\'' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
inSingle = !inSingle
|
||||
case ch == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case ch == '(' && !inSingle && !inDouble:
|
||||
depth++
|
||||
case ch == ')' && !inSingle && !inDouble:
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return input[1:index], input[index+1:], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func splitBoundItems(input string) []string {
|
||||
items := []string{}
|
||||
for _, item := range splitTopLevelCommas(input) {
|
||||
if item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// splitTopLevelCommas splits on top-level commas only; commas inside quotes,
|
||||
// nested parentheses, or doubled-single-quote escapes stay in the item.
|
||||
func splitTopLevelCommas(input string) []string {
|
||||
items := []string{}
|
||||
var current strings.Builder
|
||||
depth := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for index := 0; index < len(input); index++ {
|
||||
switch ch := input[index]; {
|
||||
case ch == '\'' && !inDouble:
|
||||
if inSingle && index+1 < len(input) && input[index+1] == '\'' {
|
||||
current.WriteString("''")
|
||||
index++
|
||||
continue
|
||||
}
|
||||
inSingle = !inSingle
|
||||
current.WriteByte(ch)
|
||||
case ch == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
current.WriteByte(ch)
|
||||
case ch == '(' && !inSingle && !inDouble:
|
||||
depth++
|
||||
current.WriteByte(ch)
|
||||
case ch == ')' && !inSingle && !inDouble:
|
||||
depth--
|
||||
current.WriteByte(ch)
|
||||
case ch == ',' && !inSingle && !inDouble && depth == 0:
|
||||
items = append(items, strings.TrimSpace(current.String()))
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
items = append(items, strings.TrimSpace(current.String()))
|
||||
return items
|
||||
}
|
||||
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
||||
effective, err := s.effectiveSchema(schema)
|
||||
if err != nil {
|
||||
@@ -1107,7 +1644,10 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
|
||||
return nil, err
|
||||
}
|
||||
for _, table := range tables {
|
||||
result = append(result, objectInfo{Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment})
|
||||
result = append(result, objectInfo{
|
||||
Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment,
|
||||
ParentSchema: table.ParentSchema, ParentName: table.ParentName,
|
||||
})
|
||||
}
|
||||
}
|
||||
if constraintsAllowRoutines(constraints) {
|
||||
|
||||
@@ -423,6 +423,12 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
|
||||
case "get_type_details":
|
||||
result, err := s.getTypeDetails(stringParam(params, "schema"), stringParam(params, "name"))
|
||||
return result, false, err
|
||||
case "get_table_partition_status":
|
||||
result, err := s.getTablePartitionStatus(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "get_table_partitioning":
|
||||
result, err := s.getTablePartitioning(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "get_table_ddl":
|
||||
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -184,8 +185,8 @@ func (connection *fallbackConn) QueryContext(_ context.Context, query string, _
|
||||
}
|
||||
if strings.Contains(query, "CASE c.relkind") && strings.Contains(query, "obj_description(c.oid)") {
|
||||
return &valueRows{
|
||||
columns: []string{"table_name", "table_type", "table_comment"},
|
||||
rows: [][]driver.Value{{"orders", "BASE TABLE", "orders table"}},
|
||||
columns: []string{"table_name", "table_type", "table_comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "BASE TABLE", "orders table", nil, nil}},
|
||||
}, nil
|
||||
}
|
||||
if strings.Contains(query, "SELECT obj_description(c.oid)") {
|
||||
@@ -2119,24 +2120,25 @@ func TestListTablesPreservesKingbaseObjectTypesAndComments(t *testing.T) {
|
||||
postgresCatalog bool
|
||||
mysqlCompat bool
|
||||
wantCatalog string
|
||||
wantInherits string
|
||||
}{
|
||||
{name: "modern system catalog", wantCatalog: "sys_catalog.sys_class c"},
|
||||
{name: "PostgreSQL catalog", postgresCatalog: true, wantCatalog: "pg_catalog.pg_class c"},
|
||||
{name: "MySQL compatibility mode", mysqlCompat: true, wantCatalog: "sys_catalog.sys_class c"},
|
||||
{name: "modern system catalog", wantCatalog: "sys_catalog.sys_class c", wantInherits: "sys_catalog.sys_inherits i"},
|
||||
{name: "PostgreSQL catalog", postgresCatalog: true, wantCatalog: "pg_catalog.pg_class c", wantInherits: "pg_catalog.pg_inherits i"},
|
||||
{name: "MySQL compatibility mode", mysqlCompat: true, wantCatalog: "sys_catalog.sys_class c", wantInherits: "sys_catalog.sys_inherits i"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if !strings.Contains(query, "FROM "+test.wantCatalog) || !strings.Contains(query, "c.relkind IN ('r','p','v','m','f')") || !strings.Contains(query, "obj_description(c.oid)") {
|
||||
if !strings.Contains(query, "SELECT DISTINCT c.relname") || !strings.Contains(query, "FROM "+test.wantCatalog) || !strings.Contains(query, "LEFT JOIN "+test.wantInherits) || !strings.Contains(query, "c.relkind IN ('r','p','v','m','f')") || !strings.Contains(query, "obj_description(c.oid)") {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{
|
||||
{"orders", "TABLE", "orders table"},
|
||||
{"sales_view", "VIEW", nil},
|
||||
{"sales_cache", "MATERIALIZED_VIEW", "cached sales"},
|
||||
{"orders", "TABLE", "orders table", nil, nil},
|
||||
{"sales_view", "VIEW", nil, nil, nil},
|
||||
{"sales_cache", "MATERIALIZED_VIEW", "cached sales", nil, nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
@@ -2162,6 +2164,311 @@ func TestListTablesPreservesKingbaseObjectTypesAndComments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesAndObjectsPreservePartitionHierarchy(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
for _, fragment := range []string{
|
||||
"LEFT JOIN sys_catalog.sys_inherits i ON i.inhrelid = c.oid",
|
||||
"LEFT JOIN sys_catalog.sys_class pc ON pc.oid = i.inhparent",
|
||||
"LEFT JOIN sys_catalog.sys_namespace pn ON pn.oid = pc.relnamespace",
|
||||
"CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema",
|
||||
"CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name",
|
||||
} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
return nil, errors.New("partition query missing " + fragment + ": " + query)
|
||||
}
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{
|
||||
{"catalog_nested", "TABLE", nil, nil, nil},
|
||||
{"catalog_nested_2024", "TABLE", nil, "partition_demo", "catalog_nested"},
|
||||
{"catalog_nested_2024_asia", "TABLE", nil, "partition_demo", "catalog_nested_2024"},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
tables, err := server.listTables("partition_demo", metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tables) != 3 || tables[0].ParentName != nil || tables[1].ParentSchema == nil || *tables[1].ParentSchema != "partition_demo" || tables[1].ParentName == nil || *tables[1].ParentName != "catalog_nested" || tables[2].ParentName == nil || *tables[2].ParentName != "catalog_nested_2024" {
|
||||
t.Fatalf("unexpected partition table hierarchy: %#v", tables)
|
||||
}
|
||||
|
||||
objects, err := server.listObjects("partition_demo", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objects) != 3 || objects[1].ParentSchema == nil || *objects[1].ParentSchema != "partition_demo" || objects[1].ParentName == nil || *objects[1].ParentName != "catalog_nested" || objects[2].ParentName == nil || *objects[2].ParentName != "catalog_nested_2024" {
|
||||
t.Fatalf("object list lost partition hierarchy: %#v", objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitioningBuildsKingbaseHierarchy(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
for _, fragment := range []string{
|
||||
"WITH RECURSIVE tree AS",
|
||||
"sys_catalog.sys_inherits",
|
||||
"sys_catalog.sys_get_partkeydef",
|
||||
"sys_catalog.sys_get_expr",
|
||||
} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
return nil, errors.New("partition query missing " + fragment + ": " + query)
|
||||
}
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"oid", "schema_name", "table_name", "parent_oid", "parent_schema", "parent_name", "relkind", "partition_bound", "partition_key"},
|
||||
rows: [][]driver.Value{
|
||||
{int64(2), "public", "sales_default", int64(1), "public", "sales", "r", "DEFAULT", nil},
|
||||
{int64(1), "public", "sales", nil, nil, nil, "p", nil, "RANGE (id)"},
|
||||
{int64(3), "public", "sales_2024", int64(1), "public", "sales", "r", "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')", nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
partitioning, err := server.getTablePartitioning("public", "sales")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !partitioning.IsPartitioned || partitioning.IsPartition || partitioning.Strategy != "range" || partitioning.DefaultPartition != "sales_default" {
|
||||
t.Fatalf("unexpected parent partitioning: %#v", partitioning)
|
||||
}
|
||||
if len(partitioning.Partitions) != 2 || !partitioning.Partitions[0].IsLeaf || partitioning.Partitions[1].Name != "sales_default" || partitioning.Partitions[1].Bound == nil || partitioning.Partitions[1].Bound.Kind != "default" {
|
||||
t.Fatalf("unexpected partition nodes: %#v", partitioning.Partitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitioningGuardsInheritanceAndCycles(t *testing.T) {
|
||||
var captured string
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
captured = query
|
||||
return &valueRows{
|
||||
columns: []string{"oid", "schema_name", "table_name", "parent_oid", "parent_schema", "parent_name", "relkind", "partition_bound", "partition_key"},
|
||||
rows: [][]driver.Value{},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
if _, err := server.getTablePartitioning("public", "sales"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both the anchor and the recursive edge must be scoped to declarative
|
||||
// partitions so a legacy INHERITS child is never reported as a partition.
|
||||
if strings.Count(captured, "AND c.relispartition") != 2 {
|
||||
t.Fatalf("partition tree must scope both edges to declarative partitions: %s", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "NOT c.oid = ANY(tree.path)") {
|
||||
t.Fatalf("partition tree must carry a cycle guard: %s", captured)
|
||||
}
|
||||
if strings.Contains(captured, "c.relpartbound, c.oid, true") {
|
||||
t.Fatalf("partition tree must use the two-argument deparser verified on sys_catalog: %s", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitioningFallsBackWithoutRelispartition(t *testing.T) {
|
||||
var queries []string
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
queries = append(queries, query)
|
||||
if strings.Contains(query, "c.relispartition") {
|
||||
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "kb: column c.relispartition does not exist"}
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"oid", "schema_name", "table_name", "parent_oid", "parent_schema", "parent_name", "relkind", "partition_bound", "partition_key"},
|
||||
rows: [][]driver.Value{
|
||||
{int64(1), "public", "sales", nil, nil, nil, "p", nil, "RANGE (id)"},
|
||||
{int64(2), "public", "sales_2024", int64(1), "public", "sales", "r", "FOR VALUES FROM (MINVALUE) TO (MAXVALUE)", nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
partitioning, err := server.getTablePartitioning("public", "sales")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("expected exactly one retry after relispartition is rejected, got %d", len(queries))
|
||||
}
|
||||
fallback := queries[1]
|
||||
if !strings.Contains(fallback, "CASE WHEN pc.relkind = 'p'") || strings.Contains(fallback, "relispartition") {
|
||||
t.Fatalf("fallback query must key the parent edge on relkind: %s", fallback)
|
||||
}
|
||||
if !strings.Contains(fallback, "tree.relkind = 'p'") {
|
||||
t.Fatalf("fallback recursion must key on the parent relkind: %s", fallback)
|
||||
}
|
||||
if !partitioning.IsPartitioned || len(partitioning.Partitions) != 1 || partitioning.Partitions[0].Name != "sales_2024" {
|
||||
t.Fatalf("unexpected partitioning from fallback: %#v", partitioning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitioningFallsBackWithoutPartKeyFunction(t *testing.T) {
|
||||
var queries []string
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
queries = append(queries, query)
|
||||
if strings.Contains(query, "get_partkeydef") {
|
||||
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "kb: function sys_catalog.sys_get_partkeydef(bigint) does not exist"}
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"oid", "schema_name", "table_name", "parent_oid", "parent_schema", "parent_name", "relkind", "partition_bound", "partition_key"},
|
||||
rows: [][]driver.Value{
|
||||
{int64(1), "public", "sales", nil, nil, nil, "p", nil, nil},
|
||||
{int64(2), "public", "sales_default", int64(1), "public", "sales", "r", "DEFAULT", nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
partitioning, err := server.getTablePartitioning("public", "sales")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(queries) != 3 {
|
||||
t.Fatalf("expected the key function to be dropped last, got %d queries", len(queries))
|
||||
}
|
||||
if !partitioning.IsPartitioned || len(partitioning.Partitions) != 1 || partitioning.Partitions[0].Bound == nil || partitioning.Partitions[0].Bound.Kind != "default" {
|
||||
t.Fatalf("partition parent status or children were lost without the key function: %#v", partitioning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKingbasePartitionBoundReadsEveryShape(t *testing.T) {
|
||||
intP := func(value int) *int { return &value }
|
||||
nullable := func(value string) sql.NullString { return sql.NullString{String: value, Valid: true} }
|
||||
cases := []struct {
|
||||
definition string
|
||||
want *pgPartitionBound
|
||||
}{
|
||||
{"DEFAULT", &pgPartitionBound{Kind: "default"}},
|
||||
{" default ", &pgPartitionBound{Kind: "default"}},
|
||||
{
|
||||
"FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')",
|
||||
&pgPartitionBound{Kind: "range", From: []string{"'2024-01-01'"}, To: []string{"'2025-01-01'"}},
|
||||
},
|
||||
{
|
||||
"FOR VALUES FROM ('a', 'a') TO ('b', MAXVALUE)",
|
||||
&pgPartitionBound{Kind: "range", From: []string{"'a'", "'a'"}, To: []string{"'b'", "MAXVALUE"}},
|
||||
},
|
||||
{"FOR VALUES IN ('a,b', 'c')", &pgPartitionBound{Kind: "list", Values: []string{"'a,b'", "'c'"}}},
|
||||
{
|
||||
"FOR VALUES WITH (modulus 2, remainder 1)",
|
||||
&pgPartitionBound{Kind: "hash", Modulus: intP(2), Remainder: intP(1)},
|
||||
},
|
||||
{
|
||||
// remainder 0 must survive even though it is the zero value.
|
||||
"FOR VALUES WITH (MODULUS 4, REMAINDER 0)",
|
||||
&pgPartitionBound{Kind: "hash", Modulus: intP(4), Remainder: intP(0)},
|
||||
},
|
||||
}
|
||||
for _, test := range cases {
|
||||
if got := parseKingbasePartitionBound(nullable(test.definition)); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("parseKingbasePartitionBound(%q) = %#v, want %#v", test.definition, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, definition := range []string{"", "NOT A BOUND", "FOR VALUES INTO (1)"} {
|
||||
if got := parseKingbasePartitionBound(nullable(definition)); got != nil {
|
||||
t.Fatalf("parseKingbasePartitionBound(%q) = %#v, want nil", definition, got)
|
||||
}
|
||||
}
|
||||
if got := parseKingbasePartitionBound(sql.NullString{}); got != nil {
|
||||
t.Fatalf("a NULL bound must stay nil, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitioningParsesNodeBounds(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
return &valueRows{
|
||||
columns: []string{"oid", "schema_name", "table_name", "parent_oid", "parent_schema", "parent_name", "relkind", "partition_bound", "partition_key"},
|
||||
rows: [][]driver.Value{
|
||||
{int64(1), "public", "sales", nil, nil, nil, "p", nil, "RANGE (id)"},
|
||||
{int64(2), "public", "sales_2024", int64(1), "public", "sales", "r", "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')", nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
partitioning, err := server.getTablePartitioning("public", "sales")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(partitioning.Partitions) != 1 || partitioning.Partitions[0].Bound == nil || partitioning.Partitions[0].Bound.Kind != "range" {
|
||||
t.Fatalf("tree node bound was not parsed: %#v", partitioning.Partitions)
|
||||
}
|
||||
if partitioning.Partitions[0].BoundDefinition != "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')" {
|
||||
t.Fatalf("raw bound definition must be retained: %#v", partitioning.Partitions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTablePartitionStatusUsesLightweightQuery(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
relkind string
|
||||
exists bool
|
||||
wantPartitioned bool
|
||||
wantPartition bool
|
||||
}{
|
||||
{name: "partitioned parent", relkind: "p", exists: false, wantPartitioned: true},
|
||||
{name: "leaf partition", relkind: "r", exists: true, wantPartition: true},
|
||||
{name: "plain table", relkind: "r", exists: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var captured string
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
captured = query
|
||||
return &valueRows{
|
||||
columns: []string{"relkind", "is_partition"},
|
||||
rows: [][]driver.Value{{test.relkind, test.exists}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
result, shutdown, err := server.dispatch("get_table_partition_status", map[string]json.RawMessage{
|
||||
"schema": json.RawMessage(`"public"`),
|
||||
"table": json.RawMessage(`"sales"`),
|
||||
})
|
||||
if err != nil || shutdown {
|
||||
t.Fatalf("dispatch failed: shutdown=%v err=%v", shutdown, err)
|
||||
}
|
||||
if strings.Contains(captured, "WITH RECURSIVE") || !strings.Contains(captured, "EXISTS (") {
|
||||
t.Fatalf("status probe must not walk the whole partition tree: %s", captured)
|
||||
}
|
||||
status, ok := result.(pgTablePartitionStatus)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected result type: %#v", result)
|
||||
}
|
||||
if status.IsPartitionedParent != test.wantPartitioned || status.IsPartition != test.wantPartition {
|
||||
t.Fatalf("unexpected status: %#v", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionKeyColumnsExtractsSimpleColumnsAndSkipsExpressions(t *testing.T) {
|
||||
if got := partitionKeyColumns("RANGE (tenant_id, measured_at)"); !reflect.DeepEqual(got, []string{"tenant_id", "measured_at"}) {
|
||||
t.Fatalf("unexpected simple partition columns: %#v", got)
|
||||
}
|
||||
if got := partitionKeyColumns(`LIST ("region", lower(code), "display""name")`); !reflect.DeepEqual(got, []string{"region", `display"name`}) {
|
||||
t.Fatalf("unexpected mixed partition columns: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPartitioningUsesJSONArrays(t *testing.T) {
|
||||
encoded, err := json.Marshal(emptyPgTablePartitioning())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), `"keyColumns":null`) || strings.Contains(string(encoded), `"partitions":null`) {
|
||||
t.Fatalf("empty partitioning must serialize arrays, got %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesCachesMissingCatalogOIDCapability(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -2183,8 +2490,8 @@ func TestListTablesCachesMissingCatalogOIDCapability(t *testing.T) {
|
||||
return nil, errors.New("fallback must return a NULL comment: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "table_comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
columns: []string{"relname", "relkind", "table_comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil, nil, nil}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
@@ -2364,8 +2671,8 @@ func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil, nil, nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
@@ -2437,8 +2744,8 @@ func TestListObjectsFiltersSortsAndPagesMySQLCompatObjects(t *testing.T) {
|
||||
switch {
|
||||
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"match_table", "TABLE", nil}},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"match_table", "TABLE", nil, nil, nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "FROM information_schema.routines"):
|
||||
return &valueRows{
|
||||
@@ -2518,7 +2825,7 @@ func TestListObjectsSkipsMySQLCompatRoutineQueryForNonRoutineConstraints(t *test
|
||||
return nil, errors.New("non-routine request must not query routines: " + query)
|
||||
}
|
||||
if objectType == "TABLE" && strings.Contains(query, "FROM sys_catalog.sys_class c") {
|
||||
return &valueRows{columns: []string{"relname", "relkind", "comment"}}, nil
|
||||
return &valueRows{columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"}}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}}
|
||||
@@ -2663,8 +2970,8 @@ func TestListObjectsKeepsTablesWhenMySQLCompatRoutineQueryFails(t *testing.T) {
|
||||
switch {
|
||||
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", "orders table"}},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", "orders table", nil, nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "FROM information_schema.routines"):
|
||||
return nil, errors.New("routine catalog unavailable")
|
||||
@@ -2740,8 +3047,8 @@ func TestListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil, nil, nil}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
@@ -2790,8 +3097,8 @@ func TestListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) {
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil, nil, nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
|
||||
import { columnIndexMetadataRequestCurrent, columnIndexTableIdentity } from "@/lib/dataGrid/dataGridColumnIndexIcon";
|
||||
import { foreignKeyMetadataRequestCurrent, foreignKeyTableIdentity } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
|
||||
import type { ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database";
|
||||
import type { ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TriggerInfo } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
type SettingsStore = ReturnType<typeof import("@/stores/settingsStore").useSettingsStore>;
|
||||
@@ -55,6 +55,11 @@ interface TableMetadataState {
|
||||
constraintsLoaded: Ref<boolean>;
|
||||
constraintsLoading: Ref<boolean>;
|
||||
constraintsError: Ref<string>;
|
||||
partitioning: Ref<PgTablePartitioning | null>;
|
||||
partitioningLoaded: Ref<boolean>;
|
||||
partitioningLoading: Ref<boolean>;
|
||||
partitioningError: Ref<string>;
|
||||
partitioningRequestGeneration: Ref<number>;
|
||||
tableInfoColumnsRequestGeneration: Ref<number>;
|
||||
tableOwnerRequestGeneration: Ref<number>;
|
||||
indexesRequestGeneration: Ref<number>;
|
||||
@@ -248,6 +253,26 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPartitions(force = false) {
|
||||
const request = tableRequest();
|
||||
if (!request || state.partitioningLoading.value) return;
|
||||
if (state.partitioningLoaded.value && !force) return;
|
||||
const requestGeneration = ++state.partitioningRequestGeneration.value;
|
||||
state.partitioningLoading.value = true;
|
||||
state.partitioningError.value = "";
|
||||
try {
|
||||
const value = await api.getTablePartitioning(request.connectionId, request.database, request.schema, request.tableName);
|
||||
if (requestGeneration !== state.partitioningRequestGeneration.value) return;
|
||||
state.partitioning.value = value;
|
||||
state.partitioningLoaded.value = true;
|
||||
} catch (error: any) {
|
||||
if (requestGeneration !== state.partitioningRequestGeneration.value) return;
|
||||
state.partitioningError.value = String(error?.message || error);
|
||||
} finally {
|
||||
if (requestGeneration === state.partitioningRequestGeneration.value) state.partitioningLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConstraints() {
|
||||
const request = tableRequest();
|
||||
const requestIdentity = currentTableIdentity("constraints");
|
||||
@@ -286,6 +311,7 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
|
||||
fetchForeignKeys,
|
||||
fetchTriggers,
|
||||
fetchConstraints,
|
||||
fetchPartitions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4565,6 +4565,53 @@ export default {
|
||||
constraintDisabled: "Disabled",
|
||||
constraintNotValidated: "Not validated",
|
||||
triggers: "Triggers",
|
||||
partitions: "Partitions",
|
||||
partitionsEmpty: "This table is not partitioned",
|
||||
partitionsEmptyChildren: "This partitioned table has no partitions yet",
|
||||
partitionsParent: "Parent table",
|
||||
partitionsOwnBound: "Partition bound",
|
||||
partitionsDefault: "Default partition",
|
||||
partitionsRowEstimate: "Estimated rows: {count}",
|
||||
partitionsSize: "Size: {size}",
|
||||
partitionChildCount: "{count} sub-partition | {count} sub-partitions",
|
||||
partitionExpand: "Expand",
|
||||
partitionCollapse: "Collapse",
|
||||
partitionSubPartitionBadge: "Sub-partition",
|
||||
partitionMemberBadge: "Partition",
|
||||
partitionKindRange: "Range",
|
||||
partitionKindList: "List",
|
||||
partitionKindHash: "Hash",
|
||||
partitionBoundDefault: "Default",
|
||||
partitionEnable: "Enable partitioning",
|
||||
partitionKind: "Partition strategy",
|
||||
partitionKeyColumns: "Partition key columns",
|
||||
partitionKeyNoColumns: "Add columns first, or use an expression.",
|
||||
partitionKeyExpression: "Partition key expression (overrides columns)",
|
||||
partitionInitialPartitions: "Initial partitions",
|
||||
partitionInitialPartitionsEmpty: "No initial partitions; you can add them after the table is created.",
|
||||
partitionAdd: "Add Partition",
|
||||
partitionAttach: "Attach Partition",
|
||||
partitionDetach: "Detach",
|
||||
partitionDrop: "Drop",
|
||||
partitionDetachSelf: "Detach This Partition",
|
||||
partitionDetachConcurrently: "Detach concurrently",
|
||||
partitionDetachWarning: "Detaching keeps the partition's data in its own table; it stops being part of the parent.",
|
||||
partitionDropWarning: "Dropping a partition permanently deletes the partition table and all of its data.",
|
||||
partitionPendingOperations: "Pending partition operations (applied on save)",
|
||||
partitionRemoveOperation: "Remove",
|
||||
partitionName: "Partition name",
|
||||
partitionNamePlaceholder: "e.g. sales_2025",
|
||||
partitionBoundKind: "Bound type",
|
||||
partitionBoundFrom: "From",
|
||||
partitionBoundTo: "To",
|
||||
partitionBoundValues: "Values",
|
||||
partitionBoundModulus: "Modulus",
|
||||
partitionBoundRemainder: "Remainder",
|
||||
partitionRangeBoundInvalid: "RANGE needs a FROM and TO tuple with the same number of values.",
|
||||
partitionListBoundInvalid: "LIST needs at least one value.",
|
||||
partitionHashBoundInvalid: "HASH needs Modulus > 0 and 0 <= Remainder < Modulus.",
|
||||
partitionSqlPreview: "SQL to run",
|
||||
partitionNameRequired: "Enter a partition name.",
|
||||
ddl: "DDL",
|
||||
addColumn: "Add Column",
|
||||
copyColumns: "Copy Fields from Another Table",
|
||||
|
||||
@@ -4483,6 +4483,53 @@ export default withEnglishFallback({
|
||||
identitySeed: "Semilla",
|
||||
identityIncrement: "Incremento",
|
||||
sqlServerIdentityTypeHint: "La identidad de SQL Server solo admite tinyint, smallint, int, bigint y decimal/numeric con escala 0",
|
||||
partitions: "Particiones",
|
||||
partitionsEmpty: "La tabla actual no es una tabla particionada",
|
||||
partitionsEmptyChildren: "La tabla particionada actual aún no tiene particiones",
|
||||
partitionsParent: "Tabla padre",
|
||||
partitionsOwnBound: "Límite de partición",
|
||||
partitionsDefault: "Partición predeterminada",
|
||||
partitionsRowEstimate: "Filas estimadas: {count}",
|
||||
partitionsSize: "Tamaño: {size}",
|
||||
partitionChildCount: "{count} subparticiones",
|
||||
partitionExpand: "Expandir",
|
||||
partitionCollapse: "Contraer",
|
||||
partitionSubPartitionBadge: "Subpartición",
|
||||
partitionMemberBadge: "Partición",
|
||||
partitionKindRange: "Rango",
|
||||
partitionKindList: "Lista",
|
||||
partitionKindHash: "Hash",
|
||||
partitionBoundDefault: "Predeterminado",
|
||||
partitionEnable: "Habilitar particionado",
|
||||
partitionKind: "Estrategia de partición",
|
||||
partitionKeyColumns: "Columnas de clave de partición",
|
||||
partitionKeyNoColumns: "Agrega primero columnas, o usa una expresión.",
|
||||
partitionKeyExpression: "Expresión de clave de partición (tiene prioridad sobre las columnas)",
|
||||
partitionInitialPartitions: "Particiones iniciales",
|
||||
partitionInitialPartitionsEmpty: "No hay particiones iniciales; se pueden agregar después de crear la tabla.",
|
||||
partitionAdd: "Agregar partición",
|
||||
partitionAttach: "Adjuntar partición",
|
||||
partitionDetach: "Separar",
|
||||
partitionDrop: "Eliminar",
|
||||
partitionDetachSelf: "Separar esta partición",
|
||||
partitionDetachConcurrently: "Separar concurrentemente",
|
||||
partitionDetachWarning: "Después de separar, los datos de la partición permanecen en una tabla independiente, pero ya no pertenecen a la tabla padre.",
|
||||
partitionDropWarning: "Eliminar una partición eliminará permanentemente la tabla de partición y todos sus datos.",
|
||||
partitionPendingOperations: "Operaciones de partición pendientes (se ejecutan al guardar)",
|
||||
partitionRemoveOperation: "Quitar",
|
||||
partitionName: "Nombre de la partición",
|
||||
partitionNamePlaceholder: "p. ej., sales_2025",
|
||||
partitionBoundKind: "Tipo de límite",
|
||||
partitionBoundFrom: "Valor inicial",
|
||||
partitionBoundTo: "Valor final",
|
||||
partitionBoundValues: "Valores de partición",
|
||||
partitionBoundModulus: "Módulo",
|
||||
partitionBoundRemainder: "Resto",
|
||||
partitionRangeBoundInvalid: "RANGE requiere tuplas FROM y TO con la misma cantidad de valores.",
|
||||
partitionListBoundInvalid: "LIST requiere al menos un valor.",
|
||||
partitionHashBoundInvalid: "HASH requiere módulo > 0 y 0 <= resto < módulo.",
|
||||
partitionSqlPreview: "SQL que se ejecutará",
|
||||
partitionNameRequired: "Introduce el nombre de la partición.",
|
||||
},
|
||||
diagram: {
|
||||
title: "Diagrama de relaciones",
|
||||
|
||||
@@ -4480,6 +4480,53 @@ export default withEnglishFallback({
|
||||
identitySeed: "Seed",
|
||||
identityIncrement: "Incremento",
|
||||
sqlServerIdentityTypeHint: "Identity di SQL Server supporta solo tinyint, smallint, int, bigint e decimal/numeric con scala 0",
|
||||
partitions: "Partizioni",
|
||||
partitionsEmpty: "La tabella corrente non è una tabella partizionata",
|
||||
partitionsEmptyChildren: "La tabella partizionata corrente non ha ancora partizioni",
|
||||
partitionsParent: "Tabella padre",
|
||||
partitionsOwnBound: "Confine di partizione",
|
||||
partitionsDefault: "Partizione predefinita",
|
||||
partitionsRowEstimate: "Righe stimate: {count}",
|
||||
partitionsSize: "Dimensione: {size}",
|
||||
partitionChildCount: "{count} sottopartizioni",
|
||||
partitionExpand: "Espandi",
|
||||
partitionCollapse: "Comprimi",
|
||||
partitionSubPartitionBadge: "Sottopartizione",
|
||||
partitionMemberBadge: "Partizione",
|
||||
partitionKindRange: "Intervallo",
|
||||
partitionKindList: "Lista",
|
||||
partitionKindHash: "Hash",
|
||||
partitionBoundDefault: "Predefinito",
|
||||
partitionEnable: "Abilita partizionamento",
|
||||
partitionKind: "Strategia di partizionamento",
|
||||
partitionKeyColumns: "Colonne chiave di partizione",
|
||||
partitionKeyNoColumns: "Aggiungi prima dei campi o usa un'espressione.",
|
||||
partitionKeyExpression: "Espressione della chiave di partizione (prevale sui campi)",
|
||||
partitionInitialPartitions: "Partizioni iniziali",
|
||||
partitionInitialPartitionsEmpty: "Nessuna partizione iniziale; potrai aggiungerle dopo aver creato la tabella.",
|
||||
partitionAdd: "Aggiungi partizione",
|
||||
partitionAttach: "Collega partizione",
|
||||
partitionDetach: "Separa",
|
||||
partitionDrop: "Elimina",
|
||||
partitionDetachSelf: "Separa questa partizione",
|
||||
partitionDetachConcurrently: "Separa in modo concorrente",
|
||||
partitionDetachWarning: "Dopo la separazione, i dati della partizione rimarranno in una tabella indipendente, ma non faranno più parte della tabella padre.",
|
||||
partitionDropWarning: "L'eliminazione della partizione eliminerà definitivamente la tabella della partizione e tutti i suoi dati.",
|
||||
partitionPendingOperations: "Operazioni di partizione in attesa (eseguite al salvataggio)",
|
||||
partitionRemoveOperation: "Rimuovi",
|
||||
partitionName: "Nome partizione",
|
||||
partitionNamePlaceholder: "es. sales_2025",
|
||||
partitionBoundKind: "Tipo di confine",
|
||||
partitionBoundFrom: "Valore iniziale",
|
||||
partitionBoundTo: "Valore finale",
|
||||
partitionBoundValues: "Valori della partizione",
|
||||
partitionBoundModulus: "Modulo",
|
||||
partitionBoundRemainder: "Resto",
|
||||
partitionRangeBoundInvalid: "RANGE richiede tuple FROM e TO con lo stesso numero di valori.",
|
||||
partitionListBoundInvalid: "LIST richiede almeno un valore.",
|
||||
partitionHashBoundInvalid: "HASH richiede modulo > 0 e 0 <= resto < modulo.",
|
||||
partitionSqlPreview: "SQL da eseguire",
|
||||
partitionNameRequired: "Inserisci il nome della partizione.",
|
||||
},
|
||||
diagram: {
|
||||
title: "Diagramma delle Relazioni",
|
||||
|
||||
@@ -4546,6 +4546,53 @@ export default withEnglishFallback({
|
||||
addEnumValue: "値を追加",
|
||||
removeEnumValue: "列挙型の値を削除",
|
||||
enumValuePlaceholder: "値(空文字可)",
|
||||
partitions: "パーティション",
|
||||
partitionsEmpty: "現在のテーブルはパーティションテーブルではありません",
|
||||
partitionsEmptyChildren: "現在のパーティションテーブルにはまだパーティションがありません",
|
||||
partitionsParent: "親テーブル",
|
||||
partitionsOwnBound: "パーティション境界",
|
||||
partitionsDefault: "デフォルトパーティション",
|
||||
partitionsRowEstimate: "推定行数:{count}",
|
||||
partitionsSize: "サイズ:{size}",
|
||||
partitionChildCount: "{count} 個のサブパーティション",
|
||||
partitionExpand: "展開",
|
||||
partitionCollapse: "折りたたみ",
|
||||
partitionSubPartitionBadge: "サブパーティション",
|
||||
partitionMemberBadge: "パーティション",
|
||||
partitionKindRange: "範囲",
|
||||
partitionKindList: "リスト",
|
||||
partitionKindHash: "ハッシュ",
|
||||
partitionBoundDefault: "デフォルト",
|
||||
partitionEnable: "パーティションを有効にする",
|
||||
partitionKind: "パーティション戦略",
|
||||
partitionKeyColumns: "パーティションキー列",
|
||||
partitionKeyNoColumns: "まずフィールドを追加するか、式を使用してください。",
|
||||
partitionKeyExpression: "パーティションキー式(フィールドより優先)",
|
||||
partitionInitialPartitions: "初期パーティション",
|
||||
partitionInitialPartitionsEmpty: "初期パーティションはありません。テーブル作成後に追加できます。",
|
||||
partitionAdd: "パーティションを追加",
|
||||
partitionAttach: "パーティションをアタッチ",
|
||||
partitionDetach: "デタッチ",
|
||||
partitionDrop: "削除",
|
||||
partitionDetachSelf: "このパーティションをデタッチ",
|
||||
partitionDetachConcurrently: "同時デタッチ",
|
||||
partitionDetachWarning: "デタッチ後、パーティションデータは独立したテーブルに残りますが、親テーブルには属さなくなります。",
|
||||
partitionDropWarning: "パーティションを削除すると、そのパーティションテーブルとすべてのデータが完全に削除されます。",
|
||||
partitionPendingOperations: "適用待ちのパーティション操作(保存時に実行)",
|
||||
partitionRemoveOperation: "削除",
|
||||
partitionName: "パーティション名",
|
||||
partitionNamePlaceholder: "例:sales_2025",
|
||||
partitionBoundKind: "境界タイプ",
|
||||
partitionBoundFrom: "開始値",
|
||||
partitionBoundTo: "終了値",
|
||||
partitionBoundValues: "パーティション値",
|
||||
partitionBoundModulus: "モジュラス",
|
||||
partitionBoundRemainder: "剰余",
|
||||
partitionRangeBoundInvalid: "RANGE には FROM と TO のタプルが必要で、値の数が同じである必要があります。",
|
||||
partitionListBoundInvalid: "LIST には少なくとも1つの値が必要です。",
|
||||
partitionHashBoundInvalid: "HASH にはモジュラス > 0 が必要で、0 <= 剰余 < モジュラス である必要があります。",
|
||||
partitionSqlPreview: "実行される SQL",
|
||||
partitionNameRequired: "パーティション名を入力してください。",
|
||||
},
|
||||
diagram: {
|
||||
title: "リレーション図",
|
||||
|
||||
@@ -4467,6 +4467,53 @@ export default withEnglishFallback({
|
||||
identitySeed: "시드",
|
||||
identityIncrement: "증가분",
|
||||
sqlServerIdentityTypeHint: "SQL Server ID는 tinyint, smallint, int, bigint 및 스케일이 0인 decimal/numeric만 지원합니다",
|
||||
partitions: "파티션",
|
||||
partitionsEmpty: "현재 테이블은 파티션 테이블이 아닙니다",
|
||||
partitionsEmptyChildren: "현재 파티션 테이블에는 아직 파티션이 없습니다",
|
||||
partitionsParent: "부모 테이블",
|
||||
partitionsOwnBound: "파티션 경계",
|
||||
partitionsDefault: "기본 파티션",
|
||||
partitionsRowEstimate: "예상 행 수: {count}",
|
||||
partitionsSize: "크기: {size}",
|
||||
partitionChildCount: "{count}개의 하위 파티션",
|
||||
partitionExpand: "펼치기",
|
||||
partitionCollapse: "접기",
|
||||
partitionSubPartitionBadge: "하위 파티션",
|
||||
partitionMemberBadge: "파티션",
|
||||
partitionKindRange: "범위",
|
||||
partitionKindList: "목록",
|
||||
partitionKindHash: "해시",
|
||||
partitionBoundDefault: "기본",
|
||||
partitionEnable: "파티션 활성화",
|
||||
partitionKind: "파티션 전략",
|
||||
partitionKeyColumns: "파티션 키 필드",
|
||||
partitionKeyNoColumns: "먼저 필드를 추가하거나 표현식을 사용하세요.",
|
||||
partitionKeyExpression: "파티션 키 표현식(필드보다 우선)",
|
||||
partitionInitialPartitions: "초기 파티션",
|
||||
partitionInitialPartitionsEmpty: "초기 파티션이 없습니다. 테이블 생성 후 추가할 수 있습니다.",
|
||||
partitionAdd: "파티션 추가",
|
||||
partitionAttach: "파티션 연결",
|
||||
partitionDetach: "분리",
|
||||
partitionDrop: "삭제",
|
||||
partitionDetachSelf: "이 파티션 분리",
|
||||
partitionDetachConcurrently: "동시 분리",
|
||||
partitionDetachWarning: "분리 후에도 파티션 데이터는 독립 테이블에 유지되지만, 더 이상 부모 테이블에 속하지 않습니다.",
|
||||
partitionDropWarning: "파티션을 삭제하면 해당 파티션 테이블과 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
partitionPendingOperations: "적용 대기 중인 파티션 작업(저장 시 실행)",
|
||||
partitionRemoveOperation: "제거",
|
||||
partitionName: "파티션 이름",
|
||||
partitionNamePlaceholder: "예: sales_2025",
|
||||
partitionBoundKind: "경계 유형",
|
||||
partitionBoundFrom: "시작 값",
|
||||
partitionBoundTo: "종료 값",
|
||||
partitionBoundValues: "파티션 값",
|
||||
partitionBoundModulus: "모듈러스",
|
||||
partitionBoundRemainder: "나머지",
|
||||
partitionRangeBoundInvalid: "RANGE에는 FROM과 TO 튜플이 필요하며, 값 개수가 같아야 합니다.",
|
||||
partitionListBoundInvalid: "LIST에는 값이 하나 이상 필요합니다.",
|
||||
partitionHashBoundInvalid: "HASH에는 모듈러스 > 0이 필요하며, 0 <= 나머지 < 모듈러스여야 합니다.",
|
||||
partitionSqlPreview: "실행될 SQL",
|
||||
partitionNameRequired: "파티션 이름을 입력하세요.",
|
||||
},
|
||||
diagram: {
|
||||
title: "관계 다이어그램",
|
||||
|
||||
@@ -4482,6 +4482,53 @@ export default withEnglishFallback({
|
||||
identitySeed: "Valor inicial",
|
||||
identityIncrement: "Incremento",
|
||||
sqlServerIdentityTypeHint: "A identidade do SQL Server só aceita tinyint, smallint, int, bigint e decimal/numeric com escala 0",
|
||||
partitions: "Partições",
|
||||
partitionsEmpty: "A tabela atual não é uma tabela particionada",
|
||||
partitionsEmptyChildren: "A tabela particionada atual ainda não tem partições",
|
||||
partitionsParent: "Tabela pai",
|
||||
partitionsOwnBound: "Limite da partição",
|
||||
partitionsDefault: "Partição padrão",
|
||||
partitionsRowEstimate: "Estimativa de linhas: {count}",
|
||||
partitionsSize: "Tamanho: {size}",
|
||||
partitionChildCount: "{count} subpartições",
|
||||
partitionExpand: "Expandir",
|
||||
partitionCollapse: "Recolher",
|
||||
partitionSubPartitionBadge: "Subpartição",
|
||||
partitionMemberBadge: "Partição",
|
||||
partitionKindRange: "RANGE",
|
||||
partitionKindList: "LIST",
|
||||
partitionKindHash: "HASH",
|
||||
partitionBoundDefault: "DEFAULT",
|
||||
partitionEnable: "Ativar particionamento",
|
||||
partitionKind: "Estratégia de particionamento",
|
||||
partitionKeyColumns: "Colunas da chave de partição",
|
||||
partitionKeyNoColumns: "Adicione campos primeiro ou use uma expressão.",
|
||||
partitionKeyExpression: "Expressão da chave de partição (tem prioridade sobre campos)",
|
||||
partitionInitialPartitions: "Partições iniciais",
|
||||
partitionInitialPartitionsEmpty: "Nenhuma partição inicial; você pode adicioná-las após criar a tabela.",
|
||||
partitionAdd: "Adicionar partição",
|
||||
partitionAttach: "Anexar partição",
|
||||
partitionDetach: "Desanexar",
|
||||
partitionDrop: "Excluir",
|
||||
partitionDetachSelf: "Desanexar esta partição",
|
||||
partitionDetachConcurrently: "Desanexar concorrentemente",
|
||||
partitionDetachWarning: "Após desanexar, os dados da partição permanecem em uma tabela independente, mas ela não pertence mais à tabela pai.",
|
||||
partitionDropWarning: "Excluir a partição removerá permanentemente a tabela de partição e todos os seus dados.",
|
||||
partitionPendingOperations: "Operações de partição pendentes (executadas ao salvar)",
|
||||
partitionRemoveOperation: "Remover",
|
||||
partitionName: "Nome da partição",
|
||||
partitionNamePlaceholder: "ex.: sales_2025",
|
||||
partitionBoundKind: "Tipo de limite",
|
||||
partitionBoundFrom: "Valor inicial",
|
||||
partitionBoundTo: "Valor final",
|
||||
partitionBoundValues: "Valores da partição",
|
||||
partitionBoundModulus: "Módulo",
|
||||
partitionBoundRemainder: "Resto",
|
||||
partitionRangeBoundInvalid: "RANGE requer tuplas FROM e TO com a mesma quantidade de valores.",
|
||||
partitionListBoundInvalid: "LIST requer pelo menos um valor.",
|
||||
partitionHashBoundInvalid: "HASH requer módulo > 0 e 0 <= resto < módulo.",
|
||||
partitionSqlPreview: "SQL a ser executado",
|
||||
partitionNameRequired: "Digite o nome da partição.",
|
||||
},
|
||||
diagram: {
|
||||
title: "Diagrama de relacionamentos",
|
||||
|
||||
@@ -4539,6 +4539,53 @@ export default withEnglishFallback({
|
||||
constraintDisabled: "已禁用",
|
||||
constraintNotValidated: "未验证",
|
||||
triggers: "触发器",
|
||||
partitions: "分区",
|
||||
partitionsEmpty: "当前表不是分区表",
|
||||
partitionsEmptyChildren: "当前分区表还没有分区",
|
||||
partitionsParent: "父表",
|
||||
partitionsOwnBound: "分区边界",
|
||||
partitionsDefault: "默认分区",
|
||||
partitionsRowEstimate: "估计行数:{count}",
|
||||
partitionsSize: "大小:{size}",
|
||||
partitionChildCount: "{count} 个子分区",
|
||||
partitionExpand: "展开",
|
||||
partitionCollapse: "折叠",
|
||||
partitionSubPartitionBadge: "子分区",
|
||||
partitionMemberBadge: "分区",
|
||||
partitionKindRange: "范围",
|
||||
partitionKindList: "列表",
|
||||
partitionKindHash: "哈希",
|
||||
partitionBoundDefault: "默认",
|
||||
partitionEnable: "启用分区",
|
||||
partitionKind: "分区策略",
|
||||
partitionKeyColumns: "分区键字段",
|
||||
partitionKeyNoColumns: "请先添加字段,或使用表达式。",
|
||||
partitionKeyExpression: "分区键表达式(优先于字段)",
|
||||
partitionInitialPartitions: "初始分区",
|
||||
partitionInitialPartitionsEmpty: "暂无初始分区;可在建表后添加。",
|
||||
partitionAdd: "新增分区",
|
||||
partitionAttach: "附加分区",
|
||||
partitionDetach: "分离",
|
||||
partitionDrop: "删除",
|
||||
partitionDetachSelf: "分离此分区",
|
||||
partitionDetachConcurrently: "并发分离",
|
||||
partitionDetachWarning: "分离后分区数据仍保留在独立表中,但不再属于父表。",
|
||||
partitionDropWarning: "删除分区会永久删除该分区表及其全部数据。",
|
||||
partitionPendingOperations: "待应用的分区操作(保存时执行)",
|
||||
partitionRemoveOperation: "移除",
|
||||
partitionName: "分区名称",
|
||||
partitionNamePlaceholder: "例如 sales_2025",
|
||||
partitionBoundKind: "边界类型",
|
||||
partitionBoundFrom: "起始值",
|
||||
partitionBoundTo: "结束值",
|
||||
partitionBoundValues: "分区值",
|
||||
partitionBoundModulus: "模数",
|
||||
partitionBoundRemainder: "余数",
|
||||
partitionRangeBoundInvalid: "RANGE 需要 FROM 与 TO 元组,且值数量相同。",
|
||||
partitionListBoundInvalid: "LIST 至少需要一个值。",
|
||||
partitionHashBoundInvalid: "HASH 需要模数 > 0,且 0 <= 余数 < 模数。",
|
||||
partitionSqlPreview: "将执行的 SQL",
|
||||
partitionNameRequired: "请输入分区名称。",
|
||||
ddl: "DDL",
|
||||
addColumn: "新增字段",
|
||||
copyColumns: "复制其他表字段",
|
||||
|
||||
@@ -4220,6 +4220,53 @@ export default withEnglishFallback({
|
||||
charsetPlaceholder: "字元集",
|
||||
collationPlaceholder: "排序規則",
|
||||
collation: "排序規則",
|
||||
partitions: "分割區",
|
||||
partitionsEmpty: "目前資料表不是分割資料表",
|
||||
partitionsEmptyChildren: "目前分割資料表還沒有分割區",
|
||||
partitionsParent: "父資料表",
|
||||
partitionsOwnBound: "分割區邊界",
|
||||
partitionsDefault: "預設分割區",
|
||||
partitionsRowEstimate: "估計資料列數:{count}",
|
||||
partitionsSize: "大小:{size}",
|
||||
partitionChildCount: "{count} 個子分割區",
|
||||
partitionExpand: "展開",
|
||||
partitionCollapse: "摺疊",
|
||||
partitionSubPartitionBadge: "子分割區",
|
||||
partitionMemberBadge: "分割區",
|
||||
partitionKindRange: "範圍",
|
||||
partitionKindList: "清單",
|
||||
partitionKindHash: "雜湊",
|
||||
partitionBoundDefault: "預設",
|
||||
partitionEnable: "啟用分割",
|
||||
partitionKind: "分割策略",
|
||||
partitionKeyColumns: "分割索引鍵欄位",
|
||||
partitionKeyNoColumns: "請先新增欄位,或使用運算式。",
|
||||
partitionKeyExpression: "分割索引鍵運算式(優先於欄位)",
|
||||
partitionInitialPartitions: "初始分割區",
|
||||
partitionInitialPartitionsEmpty: "尚無初始分割區;可在建立資料表後新增。",
|
||||
partitionAdd: "新增分割區",
|
||||
partitionAttach: "附加分割區",
|
||||
partitionDetach: "分離",
|
||||
partitionDrop: "刪除",
|
||||
partitionDetachSelf: "分離此分割區",
|
||||
partitionDetachConcurrently: "並行分離",
|
||||
partitionDetachWarning: "分離後,分割區資料仍會保留在獨立資料表中,但不再屬於父資料表。",
|
||||
partitionDropWarning: "刪除分割區會永久刪除該分割區資料表及其全部資料。",
|
||||
partitionPendingOperations: "待套用的分割區操作(儲存時執行)",
|
||||
partitionRemoveOperation: "移除",
|
||||
partitionName: "分割區名稱",
|
||||
partitionNamePlaceholder: "例如 sales_2025",
|
||||
partitionBoundKind: "邊界類型",
|
||||
partitionBoundFrom: "起始值",
|
||||
partitionBoundTo: "結束值",
|
||||
partitionBoundValues: "分割區值",
|
||||
partitionBoundModulus: "模數",
|
||||
partitionBoundRemainder: "餘數",
|
||||
partitionRangeBoundInvalid: "RANGE 需要 FROM 與 TO 元組,且值數量相同。",
|
||||
partitionListBoundInvalid: "LIST 至少需要一個值。",
|
||||
partitionHashBoundInvalid: "HASH 需要模數 > 0,且 0 <= 餘數 < 模數。",
|
||||
partitionSqlPreview: "將執行的 SQL",
|
||||
partitionNameRequired: "請輸入分割區名稱。",
|
||||
},
|
||||
diagram: {
|
||||
title: "關係圖",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { flattenPgPartitionNodes, pgPartitionBoundText, pgPartitionKindLabelKey, pgPartitionNodeBoundText, pgPartitionRowHint, splitPgPartitionBoundValues, visiblePgPartitionRows } from "@/lib/table/pgPartitionPresentation";
|
||||
import type { PgPartitionNode } from "@/types/database";
|
||||
|
||||
function node(name: string, children: PgPartitionNode[] = []): PgPartitionNode {
|
||||
return { schema: "public", name, isLeaf: children.length === 0, children };
|
||||
}
|
||||
|
||||
describe("pgPartitionPresentation", () => {
|
||||
it("flattens a multi-level tree depth-first with nesting depth", () => {
|
||||
const tree = [node("logs", [node("logs_2024", [node("logs_2024_us")])]), node("logs_default")];
|
||||
|
||||
expect(flattenPgPartitionNodes(tree).map(({ depth, node: rowNode }) => `${depth}:${rowNode.name}`)).toEqual(["0:logs", "1:logs_2024", "2:logs_2024_us", "0:logs_default"]);
|
||||
});
|
||||
|
||||
it("carries the direct parent identity for nested partition operations", () => {
|
||||
const rows = flattenPgPartitionNodes([node("logs_2024", [node("logs_2024_cn")])], { schema: "public", name: "logs" });
|
||||
|
||||
expect(rows.map((row) => ({ name: row.node.name, parentSchema: row.parentSchema, parentName: row.parentName }))).toEqual([
|
||||
{ name: "logs_2024", parentSchema: "public", parentName: "logs" },
|
||||
{ name: "logs_2024_cn", parentSchema: "public", parentName: "logs_2024" },
|
||||
]);
|
||||
});
|
||||
it("gives every row a unique key", () => {
|
||||
const rows = flattenPgPartitionNodes([node("a"), node("b", [node("b1")])]);
|
||||
expect(new Set(rows.map((row) => row.key)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("tracks depth and the ancestor key chain used to fold and indent a parent", () => {
|
||||
const rows = flattenPgPartitionNodes([node("p1", [node("p1a", [node("p1a1")])]), node("p2")]);
|
||||
|
||||
expect(rows.map((row) => [row.node.name, row.depth, row.ancestorKeys])).toEqual([
|
||||
["p1", 0, []],
|
||||
["p1a", 1, ["public.p1"]],
|
||||
["p1a1", 2, ["public.p1", "public.p1/public.p1a"]],
|
||||
["p2", 0, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides every descendant of a collapsed parent", () => {
|
||||
const rows = flattenPgPartitionNodes([node("p1", [node("p1a"), node("p1b")]), node("p2")]);
|
||||
|
||||
expect(visiblePgPartitionRows(rows, new Set()).map((row) => row.node.name)).toEqual(["p1", "p1a", "p1b", "p2"]);
|
||||
expect(visiblePgPartitionRows(rows, new Set(["public.p1"])).map((row) => row.node.name)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("builds a row hover hint from the size and count estimates", () => {
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key}(${JSON.stringify(params)})`;
|
||||
const bytes = (value: number) => `${value}B`;
|
||||
|
||||
expect(pgPartitionRowHint({ ...node("p"), rowEstimate: 366, totalBytes: 1048576 }, t, bytes)).toBe('structureEditor.partitionsRowEstimate({"count":366}) · structureEditor.partitionsSize({"size":"1048576B"})');
|
||||
expect(pgPartitionRowHint({ ...node("p"), rowEstimate: 5 }, t, bytes)).toBe('structureEditor.partitionsRowEstimate({"count":5})');
|
||||
expect(pgPartitionRowHint({ ...node("p"), totalBytes: 10 }, t, bytes)).toBe('structureEditor.partitionsSize({"size":"10B"})');
|
||||
// No estimates -> no tooltip at all.
|
||||
expect(pgPartitionRowHint(node("p"), t, bytes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders each bound kind as its SQL fragment", () => {
|
||||
expect(pgPartitionBoundText({ kind: "range", from: ["'2024-01-01'"], to: ["'2025-01-01'"] })).toBe("FROM ('2024-01-01') TO ('2025-01-01')");
|
||||
expect(pgPartitionBoundText({ kind: "range", from: ["MINVALUE", "'a'"], to: ["MAXVALUE", "'b'"] })).toBe("FROM (MINVALUE, 'a') TO (MAXVALUE, 'b')");
|
||||
expect(pgPartitionBoundText({ kind: "list", values: ["'a'", "'b'"] })).toBe("IN ('a', 'b')");
|
||||
expect(pgPartitionBoundText({ kind: "hash", modulus: 2, remainder: 1 })).toBe("MODULUS 2 REMAINDER 1");
|
||||
expect(pgPartitionBoundText({ kind: "default" })).toBe("DEFAULT");
|
||||
expect(pgPartitionBoundText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the raw catalog definition when the bound was not parsed", () => {
|
||||
expect(pgPartitionNodeBoundText({ ...node("p"), boundDefinition: "FOR VALUES IN (1)" })).toBe("FOR VALUES IN (1)");
|
||||
expect(pgPartitionNodeBoundText({ ...node("p"), bound: { kind: "default" }, boundDefinition: "DEFAULT" })).toBe("DEFAULT");
|
||||
expect(pgPartitionNodeBoundText(node("p"))).toBe("");
|
||||
});
|
||||
|
||||
it("splits bound values on top-level commas only", () => {
|
||||
expect(splitPgPartitionBoundValues("1, 2, 3")).toEqual(["1", "2", "3"]);
|
||||
expect(splitPgPartitionBoundValues("'a,b', 'c'")).toEqual(["'a,b'", "'c'"]);
|
||||
expect(splitPgPartitionBoundValues("lower('A''B'), lower('C')")).toEqual(["lower('A''B')", "lower('C')"]);
|
||||
expect(splitPgPartitionBoundValues(" '2024-01-01' ")).toEqual(["'2024-01-01'"]);
|
||||
expect(splitPgPartitionBoundValues("")).toEqual([]);
|
||||
expect(splitPgPartitionBoundValues(" , ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps strategies to translation keys", () => {
|
||||
expect(pgPartitionKindLabelKey("range")).toBe("structureEditor.partitionKindRange");
|
||||
expect(pgPartitionKindLabelKey("list")).toBe("structureEditor.partitionKindList");
|
||||
expect(pgPartitionKindLabelKey("hash")).toBe("structureEditor.partitionKindHash");
|
||||
expect(pgPartitionKindLabelKey(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+16
-9
@@ -3,12 +3,13 @@ import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visib
|
||||
|
||||
describe("table structure metadata loading", () => {
|
||||
it.each([
|
||||
["columns", { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
|
||||
["indexes", { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
|
||||
["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true }],
|
||||
["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true }],
|
||||
["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }],
|
||||
["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }],
|
||||
["columns", { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
|
||||
["indexes", { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
|
||||
["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, partitions: false, tableComment: true }],
|
||||
["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: true }],
|
||||
["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: true }],
|
||||
["partitions", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false }],
|
||||
["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true }],
|
||||
] as const)("requests only the metadata required by the %s tab", (tab, expected) => {
|
||||
expect(visibleTableStructureRefreshScope(tab)).toEqual(expected);
|
||||
});
|
||||
@@ -16,18 +17,24 @@ describe("table structure metadata loading", () => {
|
||||
it("requests only index metadata after columns and comments are already loaded", () => {
|
||||
const scope = unloadedTableStructureRefreshScope("indexes", new Set(["columns", "comment"]));
|
||||
|
||||
expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: false });
|
||||
expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: false });
|
||||
expect(hasTableStructureRefreshWork(scope)).toBe(true);
|
||||
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("indexes", new Set(["columns", "indexes", "comment"])))).toBe(false);
|
||||
});
|
||||
|
||||
it("requests trigger metadata only until that facet is loaded", () => {
|
||||
expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: false });
|
||||
expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: false });
|
||||
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("triggers", new Set(["triggers", "comment"])))).toBe(false);
|
||||
});
|
||||
|
||||
it("requests constraint metadata only until that facet is loaded", () => {
|
||||
expect(unloadedTableStructureRefreshScope("constraints", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: false });
|
||||
expect(unloadedTableStructureRefreshScope("constraints", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: false });
|
||||
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("constraints", new Set(["constraints", "comment"])))).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks the partitions facet through the loaded set so re-activating the tab does not refetch", () => {
|
||||
expect(unloadedTableStructureRefreshScope("partitions", new Set())).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false });
|
||||
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("partitions", new Set()))).toBe(true);
|
||||
expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("partitions", new Set(["partitions"])))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -518,4 +518,26 @@ describe("TDengine table hierarchy", () => {
|
||||
|
||||
expect(nodes[0].children?.[0]).toMatchObject({ label: "tree.partitions", isExpanded: false });
|
||||
});
|
||||
|
||||
it("nests second-level partitions under Kingbase-style lowercase parent names", () => {
|
||||
// KingbaseES returns lowercase relation names and a direct `parent_name`;
|
||||
// a partition that is itself a partitioned parent must nest recursively.
|
||||
const nodes = buildTableTreeNodes({
|
||||
...context,
|
||||
schema: "partition_demo",
|
||||
tables: [
|
||||
{ name: "catalog_nested", table_type: "BASE TABLE", comment: null },
|
||||
{ name: "catalog_nested_2024", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
|
||||
{ name: "catalog_nested_2024_asia", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
|
||||
{ name: "catalog_nested_2024_eu", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
|
||||
{ name: "catalog_nested_2025", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(nodes.map((node) => node.label)).toEqual(["catalog_nested"]);
|
||||
const level1 = tablePartitionGroups(nodes[0])[0].children ?? [];
|
||||
expect(level1.map((node) => node.label)).toEqual(["catalog_nested_2024", "catalog_nested_2025"]);
|
||||
const yearPartition = level1.find((node) => node.label === "catalog_nested_2024");
|
||||
expect(tablePartitionGroups(yearPartition!)[0].children?.map((node) => node.label)).toEqual(["catalog_nested_2024_asia", "catalog_nested_2024_eu"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildCreatePartitionedTableSql, buildTablePartitionOperationSql, getTablePartitioning } from "@/lib/backend/http";
|
||||
|
||||
describe("PostgreSQL table partitioning web API", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads the structured partitioning view from the schema endpoint", async () => {
|
||||
const payload = {
|
||||
isPartitioned: true,
|
||||
isPartition: false,
|
||||
strategy: "range",
|
||||
keyDefinition: "RANGE (sold_on)",
|
||||
keyColumns: ["sold_on"],
|
||||
partitions: [{ schema: "public", name: "sales_2024", isLeaf: true, children: [] }],
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getTablePartitioning("connection 1", "sales/db", "public", "order items")).resolves.toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/schema/table-partitioning?connection_id=connection+1&database=sales%2Fdb&schema=public&table=order+items");
|
||||
});
|
||||
|
||||
it("builds partition maintenance SQL through the query endpoint", async () => {
|
||||
const options = {
|
||||
databaseType: "postgres" as const,
|
||||
schema: "public",
|
||||
tableName: "sales",
|
||||
operations: [
|
||||
{
|
||||
id: "op:1",
|
||||
kind: "create" as const,
|
||||
parentSchema: "",
|
||||
parentTable: "",
|
||||
schema: "",
|
||||
name: "sales_2025",
|
||||
bound: { kind: "default" as const },
|
||||
concurrently: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const payload = {
|
||||
statements: ['CREATE TABLE "public"."sales_2025" PARTITION OF "public"."sales" DEFAULT;'],
|
||||
warnings: [],
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(buildTablePartitionOperationSql(options)).resolves.toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/query/build-table-partition-operation-sql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ options }),
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a partitioned CREATE TABLE through the query endpoint", async () => {
|
||||
const options = { databaseType: "postgres" as const, schema: "public", tableName: "sales", columns: [] } as never;
|
||||
const partitioning = { kind: "range" as const, columns: ["sold_on"], expression: "" };
|
||||
const payload = { statements: ['CREATE TABLE "public"."sales" (\n "sold_on" date\n) PARTITION BY RANGE ("sold_on");'], warnings: [] };
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(buildCreatePartitionedTableSql({ options, partitioning })).resolves.toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/query/build-create-partitioned-table-sql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ options, partitioning }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,7 @@ export const listTriggers = forward("listTriggers");
|
||||
export const listConstraints = forward("listConstraints");
|
||||
export const listPartitions = forward("listPartitions");
|
||||
export const getTablePartitionStatus = forward("getTablePartitionStatus");
|
||||
export const getTablePartitioning = forward("getTablePartitioning");
|
||||
export const listInvalidIndexes = forward("listInvalidIndexes");
|
||||
export const listSubpartitions = forward("listSubpartitions");
|
||||
export const getTableDdl = forward("getTableDdl");
|
||||
@@ -299,6 +300,8 @@ export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRen
|
||||
export const buildViewDdlSql = forward("buildViewDdlSql");
|
||||
export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql");
|
||||
export const buildTableOwnerChangeSql = forward("buildTableOwnerChangeSql");
|
||||
export const buildTablePartitionOperationSql = forward("buildTablePartitionOperationSql");
|
||||
export const buildCreatePartitionedTableSql = forward("buildCreatePartitionedTableSql");
|
||||
export const previewSqliteTableStructureChange = forward("previewSqliteTableStructureChange");
|
||||
export const applySqliteTableStructureChange = forward("applySqliteTableStructureChange");
|
||||
export const buildCreateTableSql = forward("buildCreateTableSql");
|
||||
|
||||
@@ -189,7 +189,7 @@ import type {
|
||||
} from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/lib/sql/dmlChangePreview";
|
||||
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
|
||||
@@ -1179,6 +1179,10 @@ export async function getTablePartitionStatus(connectionId: string, database: st
|
||||
return get(`/api/schema/table-partition-status?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function getTablePartitioning(connectionId: string, database: string, schema: string, table: string): Promise<import("@/types/database").PgTablePartitioning> {
|
||||
return get(`/api/schema/table-partitioning?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
|
||||
return get(`/api/schema/invalid-indexes?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
@@ -1458,13 +1462,14 @@ export async function closeClientConnectionSession(connectionId: string, databas
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise<QueryResult> {
|
||||
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number, useTransaction?: boolean): Promise<QueryResult> {
|
||||
return post("/api/query/execute-batch", {
|
||||
connectionId,
|
||||
database,
|
||||
statements,
|
||||
schema,
|
||||
timeoutSecs,
|
||||
useTransaction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1712,6 +1717,14 @@ export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSql
|
||||
return post("/api/query/build-table-owner-change-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildTablePartitionOperationSql(options: TablePartitionSqlOptions): Promise<TableStructureChangeSql> {
|
||||
return post("/api/query/build-table-partition-operation-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreatePartitionedTableSql(options: BuildCreatePartitionedTableSqlOptions): Promise<TableStructureChangeSql> {
|
||||
return post("/api/query/build-create-partitioned-table-sql", { options: options.options, partitioning: options.partitioning });
|
||||
}
|
||||
|
||||
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
|
||||
return post("/api/query/preview-sqlite-table-structure-change", {
|
||||
connectionId,
|
||||
|
||||
@@ -122,7 +122,7 @@ import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/li
|
||||
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSqlPlan, SelectedSchemaDiffInput, GenerateSchemaSyncPlanOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
|
||||
@@ -1718,13 +1718,14 @@ export async function closeClientConnectionSession(connectionId: string, databas
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise<QueryResult> {
|
||||
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number, useTransaction?: boolean): Promise<QueryResult> {
|
||||
return invoke("execute_batch", {
|
||||
connectionId,
|
||||
database,
|
||||
statements,
|
||||
schema,
|
||||
timeoutSecs,
|
||||
useTransaction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1961,6 +1962,14 @@ export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSql
|
||||
return invoke("build_table_owner_change_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildTablePartitionOperationSql(options: TablePartitionSqlOptions): Promise<TableStructureChangeSql> {
|
||||
return invoke("build_table_partition_operation_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreatePartitionedTableSql(options: BuildCreatePartitionedTableSqlOptions): Promise<TableStructureChangeSql> {
|
||||
return invoke("build_create_partitioned_table_sql", { options: options.options, partitioning: options.partitioning });
|
||||
}
|
||||
|
||||
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
|
||||
return invoke("preview_sqlite_table_structure_change", {
|
||||
connectionId,
|
||||
@@ -2174,6 +2183,15 @@ export async function getTablePartitionStatus(connectionId: string, database: st
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTablePartitioning(connectionId: string, database: string, schema: string, table: string): Promise<import("@/types/database").PgTablePartitioning> {
|
||||
return invoke("get_table_partitioning", {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
|
||||
return invoke("list_invalid_indexes", {
|
||||
connectionId,
|
||||
|
||||
@@ -42,7 +42,7 @@ async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment" | "owner";
|
||||
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "partitions" | "comment" | "owner";
|
||||
|
||||
function cacheSegment(value: string | undefined): string {
|
||||
return encodeURIComponent(value ?? "");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||||
import { getTableMetadataCapabilities, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
|
||||
|
||||
describe("tableMetadataCapabilities", () => {
|
||||
it("exposes only collection indexes for MongoDB table information", () => {
|
||||
@@ -9,6 +9,7 @@ describe("tableMetadataCapabilities", () => {
|
||||
foreignKeys: false,
|
||||
constraints: false,
|
||||
triggers: false,
|
||||
partitions: false,
|
||||
ddl: false,
|
||||
});
|
||||
});
|
||||
@@ -23,4 +24,28 @@ describe("tableMetadataCapabilities", () => {
|
||||
expect(getTableMetadataCapabilities("mysql").constraints).toBe(false);
|
||||
expect(getTableMetadataCapabilities(undefined).constraints).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes partitions only for dialects with declarative partitioning metadata", () => {
|
||||
expect(getTableMetadataCapabilities("postgres").partitions).toBe(true);
|
||||
expect(getTableMetadataCapabilities("kingbase").partitions).toBe(true);
|
||||
expect(getTableMetadataCapabilities("mysql").partitions).toBe(false);
|
||||
expect(getTableMetadataCapabilities("oracle").partitions).toBe(false);
|
||||
// openGauss-based engines use a different partition catalog and stay off.
|
||||
expect(getTableMetadataCapabilities("opengauss").partitions).toBe(false);
|
||||
expect(getTableMetadataCapabilities("vastbase").partitions).toBe(false);
|
||||
expect(getTableMetadataCapabilities("gaussdb").partitions).toBe(false);
|
||||
expect(getTableMetadataCapabilities(undefined).partitions).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the Partitions tab for PostgreSQL in both edit and create mode only", () => {
|
||||
const postgres = getTableMetadataCapabilities("postgres");
|
||||
expect(isStructureMetadataTabSupported("partitions", postgres, false)).toBe(true);
|
||||
// Create mode declares `PARTITION BY` instead of reading the catalog.
|
||||
expect(isStructureMetadataTabSupported("partitions", postgres, true)).toBe(true);
|
||||
|
||||
const mysql = getTableMetadataCapabilities("mysql");
|
||||
expect(isStructureMetadataTabSupported("partitions", mysql, false)).toBe(false);
|
||||
// The DDL tab has no create-mode baseline; the partitions tab does.
|
||||
expect(isStructureMetadataTabSupported("ddl", postgres, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,78 @@ function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export type ObjectBrowserRowSorter = (rows: ObjectBrowserRow[]) => ObjectBrowserRow[];
|
||||
|
||||
/**
|
||||
* Flattens the object rows into render order: roots first, each followed by its
|
||||
* partition children — recursively, because a PostgreSQL partition can itself be
|
||||
* a partitioned parent (a second-level sub-partitioned table).
|
||||
*
|
||||
* `depths` carries each partition row's nesting level so the caller can indent
|
||||
* it; roots are 0.
|
||||
*
|
||||
* `rows`/`matchingRows` are expected to be pre-filtered by the caller's type
|
||||
* filter. When `query` is non-empty the tree is force-expanded so a deep match
|
||||
* and the ancestors leading to it stay visible even while collapsed.
|
||||
*/
|
||||
export function groupObjectBrowserRows(options: { rows: readonly ObjectBrowserRow[]; matchingRows: readonly ObjectBrowserRow[]; query: string; expandedPartitionParentIds: ReadonlySet<string>; sortRows: ObjectBrowserRowSorter }): { rows: ObjectBrowserRow[]; depths: Map<string, number> } {
|
||||
const { rows, matchingRows, query, expandedPartitionParentIds, sortRows } = options;
|
||||
const candidateIds = new Set(rows.map((row) => row.id));
|
||||
const matchingIds = new Set(matchingRows.map((row) => row.id));
|
||||
const rowById = new Map(rows.map((row) => [row.id, row]));
|
||||
|
||||
const partitionRowsByParentId = new Map<string, ObjectBrowserRow[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.partitionParentId) continue;
|
||||
const group = partitionRowsByParentId.get(row.partitionParentId) ?? [];
|
||||
group.push(row);
|
||||
partitionRowsByParentId.set(row.partitionParentId, group);
|
||||
}
|
||||
|
||||
// A deep match must be reachable: pull in every ancestor that leads to it, or
|
||||
// the match would be filtered out as an orphaned sub-partition.
|
||||
const ancestorIdsWithMatchingPartitions = new Set<string>();
|
||||
if (query) {
|
||||
for (const row of matchingRows) {
|
||||
let ancestorId = row.partitionParentId;
|
||||
while (ancestorId && !ancestorIdsWithMatchingPartitions.has(ancestorId)) {
|
||||
ancestorIdsWithMatchingPartitions.add(ancestorId);
|
||||
ancestorId = rowById.get(ancestorId)?.partitionParentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRows = rows.filter((row) => {
|
||||
if (row.partitionParentId) return false;
|
||||
if (!query) return true;
|
||||
return matchingIds.has(row.id) || ancestorIdsWithMatchingPartitions.has(row.id);
|
||||
});
|
||||
const result: ObjectBrowserRow[] = [];
|
||||
const depths = new Map<string, number>();
|
||||
|
||||
const appendPartitions = (parent: ObjectBrowserRow, depth: number) => {
|
||||
const partitions = partitionRowsByParentId.get(parent.id)?.filter((partition) => candidateIds.has(partition.id));
|
||||
if (!partitions?.length) return;
|
||||
// Search force-expands the tree so every match (and its ancestor chain) is visible.
|
||||
if (!query && !expandedPartitionParentIds.has(parent.id)) return;
|
||||
const parentMatches = matchingIds.has(parent.id);
|
||||
const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id) || ancestorIdsWithMatchingPartitions.has(partition.id)) : partitions;
|
||||
for (const partition of sortRows(visiblePartitions)) {
|
||||
result.push(partition);
|
||||
depths.set(partition.id, depth);
|
||||
appendPartitions(partition, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
for (const row of sortRows(rootRows)) {
|
||||
result.push(row);
|
||||
depths.set(row.id, 0);
|
||||
appendPartitions(row, 1);
|
||||
}
|
||||
|
||||
return { rows: result, depths };
|
||||
}
|
||||
|
||||
function objectKey(row: Pick<ObjectBrowserRow, "schema" | "name" | "type">, fallbackSchema: string) {
|
||||
return `${row.type}\0${(row.schema || fallbackSchema).toLowerCase()}\0${row.name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { PgPartitionBound, PgPartitionKind, PgPartitionNode } from "@/types/database";
|
||||
|
||||
/** One flattened row of a partition tree, carrying what a renderer needs. */
|
||||
export interface PgPartitionTreeRow {
|
||||
key: string;
|
||||
/** 0 for a top-level partition of the table; +1 per nesting level. */
|
||||
depth: number;
|
||||
node: PgPartitionNode;
|
||||
/** Direct parent identity, used by detach operations on nested partitions. */
|
||||
parentSchema?: string;
|
||||
parentName?: string;
|
||||
/** Keys of every ancestor above this row, outermost first (for collapse). */
|
||||
ancestorKeys: string[];
|
||||
}
|
||||
|
||||
export interface PgPartitionParentIdentity {
|
||||
schema?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first flatten of a partition tree so a renderer can show multi-level
|
||||
* hierarchies without recursive components. Nesting is conveyed by indentation
|
||||
* and the fold control; no tree guide lines are drawn.
|
||||
*/
|
||||
export function flattenPgPartitionNodes(nodes: PgPartitionNode[], rootParent: PgPartitionParentIdentity = {}): PgPartitionTreeRow[] {
|
||||
return flattenPgPartitionNodesAtDepth(nodes, rootParent, 0, "", []);
|
||||
}
|
||||
|
||||
function flattenPgPartitionNodesAtDepth(nodes: PgPartitionNode[], parent: PgPartitionParentIdentity, depth: number, keyPrefix: string, ancestorKeys: string[]): PgPartitionTreeRow[] {
|
||||
const rows: PgPartitionTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
const key = `${keyPrefix}${node.schema}.${node.name}`;
|
||||
rows.push({ key, depth, node, parentSchema: parent.schema, parentName: parent.name, ancestorKeys: [...ancestorKeys] });
|
||||
if (node.children.length > 0) {
|
||||
rows.push(...flattenPgPartitionNodesAtDepth(node.children, { schema: node.schema, name: node.name }, depth + 1, `${key}/`, [...ancestorKeys, key]));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Pixels of indentation added per nesting level. */
|
||||
export const PARTITION_TREE_INDENT_PX = 16;
|
||||
|
||||
/**
|
||||
* Drops rows hidden behind a collapsed ancestor, so a renderer can show a
|
||||
* folded tree without re-walking it.
|
||||
*/
|
||||
export function visiblePgPartitionRows(rows: PgPartitionTreeRow[], collapsedKeys: ReadonlySet<string>): PgPartitionTreeRow[] {
|
||||
if (collapsedKeys.size === 0) return rows;
|
||||
return rows.filter((row) => !row.ancestorKeys.some((key) => collapsedKeys.has(key)));
|
||||
}
|
||||
|
||||
export type PartitionTranslate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/**
|
||||
* Hover hint for a partition row. The estimated row count and size are kept out
|
||||
* of the row body (they doubled every row's height) and surfaced here instead.
|
||||
*/
|
||||
export function pgPartitionRowHint(node: PgPartitionNode, t: PartitionTranslate, formatBytes: (value: number) => string): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (node.rowEstimate != null) parts.push(t("structureEditor.partitionsRowEstimate", { count: node.rowEstimate }));
|
||||
if (node.totalBytes != null) parts.push(t("structureEditor.partitionsSize", { size: formatBytes(node.totalBytes) }));
|
||||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
/** Renders a parsed bound as the SQL fragment PostgreSQL uses in `CREATE TABLE ... PARTITION OF`. */
|
||||
export function pgPartitionBoundText(bound?: PgPartitionBound): string {
|
||||
if (!bound) return "";
|
||||
if (bound.kind === "default") return "DEFAULT";
|
||||
if (bound.kind === "range") return `FROM (${bound.from.join(", ")}) TO (${bound.to.join(", ")})`;
|
||||
if (bound.kind === "list") return `IN (${bound.values.join(", ")})`;
|
||||
return `MODULUS ${bound.modulus} REMAINDER ${bound.remainder}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound text for a tree node, falling back to the raw catalog definition when
|
||||
* the bound shape was not recognized by the backend parser.
|
||||
*/
|
||||
export function pgPartitionNodeBoundText(node: PgPartitionNode): string {
|
||||
return node.bound ? pgPartitionBoundText(node.bound) : node.boundDefinition || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits user-entered partition bound values on top-level commas only. Commas
|
||||
* inside single quotes (with `''` escapes) or nested parentheses stay in the
|
||||
* value, so `lower('a,b'), 2` yields two values.
|
||||
*/
|
||||
export function splitPgPartitionBoundValues(input: string): string[] {
|
||||
const values: string[] = [];
|
||||
let current = "";
|
||||
let depth = 0;
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const ch = input[index];
|
||||
if (ch === "'" && !inDouble) {
|
||||
if (inSingle && input[index + 1] === "'") {
|
||||
current += "''";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
inSingle = !inSingle;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && ch === "(") depth += 1;
|
||||
if (!inSingle && !inDouble && ch === ")") depth -= 1;
|
||||
if (ch === "," && !inSingle && !inDouble && depth === 0) {
|
||||
values.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
values.push(current.trim());
|
||||
return values.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
/** Translation key for a partition strategy label. */
|
||||
export function pgPartitionKindLabelKey(kind?: PgPartitionKind): string | undefined {
|
||||
if (kind === "range") return "structureEditor.partitionKindRange";
|
||||
if (kind === "list") return "structureEditor.partitionKindList";
|
||||
if (kind === "hash") return "structureEditor.partitionKindHash";
|
||||
return undefined;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface TableMetadataCapabilities {
|
||||
foreignKeys: boolean;
|
||||
constraints: boolean;
|
||||
triggers: boolean;
|
||||
partitions: boolean;
|
||||
ddl: boolean;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,9 @@ const defaultCapabilities: TableMetadataCapabilities = {
|
||||
// empty tab.
|
||||
constraints: false,
|
||||
triggers: true,
|
||||
// Declarative partitioning metadata (pg_partitioned_table / pg_get_partkeydef)
|
||||
// is PostgreSQL-only for now; other dialects leave the tab hidden.
|
||||
partitions: false,
|
||||
ddl: true,
|
||||
};
|
||||
|
||||
@@ -26,8 +30,10 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
|
||||
oracle: {
|
||||
constraints: true,
|
||||
},
|
||||
// KingbaseES V9 shares PostgreSQL's declarative partition catalog and DDL.
|
||||
kingbase: {
|
||||
constraints: true,
|
||||
partitions: true,
|
||||
},
|
||||
vastbase: {
|
||||
constraints: true,
|
||||
@@ -39,6 +45,7 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
|
||||
// EXCLUDE/NOT NULL) through list_constraints.
|
||||
postgres: {
|
||||
constraints: true,
|
||||
partitions: true,
|
||||
},
|
||||
// SQL Server reports PK/UNIQUE/FOREIGN KEY/CHECK/DEFAULT constraints from the
|
||||
// sys.* catalog views through list_constraints.
|
||||
@@ -155,6 +162,7 @@ export function isStructureMetadataTabSupported(tab: TableInfoTab, capabilities:
|
||||
(tab === "foreignKeys" && capabilities.foreignKeys) ||
|
||||
(tab === "constraints" && capabilities.constraints) ||
|
||||
(tab === "triggers" && capabilities.triggers) ||
|
||||
(tab === "partitions" && capabilities.partitions) ||
|
||||
(tab === "ddl" && capabilities.ddl && !isCreateMode)
|
||||
);
|
||||
}
|
||||
@@ -108,6 +108,48 @@ export interface TableStructureChangeSql {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Explicit partition maintenance operations (PostgreSQL only). */
|
||||
export type TablePartitionOperationKind = "create" | "attach" | "detach" | "drop";
|
||||
|
||||
/** Partition bound as entered in the UI; values are SQL literal text. */
|
||||
export type TablePartitionBoundDraft = { kind: "range"; from: string[]; to: string[] } | { kind: "list"; values: string[] } | { kind: "hash"; modulus: number; remainder: number } | { kind: "default" };
|
||||
|
||||
export interface TablePartitionOperation {
|
||||
id: string;
|
||||
kind: TablePartitionOperationKind;
|
||||
/** Partitioned parent's schema; empty falls back to the edited table's schema. */
|
||||
parentSchema: string;
|
||||
/** Partitioned parent; empty falls back to the edited table. */
|
||||
parentTable: string;
|
||||
/** Schema of the partition relation; empty falls back to the parent's schema. */
|
||||
schema: string;
|
||||
name: string;
|
||||
/** Required for `create`/`attach`; must be absent for `detach`/`drop`. */
|
||||
bound?: TablePartitionBoundDraft;
|
||||
/** Emit `DETACH PARTITION ... CONCURRENTLY` (PostgreSQL 14+). */
|
||||
concurrently: boolean;
|
||||
}
|
||||
|
||||
export interface TablePartitionSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
driverProfile?: string | null;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
operations: TablePartitionOperation[];
|
||||
}
|
||||
|
||||
/** Declarative partitioning for a table being created. */
|
||||
export interface TablePartitionDefinition {
|
||||
kind: "range" | "list" | "hash";
|
||||
columns: string[];
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface BuildCreatePartitionedTableSqlOptions {
|
||||
options: BuildTableStructureChangeSqlOptions;
|
||||
partitioning: TablePartitionDefinition;
|
||||
}
|
||||
|
||||
export interface BuildTableOwnerChangeSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
|
||||
@@ -7,23 +7,26 @@ export interface TableStructureRefreshScope {
|
||||
foreignKeys: boolean;
|
||||
constraints: boolean;
|
||||
triggers: boolean;
|
||||
partitions: boolean;
|
||||
tableComment: boolean;
|
||||
}
|
||||
|
||||
export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): TableStructureRefreshScope {
|
||||
switch (activeTab) {
|
||||
case "columns":
|
||||
return { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
|
||||
return { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
|
||||
case "indexes":
|
||||
return { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
|
||||
return { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
|
||||
case "foreignKeys":
|
||||
return { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true };
|
||||
return { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, partitions: false, tableComment: true };
|
||||
case "constraints":
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true };
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, partitions: false, tableComment: true };
|
||||
case "triggers":
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true };
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, partitions: false, tableComment: true };
|
||||
case "partitions":
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false };
|
||||
case "ddl":
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true };
|
||||
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +38,14 @@ export function unloadedTableStructureRefreshScope(activeTab: TableInfoTab, load
|
||||
foreignKeys: visibleScope.foreignKeys && !loadedFacets.has("foreign-keys"),
|
||||
constraints: visibleScope.constraints && !loadedFacets.has("constraints"),
|
||||
triggers: visibleScope.triggers && !loadedFacets.has("triggers"),
|
||||
// Partition metadata has no persisted cache facet; "partitions" is only
|
||||
// tracked in the loaded-facet set so re-activating the tab does not
|
||||
// re-fetch in a loop.
|
||||
partitions: visibleScope.partitions && !loadedFacets.has("partitions"),
|
||||
tableComment: visibleScope.tableComment && !loadedFacets.has("comment"),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasTableStructureRefreshWork(scope: TableStructureRefreshScope): boolean {
|
||||
return scope.columns || scope.indexes || scope.foreignKeys || scope.constraints || scope.triggers || scope.tableComment;
|
||||
return scope.columns || scope.indexes || scope.foreignKeys || scope.constraints || scope.triggers || scope.partitions || scope.tableComment;
|
||||
}
|
||||
@@ -1498,7 +1498,7 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
|
||||
};
|
||||
}
|
||||
|
||||
const TABLE_INFO_TABS = new Set<TableInfoTab>(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers"]);
|
||||
const TABLE_INFO_TABS = new Set<TableInfoTab>(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers", "partitions"]);
|
||||
|
||||
function normalizeTableInfoTab(value: unknown): TableInfoTab {
|
||||
return typeof value === "string" && TABLE_INFO_TABS.has(value as TableInfoTab) ? (value as TableInfoTab) : DEFAULT_EDITOR_SETTINGS.tableInfoActiveTab;
|
||||
|
||||
@@ -1525,7 +1525,43 @@ export interface TableNameFilter {
|
||||
excludePatterns: string[];
|
||||
}
|
||||
|
||||
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "ddl";
|
||||
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "partitions" | "ddl";
|
||||
|
||||
/** PostgreSQL declarative partitioning strategy (`pg_partitioned_table.partstrat`). */
|
||||
export type PgPartitionKind = "range" | "list" | "hash";
|
||||
|
||||
/** Structured form of a partition's `pg_get_expr(relpartbound)` definition. Values are the SQL literal text PostgreSQL reported (`'2024-01-01'`, `MINVALUE`, `0`). */
|
||||
export type PgPartitionBound = { kind: "range"; from: string[]; to: string[] } | { kind: "list"; values: string[] } | { kind: "hash"; modulus: number; remainder: number } | { kind: "default" };
|
||||
|
||||
export interface PgPartitionNode {
|
||||
schema: string;
|
||||
name: string;
|
||||
strategy?: PgPartitionKind;
|
||||
keyDefinition?: string;
|
||||
bound?: PgPartitionBound;
|
||||
boundDefinition?: string;
|
||||
isLeaf: boolean;
|
||||
rowEstimate?: number;
|
||||
totalBytes?: number;
|
||||
children: PgPartitionNode[];
|
||||
}
|
||||
|
||||
export interface PgTablePartitioning {
|
||||
isPartitioned: boolean;
|
||||
isPartition: boolean;
|
||||
parent?: string;
|
||||
parentSchema?: string;
|
||||
parentTable?: string;
|
||||
ownBound?: PgPartitionBound;
|
||||
strategy?: PgPartitionKind;
|
||||
keyDefinition?: string;
|
||||
keyColumns: string[];
|
||||
keyExpression?: string;
|
||||
defaultPartition?: string;
|
||||
partitions: PgPartitionNode[];
|
||||
/** `server_version_num`, used to gate `DETACH PARTITION CONCURRENTLY` (14+). */
|
||||
serverVersionNum?: number;
|
||||
}
|
||||
|
||||
export interface TableStructureEditorTarget {
|
||||
kind: "column" | "index";
|
||||
@@ -1558,6 +1594,13 @@ export interface TableStructureEditorDraft {
|
||||
constraintsLoaded?: boolean;
|
||||
triggers: import("@/lib/table/tableStructureEditorSql").EditableStructureTrigger[];
|
||||
triggersLoaded?: boolean;
|
||||
/** Pending PostgreSQL partition operations (create/attach/detach/drop). */
|
||||
partitionOperations?: import("@/lib/table/tableStructureEditorSql").TablePartitionOperation[];
|
||||
/** Create-mode `PARTITION BY` declaration. */
|
||||
createPartitioningEnabled?: boolean;
|
||||
createPartitioningKind?: import("@/types/database").PgPartitionKind;
|
||||
createPartitioningColumns?: string[];
|
||||
createPartitioningExpression?: string;
|
||||
loadedMetadataFacets?: import("@/lib/metadata/objectMetadataCache").ObjectMetadataFacet[];
|
||||
scrollPositions?: Partial<Record<TableInfoTab, TableStructureEditorViewport>>;
|
||||
/** Request id of the structureInitialTab the editor already applied; remounts must not replay a consumed initial tab over the restored draft. */
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"list_constraints",
|
||||
"list_partitions",
|
||||
"list_subpartitions",
|
||||
"get_table_partition_status",
|
||||
"get_table_partitioning",
|
||||
"execute_query",
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
|
||||
@@ -3948,6 +3948,93 @@ pub async fn execute_statements(
|
||||
result
|
||||
}
|
||||
|
||||
/// Execute a batch, optionally on a single transaction.
|
||||
///
|
||||
/// `use_transaction` is opt-in and only the structure editor sets it, for
|
||||
/// batches that change a partition hierarchy: a mid-batch failure there would
|
||||
/// otherwise leave a half-created hierarchy behind. The shared transaction
|
||||
/// kernel rejects backends whose DDL cannot roll back, and statements that
|
||||
/// cannot run inside a transaction block (`... CONCURRENTLY`) are refused up
|
||||
/// front rather than half-applied.
|
||||
pub async fn execute_statements_with_transaction_option(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
statements: &[String],
|
||||
schema: Option<&str>,
|
||||
use_transaction: bool,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
if use_transaction && statements.len() > 1 {
|
||||
if batch_has_concurrently_statement(statements) {
|
||||
return Err(
|
||||
"use_transaction cannot wrap CONCURRENTLY statements: they cannot run inside a transaction block. Run the batch without use_transaction."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let db_type = connection_database_type(state, connection_id).await;
|
||||
if batch_transaction_ddl_is_unrollbackable(db_type, statements) {
|
||||
return Err(
|
||||
"use_transaction cannot be used with a batch whose DDL cannot be rolled back: DDL statements implicitly commit and cannot be undone. Run the batch without use_transaction (auto-commit, one result per statement) or split the DDL and DML into separate calls."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let result = execute_statements_in_transaction_typed(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
statements,
|
||||
schema,
|
||||
None,
|
||||
timeout_secs,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_legacy_string())?;
|
||||
let invalidate = statements.iter().any(|sql| crate::object_cache::sql_may_change_object_metadata(sql, db_type));
|
||||
if invalidate {
|
||||
crate::object_cache::invalidate_connection_object_cache(&state.storage, connection_id).await;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
execute_statements(state, connection_id, database, statements, schema, timeout_secs).await
|
||||
}
|
||||
|
||||
/// Whether a batch contains a statement PostgreSQL-family servers refuse to run
|
||||
/// inside a transaction block (`CREATE/DROP INDEX CONCURRENTLY`,
|
||||
/// `ALTER TABLE ... DETACH PARTITION CONCURRENTLY`, ...).
|
||||
fn batch_has_concurrently_statement(statements: &[String]) -> bool {
|
||||
statements.iter().any(|statement| {
|
||||
let upper = statement.to_ascii_uppercase();
|
||||
// Real CONCURRENTLY statements always start with one of these verbs.
|
||||
// Requiring the verb plus a standalone keyword keeps the word inside
|
||||
// string literals, comments or plain identifiers (e.g. a table named
|
||||
// `concurrently`) from silently demoting the batch to auto-commit; the
|
||||
// direction stays fail-safe because no CONCURRENTLY statement form
|
||||
// starts with another verb.
|
||||
let trimmed = upper.trim_start();
|
||||
let starts_with_ddl_verb =
|
||||
["CREATE ", "DROP ", "REINDEX", "REFRESH ", "ALTER "].iter().any(|verb| trimmed.starts_with(verb));
|
||||
starts_with_ddl_verb && contains_standalone_concurrently_keyword(&upper)
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_standalone_concurrently_keyword(upper: &str) -> bool {
|
||||
let keyword = "CONCURRENTLY";
|
||||
let mut search_from = 0;
|
||||
while let Some(pos) = upper[search_from..].find(keyword) {
|
||||
let pos = search_from + pos;
|
||||
let boundary =
|
||||
|ch: Option<char>| ch.map(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '"').unwrap_or(false);
|
||||
let before = boundary(upper[..pos].chars().next_back());
|
||||
let after = boundary(upper[pos + keyword.len()..].chars().next());
|
||||
if !before && !after {
|
||||
return true;
|
||||
}
|
||||
search_from = pos + 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute_statements_inner(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
@@ -8171,6 +8258,37 @@ for line in sys.stdin:
|
||||
assert!(!batch_transaction_ddl_is_unrollbackable(None, &ddl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_has_concurrently_statement_detects_transaction_block_escapees() {
|
||||
assert!(batch_has_concurrently_statement(&["CREATE INDEX CONCURRENTLY idx ON t (id)".to_string(),]));
|
||||
assert!(batch_has_concurrently_statement(&[
|
||||
"ALTER TABLE parent DETACH PARTITION child CONCURRENTLY".to_string(),
|
||||
]));
|
||||
// Case-insensitive, and a single CONCURRENTLY anywhere in the batch is enough.
|
||||
assert!(batch_has_concurrently_statement(&[
|
||||
"ALTER TABLE t ADD COLUMN c int".to_string(),
|
||||
"drop index concurrently idx".to_string(),
|
||||
]));
|
||||
assert!(!batch_has_concurrently_statement(&[
|
||||
"CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (1)".to_string(),
|
||||
]));
|
||||
// The word inside literals or plain identifiers must not demote a batch
|
||||
// that would otherwise run in one transaction.
|
||||
assert!(!batch_has_concurrently_statement(&[
|
||||
"INSERT INTO notes (body) VALUES ('run CREATE INDEX CONCURRENTLY later')".to_string(),
|
||||
]));
|
||||
assert!(!batch_has_concurrently_statement(&[
|
||||
"SELECT id FROM concurrently WHERE label = 'drop index concurrently idx'".to_string(),
|
||||
]));
|
||||
assert!(!batch_has_concurrently_statement(&[
|
||||
"-- refresh materialized view concurrently next".to_string(),
|
||||
"SELECT 1".to_string(),
|
||||
]));
|
||||
// REINDEX/REFRESH forms still detect.
|
||||
assert!(batch_has_concurrently_statement(&["REINDEX TABLE CONCURRENTLY t".to_string()]));
|
||||
assert!(batch_has_concurrently_statement(&["REFRESH MATERIALIZED VIEW CONCURRENTLY mv".to_string()]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_pool_is_sqlserver_agent_detects_agent_and_native_pools() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-query-sqlserver-agent-flag-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
@@ -5250,6 +5250,27 @@ for line in sys.stdin:
|
||||
assert!(!super::is_agent_completion_assistant_unsupported("Agent RPC error (-1): Connection failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_unsupported_agent_partition_method_errors() {
|
||||
assert!(super::is_agent_partition_method_unsupported(
|
||||
"Agent RPC error (-1): unknown method: get_table_partitioning",
|
||||
"get_table_partitioning",
|
||||
));
|
||||
assert!(super::is_agent_partition_method_unsupported(
|
||||
"Agent RPC error (-32601): Method not found: get_table_partition_status",
|
||||
"get_table_partition_status",
|
||||
));
|
||||
// A different method in the same error must not match.
|
||||
assert!(!super::is_agent_partition_method_unsupported(
|
||||
"Agent RPC error (-1): unknown method: get_table_partitioning",
|
||||
"get_table_partition_status",
|
||||
));
|
||||
assert!(!super::is_agent_partition_method_unsupported(
|
||||
"Agent RPC error (-1): Connection failed",
|
||||
"get_table_partitioning",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_metadata_prefers_schema_qualifier() {
|
||||
assert_eq!(clickhouse_metadata_database("", "testdb"), "testdb");
|
||||
@@ -6141,6 +6162,15 @@ fn is_agent_completion_assistant_unsupported(error: &str) -> bool {
|
||||
|| error.contains("completion assistant search is not supported")
|
||||
}
|
||||
|
||||
/// True when an agent built against an older protocol does not implement a
|
||||
/// table-partition RPC. A mixed-version deployment (new core, old agent) must
|
||||
/// hide the Partitions tab rather than fail every probe, so callers degrade to
|
||||
/// the default value instead of surfacing the error.
|
||||
fn is_agent_partition_method_unsupported(error: &str, method: &str) -> bool {
|
||||
let error = error.to_ascii_lowercase();
|
||||
error.contains(method) && (error.contains("unknown method") || error.contains("method not found"))
|
||||
}
|
||||
|
||||
async fn completion_assistant_fallback_core(
|
||||
state: &AppState,
|
||||
request: &db::CompletionAssistantRequest,
|
||||
@@ -7809,10 +7839,31 @@ pub async fn table_partition_status_core(
|
||||
match pool_handle.as_ref() {
|
||||
Some(PoolKind::Postgres(pool)) => {
|
||||
let info = db::postgres::get_table_partition_info(pool, schema, table).await?;
|
||||
Ok(TablePartitionStatus {
|
||||
is_partitioned_parent: info.key.is_some() && !info.is_partition,
|
||||
is_partition: info.is_partition,
|
||||
})
|
||||
Ok(TablePartitionStatus { is_partitioned_parent: info.key.is_some(), is_partition: info.is_partition })
|
||||
}
|
||||
Some(PoolKind::Agent(client)) => {
|
||||
// Resolve the config once: it gates the arm and feeds the RPC
|
||||
// timeout.
|
||||
let db_config = connection_config(state, connection_id).await;
|
||||
if !db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
|
||||
return Ok(TablePartitionStatus::default());
|
||||
}
|
||||
let mut client = client.lock().await;
|
||||
match client
|
||||
.get_table_partition_status::<TablePartitionStatus>(
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
agent_metadata_timeout(db_config.as_ref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Ok(status),
|
||||
Err(error) if is_agent_partition_method_unsupported(&error, "get_table_partition_status") => {
|
||||
Ok(TablePartitionStatus::default())
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
_ => Ok(TablePartitionStatus::default()),
|
||||
}
|
||||
@@ -7820,6 +7871,52 @@ pub async fn table_partition_status_core(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Structured declarative partitioning view used by the table structure
|
||||
/// editor. Native PostgreSQL pools and compatible agents (such as Kingbase)
|
||||
/// provide the same response shape; unsupported pools return the default
|
||||
/// all-false/empty value.
|
||||
pub async fn get_table_partitioning_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<db::PgTablePartitioning, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
|
||||
let pool_handle = state.pool_handle(&pool_key).await;
|
||||
match pool_handle.as_ref() {
|
||||
Some(PoolKind::Postgres(pool)) => db::postgres::get_table_partitioning(pool, schema, table).await,
|
||||
Some(PoolKind::Agent(client)) => {
|
||||
// Resolve the config once: it gates the arm and feeds the RPC
|
||||
// timeout.
|
||||
let db_config = connection_config(state, connection_id).await;
|
||||
if !db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
|
||||
return Ok(db::PgTablePartitioning::default());
|
||||
}
|
||||
let mut client = client.lock().await;
|
||||
match client
|
||||
.get_table_partitioning::<db::PgTablePartitioning>(
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
agent_metadata_timeout(db_config.as_ref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(partitioning) => Ok(partitioning),
|
||||
Err(error) if is_agent_partition_method_unsupported(&error, "get_table_partitioning") => {
|
||||
Ok(db::PgTablePartitioning::default())
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
_ => Ok(db::PgTablePartitioning::default()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Same-table index names whose `pg_index.indisvalid` is `false` (left behind
|
||||
/// by a cancelled `CREATE INDEX CONCURRENTLY`). Empty for non-PostgreSQL pools.
|
||||
pub async fn list_invalid_indexes_core(
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Live declarative-partitioning tests.
|
||||
//!
|
||||
//! Point `DBX_LIVE_POSTGRES_*` at a PostgreSQL server, or at a KingbaseES
|
||||
//! server (same wire protocol and `pg_partitioned_table` catalog) — all tests
|
||||
//! pass against KingbaseES V009R001C010. openGauss-based engines use a
|
||||
//! different `pg_partition` catalog and are not covered here.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use dbx_core::db::postgres;
|
||||
use dbx_core::types::{PgPartitionBound, PgPartitionKind};
|
||||
|
||||
/// Percent-encodes the URL userinfo characters that would otherwise be read
|
||||
/// as delimiters (`@`, `:`, `/`, `#`, `%`). Live fixtures commonly use
|
||||
/// passwords like `test@123`.
|
||||
fn encode_url_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'@' => encoded.push_str("%40"),
|
||||
':' => encoded.push_str("%3A"),
|
||||
'/' => encoded.push_str("%2F"),
|
||||
'#' => encoded.push_str("%23"),
|
||||
'%' => encoded.push_str("%25"),
|
||||
_ => encoded.push(ch),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn live_postgres_url() -> String {
|
||||
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
|
||||
let user =
|
||||
encode_url_component(&std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string()));
|
||||
let password = encode_url_component(&std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default());
|
||||
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
|
||||
format!("postgresql://{user}:{password}@{host}:{port}/{database}")
|
||||
}
|
||||
|
||||
fn fixture_statements(schema: &str) -> Vec<String> {
|
||||
vec![
|
||||
format!("CREATE SCHEMA \"{schema}\""),
|
||||
// RANGE with a default partition.
|
||||
format!("CREATE TABLE \"{schema}\".sales (id integer, sold_on date) PARTITION BY RANGE (sold_on)"),
|
||||
format!(
|
||||
"CREATE TABLE \"{schema}\".sales_2024 PARTITION OF \"{schema}\".sales \
|
||||
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')"
|
||||
),
|
||||
format!("CREATE TABLE \"{schema}\".sales_default PARTITION OF \"{schema}\".sales DEFAULT"),
|
||||
// LIST.
|
||||
format!("CREATE TABLE \"{schema}\".events (region text) PARTITION BY LIST (region)"),
|
||||
format!("CREATE TABLE \"{schema}\".events_a PARTITION OF \"{schema}\".events FOR VALUES IN ('a', 'b')"),
|
||||
// HASH.
|
||||
format!("CREATE TABLE \"{schema}\".h (id integer) PARTITION BY HASH (id)"),
|
||||
format!("CREATE TABLE \"{schema}\".h0 PARTITION OF \"{schema}\".h FOR VALUES WITH (MODULUS 2, REMAINDER 0)"),
|
||||
format!("CREATE TABLE \"{schema}\".h1 PARTITION OF \"{schema}\".h FOR VALUES WITH (MODULUS 2, REMAINDER 1)"),
|
||||
// Multi-column RANGE.
|
||||
format!("CREATE TABLE \"{schema}\".multi (a text, b text) PARTITION BY RANGE (a, b)"),
|
||||
format!(
|
||||
"CREATE TABLE \"{schema}\".multi_1 PARTITION OF \"{schema}\".multi \
|
||||
FOR VALUES FROM ('a', 'a') TO ('b', 'b')"
|
||||
),
|
||||
// Expression RANGE key (must be IMMUTABLE).
|
||||
format!("CREATE TABLE \"{schema}\".exp (v integer) PARTITION BY RANGE (abs(v))"),
|
||||
format!("CREATE TABLE \"{schema}\".exp_neg PARTITION OF \"{schema}\".exp FOR VALUES FROM (MINVALUE) TO (0)"),
|
||||
// Plain, non-partitioned table.
|
||||
format!("CREATE TABLE \"{schema}\".plain (id integer)"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Live PostgreSQL: `get_table_partitioning` must report strategy, key columns,
|
||||
/// nested children, bounds, and the default partition for every supported
|
||||
/// partitioning shape, and must report a plain table as unpartitioned.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
|
||||
async fn live_postgres_partitioning_is_structured() {
|
||||
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let schema = format!("dbx_part_{}", &suffix[..8]);
|
||||
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
|
||||
let _ = postgres::execute_batch(&pool, &cleanup).await;
|
||||
postgres::execute_batch(&pool, &fixture_statements(&schema)).await.expect("create live partition fixture");
|
||||
|
||||
// RANGE + default partition.
|
||||
let sales = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read sales partitioning");
|
||||
assert!(sales.is_partitioned);
|
||||
assert!(!sales.is_partition);
|
||||
assert_eq!(sales.strategy, Some(PgPartitionKind::Range));
|
||||
assert_eq!(sales.key_columns, vec!["sold_on".to_string()]);
|
||||
assert_eq!(sales.default_partition.as_deref(), Some("sales_default"));
|
||||
assert_eq!(sales.partitions.len(), 2);
|
||||
let sales_2024 = sales.partitions.iter().find(|node| node.name == "sales_2024").expect("sales_2024 present");
|
||||
assert!(sales_2024.is_leaf);
|
||||
// The literal text is whatever the server renders: PostgreSQL 14 emits
|
||||
// `'2024-01-01'`, KingbaseES emits `'2024-01-01 00:00:00'`. Assert the shape
|
||||
// and the date prefix instead of an exact string so both pass.
|
||||
match sales_2024.bound.as_ref() {
|
||||
Some(PgPartitionBound::Range { from, to }) => {
|
||||
assert_eq!(from.len(), 1);
|
||||
assert_eq!(to.len(), 1);
|
||||
assert!(from[0].starts_with("'2024-01-01"), "unexpected from: {from:?}");
|
||||
assert!(to[0].starts_with("'2025-01-01"), "unexpected to: {to:?}");
|
||||
}
|
||||
other => panic!("expected a range bound, got {other:?}"),
|
||||
}
|
||||
|
||||
// LIST.
|
||||
let events = postgres::get_table_partitioning(&pool, &schema, "events").await.expect("read events partitioning");
|
||||
assert_eq!(events.strategy, Some(PgPartitionKind::List));
|
||||
assert_eq!(
|
||||
events.partitions[0].bound,
|
||||
Some(PgPartitionBound::List { values: vec!["'a'".to_string(), "'b'".to_string()] })
|
||||
);
|
||||
|
||||
// HASH.
|
||||
let hash = postgres::get_table_partitioning(&pool, &schema, "h").await.expect("read hash partitioning");
|
||||
assert_eq!(hash.strategy, Some(PgPartitionKind::Hash));
|
||||
let h0 = hash.partitions.iter().find(|node| node.name == "h0").expect("h0 present");
|
||||
assert_eq!(h0.bound, Some(PgPartitionBound::Hash { modulus: 2, remainder: 0 }));
|
||||
assert_eq!(h0.strategy, None);
|
||||
|
||||
// Multi-column RANGE.
|
||||
let multi = postgres::get_table_partitioning(&pool, &schema, "multi").await.expect("read multi partitioning");
|
||||
assert_eq!(multi.key_columns, vec!["a".to_string(), "b".to_string()]);
|
||||
assert_eq!(
|
||||
multi.partitions[0].bound,
|
||||
Some(PgPartitionBound::Range {
|
||||
from: vec!["'a'".to_string(), "'a'".to_string()],
|
||||
to: vec!["'b'".to_string(), "'b'".to_string()]
|
||||
})
|
||||
);
|
||||
|
||||
// Expression RANGE key: no plain columns, expression text captured.
|
||||
let exp = postgres::get_table_partitioning(&pool, &schema, "exp").await.expect("read exp partitioning");
|
||||
assert!(exp.key_columns.is_empty());
|
||||
assert_eq!(exp.key_expression.as_deref(), Some("abs(v)"));
|
||||
assert_eq!(
|
||||
exp.partitions[0].bound,
|
||||
Some(PgPartitionBound::Range { from: vec!["MINVALUE".to_string()], to: vec!["0".to_string()] })
|
||||
);
|
||||
|
||||
// A member partition reports its parent and own bound (and no descendants).
|
||||
let child = postgres::get_table_partitioning(&pool, &schema, "sales_2024").await.expect("read child partitioning");
|
||||
assert!(child.is_partition);
|
||||
assert!(!child.is_partitioned);
|
||||
assert_eq!(child.parent.as_deref(), Some(format!("{schema}.sales").as_str()));
|
||||
assert!(child.partitions.is_empty());
|
||||
assert!(child.own_bound.is_some());
|
||||
|
||||
// A plain table is not partitioning at all.
|
||||
let plain = postgres::get_table_partitioning(&pool, &schema, "plain").await.expect("read plain table");
|
||||
assert!(!plain.is_partitioned && !plain.is_partition && plain.partitions.is_empty());
|
||||
|
||||
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup live partition schema");
|
||||
}
|
||||
|
||||
/// Live PostgreSQL: a two-level hierarchy (partitioned partition) must be
|
||||
/// reported as nested children, not flattened.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
|
||||
async fn live_postgres_partitioning_nests_subpartitions() {
|
||||
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let schema = format!("dbx_partn_{}", &suffix[..8]);
|
||||
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
|
||||
let _ = postgres::execute_batch(&pool, &cleanup).await;
|
||||
let statements = vec![
|
||||
format!("CREATE SCHEMA \"{schema}\""),
|
||||
format!("CREATE TABLE \"{schema}\".logs (ts date, region text) PARTITION BY RANGE (ts)"),
|
||||
format!(
|
||||
"CREATE TABLE \"{schema}\".logs_2024 PARTITION OF \"{schema}\".logs \
|
||||
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01') PARTITION BY LIST (region)"
|
||||
),
|
||||
format!("CREATE TABLE \"{schema}\".logs_2024_us PARTITION OF \"{schema}\".logs_2024 FOR VALUES IN ('us')"),
|
||||
];
|
||||
postgres::execute_batch(&pool, &statements).await.expect("create nested partition fixture");
|
||||
|
||||
let logs = postgres::get_table_partitioning(&pool, &schema, "logs").await.expect("read logs partitioning");
|
||||
assert_eq!(logs.strategy, Some(PgPartitionKind::Range));
|
||||
assert_eq!(logs.partitions.len(), 1);
|
||||
let child = &logs.partitions[0];
|
||||
assert_eq!(child.name, "logs_2024");
|
||||
assert_eq!(child.strategy, Some(PgPartitionKind::List));
|
||||
assert!(!child.is_leaf);
|
||||
assert_eq!(child.children.len(), 1);
|
||||
assert_eq!(child.children[0].name, "logs_2024_us");
|
||||
|
||||
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup nested partition schema");
|
||||
}
|
||||
|
||||
/// Live PostgreSQL: the generated partition maintenance DDL must actually run.
|
||||
/// Covers create (with a bound), detach, and drop end to end.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
|
||||
async fn live_postgres_partition_operations_execute() {
|
||||
use dbx_core::table_structure_sql::{
|
||||
build_table_partition_operation_sql, TablePartitionBoundDraft, TablePartitionOperation,
|
||||
TablePartitionOperationKind, TablePartitionSqlOptions,
|
||||
};
|
||||
|
||||
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let schema = format!("dbx_partop_{}", &suffix[..8]);
|
||||
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
|
||||
let _ = postgres::execute_batch(&pool, &cleanup).await;
|
||||
postgres::execute_batch(
|
||||
&pool,
|
||||
&[
|
||||
format!("CREATE SCHEMA \"{schema}\""),
|
||||
format!("CREATE TABLE \"{schema}\".sales (id integer, sold_on date) PARTITION BY RANGE (sold_on)"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("create partitioned parent");
|
||||
|
||||
let operation = |kind: TablePartitionOperationKind, name: &str, bound: Option<TablePartitionBoundDraft>| {
|
||||
TablePartitionOperation {
|
||||
id: format!("op:{name}"),
|
||||
kind,
|
||||
parent_schema: String::new(),
|
||||
parent_table: String::new(),
|
||||
schema: String::new(),
|
||||
name: name.to_string(),
|
||||
bound,
|
||||
concurrently: false,
|
||||
}
|
||||
};
|
||||
|
||||
let create = build_table_partition_operation_sql(TablePartitionSqlOptions {
|
||||
database_type: Some(dbx_core::models::connection::DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
schema: Some(schema.clone()),
|
||||
table_name: "sales".to_string(),
|
||||
operations: vec![operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_2025",
|
||||
Some(TablePartitionBoundDraft::Range {
|
||||
from: vec!["'2025-01-01'".to_string()],
|
||||
to: vec!["'2026-01-01'".to_string()],
|
||||
}),
|
||||
)],
|
||||
});
|
||||
assert!(create.warnings.is_empty(), "{:?}", create.warnings);
|
||||
postgres::execute_batch(&pool, &create.statements).await.expect("execute create partition");
|
||||
|
||||
let partitioning = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning");
|
||||
assert!(partitioning.partitions.iter().any(|node| node.name == "sales_2025"));
|
||||
assert!(partitioning.server_version_num.is_some());
|
||||
|
||||
// Detach + drop: the partition must disappear from the parent's tree.
|
||||
let detach = build_table_partition_operation_sql(TablePartitionSqlOptions {
|
||||
database_type: Some(dbx_core::models::connection::DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
schema: Some(schema.clone()),
|
||||
table_name: "sales".to_string(),
|
||||
operations: vec![
|
||||
operation(TablePartitionOperationKind::Detach, "sales_2025", None),
|
||||
operation(TablePartitionOperationKind::Drop, "sales_2025", None),
|
||||
],
|
||||
});
|
||||
assert!(detach.warnings.is_empty(), "{:?}", detach.warnings);
|
||||
postgres::execute_batch(&pool, &detach.statements).await.expect("execute detach + drop");
|
||||
let after = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning after drop");
|
||||
assert!(!after.partitions.iter().any(|node| node.name == "sales_2025"));
|
||||
|
||||
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup live partition-op schema");
|
||||
}
|
||||
|
||||
/// Live PostgreSQL: a table created through the create-mode partitioning
|
||||
/// builder must be a real partitioned parent that accepts partitions.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
|
||||
async fn live_postgres_create_partitioned_table_executes() {
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
use dbx_core::table_structure_sql::{
|
||||
build_create_partitioned_table_sql, build_table_partition_operation_sql, EditableStructureColumn,
|
||||
TablePartitionBoundDraft, TablePartitionDefinition, TablePartitionOperation, TablePartitionOperationKind,
|
||||
TablePartitionSqlOptions, TableStructureSqlOptions,
|
||||
};
|
||||
use dbx_core::types::PgPartitionKind;
|
||||
|
||||
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let schema = format!("dbx_partnew_{}", &suffix[..8]);
|
||||
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
|
||||
let _ = postgres::execute_batch(&pool, &cleanup).await;
|
||||
postgres::execute_batch(&pool, &[format!("CREATE SCHEMA \"{schema}\"")]).await.expect("create schema");
|
||||
|
||||
let column = |name: &str, data_type: &str| EditableStructureColumn {
|
||||
id: name.to_string(),
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
is_nullable: false,
|
||||
default_value: String::new(),
|
||||
comment: String::new(),
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
original: None,
|
||||
original_position: None,
|
||||
marked_for_drop: false,
|
||||
character_set: String::new(),
|
||||
collation: String::new(),
|
||||
};
|
||||
let options = TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
schema: Some(schema.clone()),
|
||||
table_name: "sales".to_string(),
|
||||
columns: vec![column("sold_on", "date"), column("amount", "numeric")],
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
table_collation: None,
|
||||
};
|
||||
let create = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::Range,
|
||||
columns: vec!["sold_on".to_string()],
|
||||
expression: String::new(),
|
||||
},
|
||||
);
|
||||
assert!(create.warnings.is_empty(), "{:?}", create.warnings);
|
||||
postgres::execute_batch(&pool, &create.statements).await.expect("execute CREATE TABLE ... PARTITION BY");
|
||||
|
||||
let add = build_table_partition_operation_sql(TablePartitionSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
schema: Some(schema.clone()),
|
||||
table_name: "sales".to_string(),
|
||||
operations: vec![TablePartitionOperation {
|
||||
id: "op:1".to_string(),
|
||||
kind: TablePartitionOperationKind::Create,
|
||||
parent_schema: String::new(),
|
||||
parent_table: String::new(),
|
||||
schema: String::new(),
|
||||
name: "sales_2025".to_string(),
|
||||
bound: Some(TablePartitionBoundDraft::Range {
|
||||
from: vec!["'2025-01-01'".to_string()],
|
||||
to: vec!["'2026-01-01'".to_string()],
|
||||
}),
|
||||
concurrently: false,
|
||||
}],
|
||||
});
|
||||
postgres::execute_batch(&pool, &add.statements).await.expect("execute add partition");
|
||||
|
||||
let partitioning = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning");
|
||||
assert!(partitioning.is_partitioned);
|
||||
assert_eq!(partitioning.key_columns, vec!["sold_on".to_string()]);
|
||||
assert!(partitioning.partitions.iter().any(|node| node.name == "sales_2025"));
|
||||
|
||||
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup create-partitioned schema");
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
"list_constraints",
|
||||
"list_partitions",
|
||||
"list_subpartitions",
|
||||
"get_table_partition_status",
|
||||
"get_table_partitioning",
|
||||
"execute_query",
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
|
||||
@@ -1219,6 +1219,8 @@ pub enum AgentMethod {
|
||||
ListConstraints,
|
||||
ListPartitions,
|
||||
ListSubpartitions,
|
||||
GetTablePartitionStatus,
|
||||
GetTablePartitioning,
|
||||
GetTableDdl,
|
||||
ExecuteQuery,
|
||||
ExecuteQueryPage,
|
||||
@@ -1238,7 +1240,7 @@ pub enum AgentMethod {
|
||||
}
|
||||
|
||||
impl AgentMethod {
|
||||
pub const ALL: [Self; 39] = [
|
||||
pub const ALL: [Self; 41] = [
|
||||
Self::Handshake,
|
||||
Self::Connect,
|
||||
Self::OpenSession,
|
||||
@@ -1263,6 +1265,8 @@ impl AgentMethod {
|
||||
Self::ListConstraints,
|
||||
Self::ListPartitions,
|
||||
Self::ListSubpartitions,
|
||||
Self::GetTablePartitionStatus,
|
||||
Self::GetTablePartitioning,
|
||||
Self::ExecuteQuery,
|
||||
Self::ExecuteQueryPage,
|
||||
Self::FetchQueryPage,
|
||||
@@ -1308,6 +1312,8 @@ impl AgentMethod {
|
||||
Self::ListConstraints => "list_constraints",
|
||||
Self::ListPartitions => "list_partitions",
|
||||
Self::ListSubpartitions => "list_subpartitions",
|
||||
Self::GetTablePartitionStatus => "get_table_partition_status",
|
||||
Self::GetTablePartitioning => "get_table_partitioning",
|
||||
Self::ExecuteQuery => "execute_query",
|
||||
Self::ExecuteQueryPage => "execute_query_page",
|
||||
Self::FetchQueryPage => "fetch_query_page",
|
||||
@@ -2336,6 +2342,36 @@ impl AgentDriverClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_table_partition_status<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<T, String> {
|
||||
self.call_method_with_timeout(
|
||||
AgentMethod::GetTablePartitionStatus,
|
||||
agent_schema_table_params(database, schema, table),
|
||||
timeout_duration,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_table_partitioning<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<T, String> {
|
||||
self.call_method_with_timeout(
|
||||
AgentMethod::GetTablePartitioning,
|
||||
agent_schema_table_params(database, schema, table),
|
||||
timeout_duration,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_table_ddl<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
database: &str,
|
||||
@@ -5118,6 +5154,8 @@ for line in sys.stdin:
|
||||
assert_eq!(AgentMethod::ListConstraints.as_str(), "list_constraints");
|
||||
assert_eq!(AgentMethod::ListPartitions.as_str(), "list_partitions");
|
||||
assert_eq!(AgentMethod::ListSubpartitions.as_str(), "list_subpartitions");
|
||||
assert_eq!(AgentMethod::GetTablePartitionStatus.as_str(), "get_table_partition_status");
|
||||
assert_eq!(AgentMethod::GetTablePartitioning.as_str(), "get_table_partitioning");
|
||||
assert_eq!(AgentMethod::GetTableDdl.as_str(), "get_table_ddl");
|
||||
assert_eq!(AgentMethod::ExecuteQuery.as_str(), "execute_query");
|
||||
assert_eq!(AgentMethod::ExecuteQueryPage.as_str(), "execute_query_page");
|
||||
|
||||
@@ -11,7 +11,7 @@ use rustls::server::ParsedCertificate;
|
||||
use sqlparser::ast::{SetExpr, Statement};
|
||||
use sqlparser::dialect::PostgreSqlDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::fs::File;
|
||||
use std::future::Future;
|
||||
@@ -37,8 +37,8 @@ use crate::types::{
|
||||
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, ConstraintInfo,
|
||||
CustomTypeDdl, CustomTypeDetails, CustomTypeDomainConstraint, CustomTypeKind, CustomTypeMember,
|
||||
CustomTypeProperties, DatabaseInfo, DatabaseStorageInfo, ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo,
|
||||
ObjectInfo, ObjectStatistics, OwnerInfo, QueryMessage, QueryResult, RuleInfo, SchemaInfo, SequenceInfo,
|
||||
SpatialColumnBuilder, TableInfo, TriggerInfo,
|
||||
ObjectInfo, ObjectStatistics, OwnerInfo, PgPartitionBound, PgPartitionKind, PgPartitionNode, PgTablePartitioning,
|
||||
QueryMessage, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, SpatialColumnBuilder, TableInfo, TriggerInfo,
|
||||
};
|
||||
|
||||
pub const GAUSSDB_COMPATIBILITY_SQL: &str =
|
||||
@@ -3951,6 +3951,390 @@ pub async fn get_table_partition_key(pool: &Pool, schema: &str, table: &str) ->
|
||||
Ok(get_table_partition_info(pool, schema, table).await?.key)
|
||||
}
|
||||
|
||||
/// The partitioning strategy of a single PostgreSQL partitioned parent, read
|
||||
/// from `pg_partitioned_table` so expression keys and multi-column keys come
|
||||
/// back structured instead of as concatenated `pg_get_partkeydef` text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PostgresPartitionStrategy {
|
||||
pub kind: PgPartitionKind,
|
||||
pub key_definition: String,
|
||||
/// Key columns, in key order. Empty for a pure-expression key.
|
||||
pub columns: Vec<String>,
|
||||
/// `pg_get_expr(partexprs)` text, set only for expression keys.
|
||||
pub expression: Option<String>,
|
||||
}
|
||||
|
||||
fn postgres_partition_strategy_sql() -> &'static str {
|
||||
"SELECT p.partstrat::text, \
|
||||
pg_catalog.pg_get_partkeydef(c.oid) AS key_def, \
|
||||
COALESCE(ARRAY( \
|
||||
SELECT a.attname::text \
|
||||
FROM unnest(p.partattrs) WITH ORDINALITY AS u(attnum, ord) \
|
||||
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = u.attnum \
|
||||
WHERE u.attnum > 0 \
|
||||
ORDER BY u.ord \
|
||||
), '{}') AS key_columns, \
|
||||
pg_catalog.pg_get_expr(p.partexprs, c.oid) AS key_expression \
|
||||
FROM pg_catalog.pg_partitioned_table p \
|
||||
JOIN pg_catalog.pg_class c ON c.oid = p.partrelid \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||
WHERE n.nspname = $1 AND c.relname = $2"
|
||||
}
|
||||
|
||||
/// Pre-10 servers have no `pg_partitioned_table`; return no row so the caller
|
||||
/// sees `None` (the relation is a plain table there).
|
||||
fn postgres_partition_strategy_compat_sql() -> &'static str {
|
||||
"SELECT NULL::text, NULL::text, '{}'::text[], NULL::text WHERE false"
|
||||
}
|
||||
|
||||
pub async fn get_table_partition_strategy(
|
||||
pool: &Pool,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Option<PostgresPartitionStrategy>, String> {
|
||||
let schema = if schema.is_empty() { "public" } else { schema };
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table];
|
||||
let rows = query_with_compat_fallback(
|
||||
"get_table_partition_strategy",
|
||||
&[postgres_partition_strategy_sql(), postgres_partition_strategy_compat_sql()],
|
||||
|sql| postgres_query_cached(&client, sql, ¶ms),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.first().and_then(|row| {
|
||||
let strategy = row.try_get::<_, Option<String>>(0).ok().flatten()?;
|
||||
let kind = pg_partition_kind_from_strategy(&strategy)?;
|
||||
let key_definition = row.try_get::<_, Option<String>>(1).ok().flatten().unwrap_or_default();
|
||||
let columns = row.try_get::<_, Option<Vec<String>>>(2).ok().flatten().unwrap_or_default();
|
||||
let expression = row.try_get::<_, Option<String>>(3).ok().flatten().filter(|value| !value.trim().is_empty());
|
||||
Some(PostgresPartitionStrategy { kind, key_definition, columns, expression })
|
||||
}))
|
||||
}
|
||||
|
||||
/// `pg_partitioned_table.partstrat`: `r` = range, `l` = list, `h` = hash.
|
||||
pub fn pg_partition_kind_from_strategy(strategy: &str) -> Option<PgPartitionKind> {
|
||||
match strategy.trim() {
|
||||
"r" => Some(PgPartitionKind::Range),
|
||||
"l" => Some(PgPartitionKind::List),
|
||||
"h" => Some(PgPartitionKind::Hash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the strategy kind from `pg_get_partkeydef` output (`RANGE (col)`, …).
|
||||
/// Used for descendants in a partition tree, where the full strategy query is
|
||||
/// unnecessary (the kind is the only thing a nested node needs to render).
|
||||
pub fn pg_partition_kind_from_keydef(definition: &str) -> Option<PgPartitionKind> {
|
||||
let definition = definition.trim_start();
|
||||
let head = definition.split(|ch: char| ch.is_whitespace() || ch == '(').next()?;
|
||||
match head.to_ascii_uppercase().as_str() {
|
||||
"RANGE" => Some(PgPartitionKind::Range),
|
||||
"LIST" => Some(PgPartitionKind::List),
|
||||
"HASH" => Some(PgPartitionKind::Hash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the text PostgreSQL renders for `pg_get_expr(relpartbound, oid, true)`.
|
||||
///
|
||||
/// Returns `None` for an unrecognized shape so callers can fall back to the raw
|
||||
/// definition. Recognized forms:
|
||||
/// * `DEFAULT`
|
||||
/// * `FOR VALUES FROM (...) TO (...)` — RANGE, including `MINVALUE`/`MAXVALUE`
|
||||
/// * `FOR VALUES IN (...)` — LIST
|
||||
/// * `FOR VALUES WITH (MODULUS n, REMAINDER m)` — HASH (the catalog renders
|
||||
/// this lowercase, e.g. `modulus 2, remainder 0`)
|
||||
///
|
||||
/// Values are kept as SQL literal text so they round-trip byte-for-byte.
|
||||
pub fn parse_pg_partition_bound(definition: &str) -> Option<PgPartitionBound> {
|
||||
let definition = definition.trim();
|
||||
if definition.eq_ignore_ascii_case("DEFAULT") {
|
||||
return Some(PgPartitionBound::Default);
|
||||
}
|
||||
let rest = strip_ascii_prefix_ci(definition, "FOR VALUES")?.trim_start();
|
||||
if let Some(body) = strip_ascii_prefix_ci(rest, "FROM") {
|
||||
let (from, after_from) = take_paren_group(body.trim_start())?;
|
||||
let after_from = strip_ascii_prefix_ci(after_from.trim_start(), "TO")?;
|
||||
let (to, _) = take_paren_group(after_from.trim_start())?;
|
||||
return Some(PgPartitionBound::Range { from: split_bound_items(&from), to: split_bound_items(&to) });
|
||||
}
|
||||
if let Some(body) = strip_ascii_prefix_ci(rest, "IN") {
|
||||
let (values, _) = take_paren_group(body.trim_start())?;
|
||||
return Some(PgPartitionBound::List { values: split_bound_items(&values) });
|
||||
}
|
||||
if let Some(body) = strip_ascii_prefix_ci(rest, "WITH") {
|
||||
let (options, _) = take_paren_group(body.trim_start())?;
|
||||
let mut modulus = None;
|
||||
let mut remainder = None;
|
||||
for option in split_top_level_commas(&options) {
|
||||
let mut parts = option.split_whitespace();
|
||||
let key = parts.next()?.to_ascii_lowercase();
|
||||
let value: i32 = parts.next()?.parse().ok()?;
|
||||
match key.as_str() {
|
||||
"modulus" => modulus = Some(value),
|
||||
"remainder" => remainder = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Some(PgPartitionBound::Hash { modulus: modulus?, remainder: remainder? });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Case-insensitive prefix strip that also requires a token boundary, so
|
||||
/// `IN` never matches the start of `INTO` and `TO` never matches `TOAST`.
|
||||
fn strip_ascii_prefix_ci<'a>(input: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let head = input.get(..prefix.len())?;
|
||||
if !head.eq_ignore_ascii_case(prefix) {
|
||||
return None;
|
||||
}
|
||||
let rest = &input[prefix.len()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(ch) if ch.is_whitespace() || ch == '(' => Some(rest),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `( ... )` off the front of `input`, honoring single quotes, double
|
||||
/// quotes, `''` escapes, and nested parentheses. Returns the inner text and
|
||||
/// the remainder after the closing paren.
|
||||
fn take_paren_group(input: &str) -> Option<(String, &str)> {
|
||||
let input = input.trim_start();
|
||||
let mut depth = 0i32;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut close_index = None;
|
||||
let mut chars = input.char_indices().peekable();
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
match ch {
|
||||
'\'' if !in_double => {
|
||||
if in_single && chars.peek().map(|(_, next)| *next) == Some('\'') {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
in_single = !in_single;
|
||||
}
|
||||
'"' if !in_single => in_double = !in_double,
|
||||
'(' if !in_single && !in_double => depth += 1,
|
||||
')' if !in_single && !in_double => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close_index = Some(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let close_index = close_index?;
|
||||
Some((input[1..close_index].to_string(), &input[close_index + 1..]))
|
||||
}
|
||||
|
||||
fn split_bound_items(input: &str) -> Vec<String> {
|
||||
split_top_level_commas(input).into_iter().filter(|item| !item.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Splits on top-level commas only; commas inside quotes, nested parentheses,
|
||||
/// or `''` escapes stay in the item.
|
||||
fn split_top_level_commas(input: &str) -> Vec<String> {
|
||||
let mut items = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut depth = 0i32;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\'' if !in_double => {
|
||||
if in_single && chars.peek() == Some(&'\'') {
|
||||
current.push('\'');
|
||||
current.push('\'');
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
in_single = !in_single;
|
||||
current.push(ch);
|
||||
}
|
||||
'"' if !in_single => {
|
||||
in_double = !in_double;
|
||||
current.push(ch);
|
||||
}
|
||||
'(' if !in_single && !in_double => {
|
||||
depth += 1;
|
||||
current.push(ch);
|
||||
}
|
||||
')' if !in_single && !in_double => {
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
',' if !in_single && !in_double && depth == 0 => {
|
||||
items.push(current.trim().to_string());
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
items.push(current.trim().to_string());
|
||||
items
|
||||
}
|
||||
|
||||
/// Cheap per-relation size/count estimates for a set of relation oids.
|
||||
/// Best-effort: an empty map on failure just means the UI omits the columns.
|
||||
fn postgres_partition_relation_stats_sql() -> &'static str {
|
||||
"SELECT c.oid::bigint, \
|
||||
CASE WHEN c.relkind = 'f' THEN NULL ELSE c.reltuples::bigint END, \
|
||||
CASE WHEN c.relkind = 'f' THEN NULL ELSE pg_catalog.pg_total_relation_size(c.oid) END \
|
||||
FROM pg_catalog.pg_class c \
|
||||
WHERE c.oid::bigint = ANY($1)"
|
||||
}
|
||||
|
||||
pub async fn get_partition_relation_stats(
|
||||
pool: &Pool,
|
||||
oids: &[i64],
|
||||
) -> Result<HashMap<i64, (Option<i64>, Option<i64>)>, String> {
|
||||
if oids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let owned: Vec<i64> = oids.to_vec();
|
||||
let rows = postgres_query_cached(&client, postgres_partition_relation_stats_sql(), &[&owned])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let oid = row.try_get::<_, i64>(0).ok()?;
|
||||
let rows = row.try_get::<_, Option<i64>>(1).ok().flatten().filter(|value| *value >= 0);
|
||||
let bytes = row.try_get::<_, Option<i64>>(2).ok().flatten().filter(|value| *value >= 0);
|
||||
Some((oid, (rows, bytes)))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `current_setting('server_version_num')` as an integer (e.g. 140019 for
|
||||
/// 14.19), or `None` when the server does not report it.
|
||||
pub async fn get_server_version_num(pool: &Pool) -> Result<Option<i32>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let rows = postgres_query_cached(&client, "SELECT current_setting('server_version_num')::int", &[])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.first().and_then(|row| row.try_get::<_, i32>(0).ok()))
|
||||
}
|
||||
|
||||
/// Full structured partitioning view of one relation, rooted at it. Returns a
|
||||
/// default (all-false/empty) value for a plain, non-partitioned table.
|
||||
pub async fn get_table_partitioning(pool: &Pool, schema: &str, table: &str) -> Result<PgTablePartitioning, String> {
|
||||
let schema = if schema.is_empty() { "public" } else { schema };
|
||||
let tree = fetch_postgres_partition_tree(pool, schema, table).await?;
|
||||
let tree_oids: HashSet<i64> = tree.iter().map(|node| node.oid).collect();
|
||||
// The requested relation is the root: the only tree node whose parent (if
|
||||
// any) is not also in this tree. An empty tree means the relation is not a
|
||||
// table/partition at all (view, sequence, missing).
|
||||
let Some(root) = tree.iter().find(|node| !node.parent_oid.is_some_and(|parent| tree_oids.contains(&parent))) else {
|
||||
return Ok(PgTablePartitioning::default());
|
||||
};
|
||||
let is_partitioned = root.partition_info.key.is_some();
|
||||
let is_partition = root.partition_info.is_partition;
|
||||
if !is_partitioned && !is_partition {
|
||||
return Ok(PgTablePartitioning::default());
|
||||
}
|
||||
|
||||
let strategy =
|
||||
if is_partitioned { get_table_partition_strategy(pool, &root.schema, &root.table).await? } else { None };
|
||||
|
||||
let oids: Vec<i64> = tree.iter().map(|node| node.oid).collect();
|
||||
let stats = get_partition_relation_stats(pool, &oids).await.unwrap_or_default();
|
||||
|
||||
let nodes_by_oid: HashMap<i64, &PostgresPartitionTreeNode> = tree.iter().map(|node| (node.oid, node)).collect();
|
||||
let mut children_by_parent: HashMap<i64, Vec<i64>> = HashMap::new();
|
||||
for node in &tree {
|
||||
if let Some(parent_oid) = node.parent_oid {
|
||||
if tree_oids.contains(&parent_oid) {
|
||||
children_by_parent.entry(parent_oid).or_default().push(node.oid);
|
||||
}
|
||||
}
|
||||
}
|
||||
for child_oids in children_by_parent.values_mut() {
|
||||
child_oids.sort_by(|left, right| {
|
||||
let left = nodes_by_oid.get(left).map(|node| node.table.as_str()).unwrap_or_default();
|
||||
let right = nodes_by_oid.get(right).map(|node| node.table.as_str()).unwrap_or_default();
|
||||
left.cmp(right)
|
||||
});
|
||||
}
|
||||
|
||||
let mut visited: HashSet<i64> = HashSet::new();
|
||||
visited.insert(root.oid);
|
||||
let partitions = build_partition_nodes(root.oid, &nodes_by_oid, &children_by_parent, &stats, &mut visited);
|
||||
let default_partition =
|
||||
partitions.iter().find(|node| node.bound == Some(PgPartitionBound::Default)).map(|node| node.name.clone());
|
||||
// Best effort: a missing version just hides the CONCURRENTLY option.
|
||||
let server_version_num = get_server_version_num(pool).await.unwrap_or(None);
|
||||
|
||||
Ok(PgTablePartitioning {
|
||||
is_partitioned,
|
||||
is_partition,
|
||||
parent: match (root.partition_info.parent_schema.as_deref(), root.partition_info.parent_table.as_deref()) {
|
||||
(Some(schema), Some(table)) => Some(format!("{schema}.{table}")),
|
||||
_ => None,
|
||||
},
|
||||
parent_schema: root.partition_info.parent_schema.clone(),
|
||||
parent_table: root.partition_info.parent_table.clone(),
|
||||
own_bound: root.partition_info.bound.as_deref().and_then(parse_pg_partition_bound),
|
||||
strategy: strategy.as_ref().map(|strategy| strategy.kind),
|
||||
key_definition: strategy
|
||||
.as_ref()
|
||||
.map(|strategy| strategy.key_definition.clone())
|
||||
.or_else(|| root.partition_info.key.clone()),
|
||||
key_columns: strategy.as_ref().map(|strategy| strategy.columns.clone()).unwrap_or_default(),
|
||||
key_expression: strategy.and_then(|strategy| strategy.expression),
|
||||
default_partition,
|
||||
partitions,
|
||||
server_version_num,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively materializes the children of `parent_oid`. `visited` guards
|
||||
/// against a corrupted catalog (or a non-PostgreSQL fork) whose `pg_inherits`
|
||||
/// data forms a cycle, which would otherwise recurse forever.
|
||||
fn build_partition_nodes(
|
||||
parent_oid: i64,
|
||||
nodes_by_oid: &HashMap<i64, &PostgresPartitionTreeNode>,
|
||||
children_by_parent: &HashMap<i64, Vec<i64>>,
|
||||
stats: &HashMap<i64, (Option<i64>, Option<i64>)>,
|
||||
visited: &mut HashSet<i64>,
|
||||
) -> Vec<PgPartitionNode> {
|
||||
let Some(child_oids) = children_by_parent.get(&parent_oid) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut nodes = Vec::with_capacity(child_oids.len());
|
||||
for child_oid in child_oids {
|
||||
if !visited.insert(*child_oid) {
|
||||
continue;
|
||||
}
|
||||
let Some(node) = nodes_by_oid.get(child_oid) else {
|
||||
continue;
|
||||
};
|
||||
let children = build_partition_nodes(*child_oid, nodes_by_oid, children_by_parent, stats, visited);
|
||||
let (row_estimate, total_bytes) = stats.get(child_oid).copied().unwrap_or((None, None));
|
||||
let bound_definition = node.partition_info.bound.clone();
|
||||
let strategy = node.partition_info.key.as_deref().and_then(pg_partition_kind_from_keydef);
|
||||
nodes.push(PgPartitionNode {
|
||||
schema: node.schema.clone(),
|
||||
name: node.table.clone(),
|
||||
strategy,
|
||||
key_definition: node.partition_info.key.clone(),
|
||||
bound: bound_definition.as_deref().and_then(parse_pg_partition_bound),
|
||||
bound_definition,
|
||||
is_leaf: children.is_empty(),
|
||||
row_estimate,
|
||||
total_bytes,
|
||||
children,
|
||||
});
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// Classifies one row of `postgres_table_partition_local_objects_sql` (or its
|
||||
/// `_for_relations` sibling, which has the same `object_kind`/`object_name`/
|
||||
/// `object_type` columns plus a leading `relid`) into `entry`. Shared so a
|
||||
@@ -14761,4 +15145,87 @@ mod tests {
|
||||
assert!(!visible_sql.contains("NOT IN"));
|
||||
assert!(!visible_sql.contains("NOT LIKE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_partition_kind_from_strategy_maps_catalog_letters() {
|
||||
assert_eq!(pg_partition_kind_from_strategy("r"), Some(PgPartitionKind::Range));
|
||||
assert_eq!(pg_partition_kind_from_strategy("l"), Some(PgPartitionKind::List));
|
||||
assert_eq!(pg_partition_kind_from_strategy("h"), Some(PgPartitionKind::Hash));
|
||||
assert_eq!(pg_partition_kind_from_strategy("x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_partition_kind_from_keydef_uses_the_leading_keyword() {
|
||||
assert_eq!(pg_partition_kind_from_keydef("RANGE (sold_on)"), Some(PgPartitionKind::Range));
|
||||
assert_eq!(pg_partition_kind_from_keydef("LIST (region)"), Some(PgPartitionKind::List));
|
||||
assert_eq!(pg_partition_kind_from_keydef("HASH (id)"), Some(PgPartitionKind::Hash));
|
||||
assert_eq!(pg_partition_kind_from_keydef("RANGE (abs(v))"), Some(PgPartitionKind::Range));
|
||||
assert_eq!(pg_partition_kind_from_keydef("UNKNOWN (x)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_partition_bound_reads_range_bounds() {
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')"),
|
||||
Some(PgPartitionBound::Range {
|
||||
from: vec!["'2024-01-01'".to_string()],
|
||||
to: vec!["'2025-01-01'".to_string()],
|
||||
})
|
||||
);
|
||||
// MINVALUE / MAXVALUE are keywords, kept verbatim without quoting.
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES FROM (MINVALUE) TO (0)"),
|
||||
Some(PgPartitionBound::Range { from: vec!["MINVALUE".to_string()], to: vec!["0".to_string()] })
|
||||
);
|
||||
// Multi-column range keeps each tuple position separate.
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES FROM ('a', 'a') TO ('b', MAXVALUE)"),
|
||||
Some(PgPartitionBound::Range {
|
||||
from: vec!["'a'".to_string(), "'a'".to_string()],
|
||||
to: vec!["'b'".to_string(), "MAXVALUE".to_string()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_partition_bound_reads_list_and_hash_and_default() {
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES IN ('a', 'b')"),
|
||||
Some(PgPartitionBound::List { values: vec!["'a'".to_string(), "'b'".to_string()] })
|
||||
);
|
||||
// The catalog renders hash options lowercase.
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES WITH (modulus 2, remainder 1)"),
|
||||
Some(PgPartitionBound::Hash { modulus: 2, remainder: 1 })
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES WITH (MODULUS 4, REMAINDER 3)"),
|
||||
Some(PgPartitionBound::Hash { modulus: 4, remainder: 3 })
|
||||
);
|
||||
assert_eq!(parse_pg_partition_bound("DEFAULT"), Some(PgPartitionBound::Default));
|
||||
assert_eq!(parse_pg_partition_bound(" default "), Some(PgPartitionBound::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_partition_bound_keeps_commas_inside_quotes_and_calls() {
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES IN ('a,b', 'c')"),
|
||||
Some(PgPartitionBound::List { values: vec!["'a,b'".to_string(), "'c'".to_string()] })
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pg_partition_bound("FOR VALUES FROM (lower('A''B')) TO (lower('C'))"),
|
||||
Some(PgPartitionBound::Range {
|
||||
from: vec!["lower('A''B')".to_string()],
|
||||
to: vec!["lower('C')".to_string()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_partition_bound_rejects_unrecognized_input() {
|
||||
assert_eq!(parse_pg_partition_bound(""), None);
|
||||
assert_eq!(parse_pg_partition_bound("NOT A BOUND"), None);
|
||||
// `IN` must not match the start of `INTO`.
|
||||
assert_eq!(parse_pg_partition_bound("FOR VALUES INTO (1)"), None);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod foreign_keys;
|
||||
mod indexes;
|
||||
mod mysql_engine;
|
||||
mod owner;
|
||||
mod partitions;
|
||||
mod triggers;
|
||||
mod types;
|
||||
mod util;
|
||||
@@ -19,6 +20,7 @@ mod tests;
|
||||
pub use column_alter::build_single_column_alter_sql;
|
||||
pub use create_table::build_create_table_sql;
|
||||
pub use owner::build_table_owner_change_sql;
|
||||
pub use partitions::{build_create_partitioned_table_sql, build_table_partition_operation_sql};
|
||||
pub use types::*;
|
||||
|
||||
pub use column_alter::{
|
||||
|
||||
@@ -23,7 +23,20 @@ fn is_sqlite_integer_family_type(data_type: &str) -> bool {
|
||||
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate}(")))
|
||||
}
|
||||
|
||||
pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
build_create_table_sql_with_partition_clause(options, None)
|
||||
}
|
||||
|
||||
/// Shared implementation for plain and partitioned `CREATE TABLE`.
|
||||
///
|
||||
/// `partition_clause` is a ready-made `PARTITION BY ...` body (no leading
|
||||
/// keyword), appended to the `CREATE TABLE` statement. Keeping it as a separate
|
||||
/// entry point means `build_create_table_sql`'s signature — used from ~160 call
|
||||
/// sites and integration tests — stays unchanged.
|
||||
pub(super) fn build_create_table_sql_with_partition_clause(
|
||||
mut options: TableStructureSqlOptions,
|
||||
partition_clause: Option<String>,
|
||||
) -> TableStructureSqlResult {
|
||||
let capabilities;
|
||||
let dialect;
|
||||
if options.is_gaussdb_m_mode {
|
||||
@@ -177,7 +190,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
|
||||
column_definitions.push(format!("PRIMARY KEY ({pk_list})"));
|
||||
}
|
||||
|
||||
statements.push(format!("CREATE TABLE {table} (\n {}\n);", column_definitions.join(",\n ")));
|
||||
let create_table = format!("CREATE TABLE {table} (\n {}\n)", column_definitions.join(",\n "));
|
||||
let create_table = match partition_clause {
|
||||
Some(clause) => format!("{create_table} {clause}"),
|
||||
None => create_table,
|
||||
};
|
||||
statements.push(format!("{create_table};"));
|
||||
|
||||
if let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) {
|
||||
if let Some(statement) = statements.last_mut() {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::create_table::build_create_table_sql_with_partition_clause;
|
||||
use super::dialect::{capabilities_for, StructureDialect};
|
||||
use super::types::{
|
||||
TablePartitionBoundDraft, TablePartitionDefinition, TablePartitionOperation, TablePartitionOperationKind,
|
||||
TablePartitionSqlOptions, TableStructureSqlOptions, TableStructureSqlResult,
|
||||
};
|
||||
use super::util::quote_ident;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::PgPartitionKind;
|
||||
|
||||
/// Engines whose declarative-partition DDL has been verified. Only PostgreSQL
|
||||
/// and KingbaseES expose the same `pg_partitioned_table` catalog and
|
||||
/// `CREATE TABLE ... PARTITION OF` / `ATTACH` / `DETACH` syntax. openGauss-based
|
||||
/// engines (openGauss, Vastbase, GaussDB) use a different `pg_partition`
|
||||
/// catalog and are intentionally excluded until validated.
|
||||
fn supports_partition_ddl(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(database_type, Some(DatabaseType::Postgres | DatabaseType::Kingbase))
|
||||
}
|
||||
|
||||
/// Builds `CREATE TABLE ... PARTITION BY ...` for a table being created.
|
||||
///
|
||||
/// Kept separate from `build_create_table_sql` so the widely-used plain builder
|
||||
/// keeps its signature; shares its implementation through
|
||||
/// `build_create_table_sql_with_partition_clause`.
|
||||
pub fn build_create_partitioned_table_sql(
|
||||
options: TableStructureSqlOptions,
|
||||
definition: TablePartitionDefinition,
|
||||
) -> TableStructureSqlResult {
|
||||
let dialect = capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
|
||||
if dialect != StructureDialect::Postgres || !supports_partition_ddl(options.database_type) {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Partitioning is not supported for this database engine.".to_string()],
|
||||
};
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let Some(clause) = format_partition_clause(&definition, &mut warnings) else {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
};
|
||||
build_create_table_sql_with_partition_clause(options, Some(clause))
|
||||
}
|
||||
|
||||
/// Renders the body of a `PARTITION BY` clause (without the leading keyword),
|
||||
/// e.g. `RANGE (region, sold_on)`.
|
||||
fn format_partition_clause(definition: &TablePartitionDefinition, warnings: &mut Vec<String>) -> Option<String> {
|
||||
let keyword = match definition.kind {
|
||||
PgPartitionKind::Range => "RANGE",
|
||||
PgPartitionKind::List => "LIST",
|
||||
PgPartitionKind::Hash => "HASH",
|
||||
};
|
||||
let expression = definition.expression.trim();
|
||||
let key = if !expression.is_empty() {
|
||||
expression.to_string()
|
||||
} else {
|
||||
let columns = definition
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.trim())
|
||||
.filter(|column| !column.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if columns.is_empty() {
|
||||
warnings.push("A partition key needs at least one column or an expression.".to_string());
|
||||
return None;
|
||||
}
|
||||
columns.iter().map(|column| quote_ident(StructureDialect::Postgres, column)).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
Some(format!("PARTITION BY {keyword} ({key})"))
|
||||
}
|
||||
|
||||
/// Builds the DDL for explicit partition maintenance operations
|
||||
/// (create / attach / detach / drop).
|
||||
///
|
||||
/// Gated to the engines in [`supports_partition_ddl`]; a refusal empties the
|
||||
/// statement list and pushes one warning. Callers surface the warning and block
|
||||
/// the save, so an unsupported request never silently degrades.
|
||||
pub fn build_table_partition_operation_sql(options: TablePartitionSqlOptions) -> TableStructureSqlResult {
|
||||
if options.operations.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: Vec::new() };
|
||||
}
|
||||
let dialect = capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
|
||||
if dialect != StructureDialect::Postgres || !supports_partition_ddl(options.database_type) {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Partition operations are not supported for this database engine.".to_string()],
|
||||
};
|
||||
}
|
||||
|
||||
let default_schema = options.schema.as_deref().unwrap_or("");
|
||||
let mut warnings = Vec::new();
|
||||
let mut statements = Vec::new();
|
||||
for operation in &options.operations {
|
||||
if let Some(statement) = build_operation_sql(operation, default_schema, &options.table_name, &mut warnings) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
|
||||
fn build_operation_sql(
|
||||
operation: &TablePartitionOperation,
|
||||
default_schema: &str,
|
||||
default_parent_table: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let dialect = StructureDialect::Postgres;
|
||||
let parent_schema = pick(&operation.parent_schema, default_schema);
|
||||
let parent_table = pick(&operation.parent_table, default_parent_table);
|
||||
let child_schema = pick(&operation.schema, &parent_schema);
|
||||
let name = operation.name.trim();
|
||||
if parent_table.is_empty() {
|
||||
warnings.push("A partition operation requires a parent table.".to_string());
|
||||
return None;
|
||||
}
|
||||
if name.is_empty() {
|
||||
warnings.push("A partition operation requires a partition name.".to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
let parent = qualify(dialect, &parent_schema, &parent_table);
|
||||
let child = qualify(dialect, &child_schema, name);
|
||||
match operation.kind {
|
||||
TablePartitionOperationKind::Create => {
|
||||
let bound = require_bound(operation, "create", warnings)?;
|
||||
Some(format!("CREATE TABLE {child} PARTITION OF {parent} {bound};"))
|
||||
}
|
||||
TablePartitionOperationKind::Attach => {
|
||||
let bound = require_bound(operation, "attach", warnings)?;
|
||||
Some(format!("ALTER TABLE {parent} ATTACH PARTITION {child} {bound};"))
|
||||
}
|
||||
TablePartitionOperationKind::Detach => {
|
||||
reject_bound(operation, "detach", warnings)?;
|
||||
let concurrently = if operation.concurrently { " CONCURRENTLY" } else { "" };
|
||||
Some(format!("ALTER TABLE {parent} DETACH PARTITION {child}{concurrently};"))
|
||||
}
|
||||
TablePartitionOperationKind::Drop => {
|
||||
reject_bound(operation, "drop", warnings)?;
|
||||
Some(format!("DROP TABLE {child};"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_bound(operation: &TablePartitionOperation, action: &str, warnings: &mut Vec<String>) -> Option<String> {
|
||||
let Some(bound) = operation.bound.as_ref() else {
|
||||
warnings.push(format!("A partition {action} operation requires a bound definition."));
|
||||
return None;
|
||||
};
|
||||
format_bound(bound, warnings)
|
||||
}
|
||||
|
||||
fn reject_bound(operation: &TablePartitionOperation, action: &str, warnings: &mut Vec<String>) -> Option<()> {
|
||||
if operation.bound.is_some() {
|
||||
warnings.push(format!("A partition {action} operation must not carry a bound definition."));
|
||||
return None;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Renders a bound as the `FOR VALUES ...` clause PostgreSQL expects (or a bare
|
||||
/// `DEFAULT`). Returns `None` and records a warning when the bound is
|
||||
/// malformed, so the caller can drop just that statement.
|
||||
fn format_bound(bound: &TablePartitionBoundDraft, warnings: &mut Vec<String>) -> Option<String> {
|
||||
match bound {
|
||||
TablePartitionBoundDraft::Default => Some("DEFAULT".to_string()),
|
||||
TablePartitionBoundDraft::Range { from, to } => {
|
||||
if from.is_empty() || from.len() != to.len() {
|
||||
warnings
|
||||
.push("A RANGE partition bound needs a FROM and TO tuple of equal, non-zero length.".to_string());
|
||||
return None;
|
||||
}
|
||||
let from = from.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
|
||||
let to = to.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
|
||||
Some(format!("FOR VALUES FROM ({from}) TO ({to})"))
|
||||
}
|
||||
TablePartitionBoundDraft::List { values } => {
|
||||
if values.is_empty() {
|
||||
warnings.push("A LIST partition bound needs at least one value.".to_string());
|
||||
return None;
|
||||
}
|
||||
let values = values.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
|
||||
Some(format!("FOR VALUES IN ({values})"))
|
||||
}
|
||||
TablePartitionBoundDraft::Hash { modulus, remainder } => {
|
||||
if *modulus <= 0 || *remainder < 0 || remainder >= modulus {
|
||||
warnings.push("A HASH partition bound needs MODULUS > 0 and 0 <= REMAINDER < MODULUS.".to_string());
|
||||
return None;
|
||||
}
|
||||
Some(format!("FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick(value: &str, fallback: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
fallback.trim().to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn qualify(dialect: StructureDialect, schema: &str, table: &str) -> String {
|
||||
if schema.is_empty() {
|
||||
quote_ident(dialect, table)
|
||||
} else {
|
||||
format!("{}.{}", quote_ident(dialect, schema), quote_ident(dialect, table))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::PgPartitionKind;
|
||||
|
||||
fn column(name: &str) -> EditableStructureColumn {
|
||||
EditableStructureColumn {
|
||||
@@ -8539,3 +8540,389 @@ fn postgres_expression_index_opclass_round_trips_from_indclass() {
|
||||
// The bare expression text no longer carries the opclass, so it cannot be duplicated.
|
||||
assert!(!sql.contains("gin_trgm_ops gin_trgm_ops"), "Opclass must not be duplicated, got: {sql}");
|
||||
}
|
||||
|
||||
fn partition_options(
|
||||
database_type: DatabaseType,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
operations: Vec<TablePartitionOperation>,
|
||||
) -> TablePartitionSqlOptions {
|
||||
TablePartitionSqlOptions {
|
||||
database_type: Some(database_type),
|
||||
driver_profile: None,
|
||||
schema: schema.map(str::to_string),
|
||||
table_name: table_name.to_string(),
|
||||
operations,
|
||||
}
|
||||
}
|
||||
|
||||
fn partition_operation(
|
||||
kind: TablePartitionOperationKind,
|
||||
name: &str,
|
||||
bound: Option<TablePartitionBoundDraft>,
|
||||
) -> TablePartitionOperation {
|
||||
TablePartitionOperation {
|
||||
id: format!("op:{name}"),
|
||||
kind,
|
||||
parent_schema: String::new(),
|
||||
parent_table: String::new(),
|
||||
schema: String::new(),
|
||||
name: name.to_string(),
|
||||
bound,
|
||||
concurrently: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_create_uses_partition_of_with_each_bound_kind() {
|
||||
let range = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_2025",
|
||||
Some(TablePartitionBoundDraft::Range {
|
||||
from: vec!["'2025-01-01'".to_string()],
|
||||
to: vec!["'2026-01-01'".to_string()],
|
||||
}),
|
||||
)],
|
||||
));
|
||||
assert!(range.warnings.is_empty(), "{:?}", range.warnings);
|
||||
assert_eq!(
|
||||
range.statements,
|
||||
vec!["CREATE TABLE \"public\".\"sales_2025\" PARTITION OF \"public\".\"sales\" FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');"]
|
||||
);
|
||||
|
||||
let list = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"events",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"events_ab",
|
||||
Some(TablePartitionBoundDraft::List { values: vec!["'a'".to_string(), "'b'".to_string()] }),
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
list.statements,
|
||||
vec!["CREATE TABLE \"public\".\"events_ab\" PARTITION OF \"public\".\"events\" FOR VALUES IN ('a', 'b');"]
|
||||
);
|
||||
|
||||
let hash = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"h",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"h0",
|
||||
Some(TablePartitionBoundDraft::Hash { modulus: 2, remainder: 0 }),
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
hash.statements,
|
||||
vec!["CREATE TABLE \"public\".\"h0\" PARTITION OF \"public\".\"h\" FOR VALUES WITH (MODULUS 2, REMAINDER 0);"]
|
||||
);
|
||||
|
||||
let default = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_default",
|
||||
Some(TablePartitionBoundDraft::Default),
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
default.statements,
|
||||
vec!["CREATE TABLE \"public\".\"sales_default\" PARTITION OF \"public\".\"sales\" DEFAULT;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_attach_detach_and_drop_generate_explicit_statements() {
|
||||
let attach = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Attach,
|
||||
"sales_2025",
|
||||
Some(TablePartitionBoundDraft::Range {
|
||||
from: vec!["MINVALUE".to_string()],
|
||||
to: vec!["'2025-01-01'".to_string()],
|
||||
}),
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
attach.statements,
|
||||
vec!["ALTER TABLE \"public\".\"sales\" ATTACH PARTITION \"public\".\"sales_2025\" FOR VALUES FROM (MINVALUE) TO ('2025-01-01');"]
|
||||
);
|
||||
|
||||
let detach = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None)],
|
||||
));
|
||||
assert_eq!(detach.statements, vec!["ALTER TABLE \"public\".\"sales\" DETACH PARTITION \"public\".\"sales_2024\";"]);
|
||||
|
||||
let mut concurrent = partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None);
|
||||
concurrent.concurrently = true;
|
||||
let detach_concurrently = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![concurrent],
|
||||
));
|
||||
assert_eq!(
|
||||
detach_concurrently.statements,
|
||||
vec!["ALTER TABLE \"public\".\"sales\" DETACH PARTITION \"public\".\"sales_2024\" CONCURRENTLY;"]
|
||||
);
|
||||
|
||||
let drop = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(TablePartitionOperationKind::Drop, "sales_2024", None)],
|
||||
));
|
||||
assert_eq!(drop.statements, vec!["DROP TABLE \"public\".\"sales_2024\";"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_operation_can_target_an_explicit_parent_and_schema() {
|
||||
// Detaching the partition currently being edited: the parent is not the
|
||||
// edited table, and the child lives in another schema.
|
||||
let mut detach = partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None);
|
||||
detach.parent_schema = "analytics".to_string();
|
||||
detach.parent_table = "sales".to_string();
|
||||
detach.schema = "archive".to_string();
|
||||
|
||||
let result = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales_2024",
|
||||
vec![detach],
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE \"analytics\".\"sales\" DETACH PARTITION \"archive\".\"sales_2024\";"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_operations_are_refused_for_non_postgres() {
|
||||
let result = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Mysql,
|
||||
Some("db"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_2025",
|
||||
Some(TablePartitionBoundDraft::Default),
|
||||
)],
|
||||
));
|
||||
|
||||
assert!(result.statements.is_empty());
|
||||
assert_eq!(result.warnings, vec!["Partition operations are not supported for this database engine.".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_operations_are_supported_for_kingbase() {
|
||||
let result = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Kingbase,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_2025",
|
||||
Some(TablePartitionBoundDraft::Default),
|
||||
)],
|
||||
));
|
||||
|
||||
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["CREATE TABLE \"public\".\"sales_2025\" PARTITION OF \"public\".\"sales\" DEFAULT;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_operations_warn_on_malformed_bounds() {
|
||||
let mismatched = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"sales_x",
|
||||
Some(TablePartitionBoundDraft::Range {
|
||||
from: vec!["1".to_string()],
|
||||
to: vec!["2".to_string(), "3".to_string()],
|
||||
}),
|
||||
)],
|
||||
));
|
||||
assert!(mismatched.statements.is_empty());
|
||||
assert!(mismatched.warnings[0].contains("RANGE partition bound"));
|
||||
|
||||
let bad_hash = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"h",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"h9",
|
||||
Some(TablePartitionBoundDraft::Hash { modulus: 2, remainder: 2 }),
|
||||
)],
|
||||
));
|
||||
assert!(bad_hash.statements.is_empty());
|
||||
assert!(bad_hash.warnings[0].contains("HASH partition bound"));
|
||||
|
||||
let detach_with_bound = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Detach,
|
||||
"sales_2024",
|
||||
Some(TablePartitionBoundDraft::Default),
|
||||
)],
|
||||
));
|
||||
assert!(detach_with_bound.statements.is_empty());
|
||||
assert!(detach_with_bound.warnings[0].contains("must not carry a bound"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_operation_quotes_identifiers() {
|
||||
let result = build_table_partition_operation_sql(partition_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("we\"ird"),
|
||||
"sales",
|
||||
vec![partition_operation(
|
||||
TablePartitionOperationKind::Create,
|
||||
"part\"1",
|
||||
Some(TablePartitionBoundDraft::Default),
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["CREATE TABLE \"we\"\"ird\".\"part\"\"1\" PARTITION OF \"we\"\"ird\".\"sales\" DEFAULT;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_partitioned_table_appends_partition_by_clause() {
|
||||
let column = EditableStructureColumn {
|
||||
id: "sold_on".to_string(),
|
||||
name: "sold_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
is_nullable: false,
|
||||
default_value: String::new(),
|
||||
comment: String::new(),
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
original: None,
|
||||
original_position: None,
|
||||
marked_for_drop: false,
|
||||
character_set: String::new(),
|
||||
collation: String::new(),
|
||||
};
|
||||
let options = structure_change_options(DatabaseType::Postgres, Some("public"), "sales", vec![column]);
|
||||
|
||||
let result = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::Range,
|
||||
columns: vec!["sold_on".to_string()],
|
||||
expression: String::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
||||
assert_eq!(result.statements.len(), 1);
|
||||
assert!(
|
||||
result.statements[0].ends_with(") PARTITION BY RANGE (\"sold_on\");"),
|
||||
"unexpected statement: {}",
|
||||
result.statements[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_partitioned_table_uses_expression_and_multi_column_keys() {
|
||||
let options = structure_change_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"events",
|
||||
vec![column("region"), column("occurred_at")],
|
||||
);
|
||||
let expression = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::Hash,
|
||||
columns: Vec::new(),
|
||||
expression: "abs(id)".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(expression.statements[0].ends_with(") PARTITION BY HASH (abs(id));"), "{}", expression.statements[0]);
|
||||
|
||||
let options = structure_change_options(
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
"events",
|
||||
vec![column("region"), column("occurred_at")],
|
||||
);
|
||||
let multi = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::List,
|
||||
columns: vec!["region".to_string(), "occurred_at".to_string()],
|
||||
expression: String::new(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
multi.statements[0].ends_with(") PARTITION BY LIST (\"region\", \"occurred_at\");"),
|
||||
"{}",
|
||||
multi.statements[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_partitioned_table_requires_a_key_and_postgres() {
|
||||
let options = structure_change_options(DatabaseType::Postgres, Some("public"), "events", vec![column("id")]);
|
||||
let missing_key = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition { kind: PgPartitionKind::Range, columns: Vec::new(), expression: String::new() },
|
||||
);
|
||||
assert!(missing_key.statements.is_empty());
|
||||
assert!(missing_key.warnings[0].contains("at least one column"));
|
||||
|
||||
let options = structure_change_options(DatabaseType::Mysql, Some("db"), "events", vec![column("id")]);
|
||||
let mysql = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::Range,
|
||||
columns: vec!["id".to_string()],
|
||||
expression: String::new(),
|
||||
},
|
||||
);
|
||||
assert!(mysql.statements.is_empty());
|
||||
assert_eq!(mysql.warnings, vec!["Partitioning is not supported for this database engine.".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_partitioned_table_is_supported_for_kingbase() {
|
||||
let options = structure_change_options(DatabaseType::Kingbase, Some("public"), "events", vec![column("region")]);
|
||||
let result = build_create_partitioned_table_sql(
|
||||
options,
|
||||
TablePartitionDefinition {
|
||||
kind: PgPartitionKind::List,
|
||||
columns: vec!["region".to_string()],
|
||||
expression: String::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
||||
assert!(result.statements[0].ends_with(") PARTITION BY LIST (\"region\");"), "{}", result.statements[0]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::PgPartitionKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -258,6 +259,90 @@ pub struct TableStructureSqlOptions {
|
||||
pub is_gaussdb_m_mode: bool,
|
||||
}
|
||||
|
||||
/// Options for `build_table_partition_operation_sql`.
|
||||
///
|
||||
/// Partition maintenance is deliberately kept out of `TableStructureSqlOptions`:
|
||||
/// the operations are explicit user actions (never diffed against introspected
|
||||
/// state), and keeping them separate means a column/index draft cannot
|
||||
/// accidentally trigger or suppress partition DDL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TablePartitionSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
/// The edited table; also the default parent of every operation.
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub operations: Vec<TablePartitionOperation>,
|
||||
}
|
||||
|
||||
/// Declarative partitioning for a table being created (`CREATE TABLE ...
|
||||
/// PARTITION BY ...`). PostgreSQL only.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TablePartitionDefinition {
|
||||
pub kind: PgPartitionKind,
|
||||
/// Partition-key columns, used when `expression` is empty.
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
/// Raw partition-key expression (e.g. `date_trunc('month', ts)`), used
|
||||
/// instead of `columns` when non-empty.
|
||||
#[serde(default)]
|
||||
pub expression: String,
|
||||
}
|
||||
|
||||
/// One partition maintenance operation. The parent is the edited table unless
|
||||
/// `parent_table` names another relation (used when detaching the partition
|
||||
/// currently being edited).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TablePartitionOperation {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub kind: TablePartitionOperationKind,
|
||||
/// Partitioned parent's schema; empty falls back to the edited table's schema.
|
||||
#[serde(default)]
|
||||
pub parent_schema: String,
|
||||
/// Partitioned parent; empty falls back to the edited table.
|
||||
#[serde(default)]
|
||||
pub parent_table: String,
|
||||
/// Schema of the partition relation; empty falls back to the parent's schema.
|
||||
#[serde(default)]
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
/// Required for `create`/`attach`; must be absent for `detach`/`drop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bound: Option<TablePartitionBoundDraft>,
|
||||
/// Emit `DETACH PARTITION ... CONCURRENTLY` (PostgreSQL 14+).
|
||||
#[serde(default)]
|
||||
pub concurrently: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TablePartitionOperationKind {
|
||||
Create,
|
||||
Attach,
|
||||
Detach,
|
||||
Drop,
|
||||
}
|
||||
|
||||
/// Partition bound as entered in the UI. Values are SQL literal text; the
|
||||
/// builder only wraps them in the PostgreSQL `FOR VALUES` grammar, so `1`,
|
||||
/// `'2024-01-01'`, `MINVALUE`, and `MAXVALUE` all round-trip unchanged.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum TablePartitionBoundDraft {
|
||||
Range { from: Vec<String>, to: Vec<String> },
|
||||
List { values: Vec<String> },
|
||||
Hash { modulus: i32, remainder: i32 },
|
||||
Default,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableStructureSqlResult {
|
||||
|
||||
@@ -715,6 +715,100 @@ pub struct SubpartitionInfo {
|
||||
pub partition_key: String,
|
||||
}
|
||||
|
||||
/// PostgreSQL declarative partitioning strategy (`pg_partitioned_table.partstrat`:
|
||||
/// `r` = range, `l` = list, `h` = hash).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PgPartitionKind {
|
||||
Range,
|
||||
List,
|
||||
Hash,
|
||||
}
|
||||
|
||||
/// Structured form of a partition's `pg_get_expr(relpartbound)` definition.
|
||||
///
|
||||
/// Values are kept as SQL literal text (`'2024-01-01'`, `1`, `MINVALUE`,
|
||||
/// `MAXVALUE`, `'a'`) so the UI can round-trip exactly what PostgreSQL
|
||||
/// reported without re-typing or re-quoting them client-side.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum PgPartitionBound {
|
||||
Range { from: Vec<String>, to: Vec<String> },
|
||||
List { values: Vec<String> },
|
||||
Hash { modulus: i32, remainder: i32 },
|
||||
Default,
|
||||
}
|
||||
|
||||
/// One relation in a PostgreSQL partition hierarchy, as returned by
|
||||
/// `get_table_partitioning_core`. `children` is nested to the same depth as
|
||||
/// the catalog's partition tree (PG supports multi-level partitioning).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgPartitionNode {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
/// Set only when this node is itself a partitioned parent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<PgPartitionKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_definition: Option<String>,
|
||||
/// This node's own partition bound (set for every non-root node).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bound: Option<PgPartitionBound>,
|
||||
/// Raw `pg_get_expr(relpartbound)` text; retained as a display fallback
|
||||
/// when `bound` could not be parsed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bound_definition: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_leaf: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub row_estimate: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_bytes: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub children: Vec<PgPartitionNode>,
|
||||
}
|
||||
|
||||
/// Structured partitioning view of one PostgreSQL relation, used by the table
|
||||
/// structure editor's "Partitions" tab. Works for the partitioned parent
|
||||
/// (`is_partitioned`), for a member partition (`is_partition`), and reports
|
||||
/// the whole subtree rooted at the requested relation either way.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgTablePartitioning {
|
||||
pub is_partitioned: bool,
|
||||
pub is_partition: bool,
|
||||
/// Display form `"schema.table"` of the owning parent, when this relation
|
||||
/// is itself a partition.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
/// Parent parts, kept separate so a dotted identifier cannot be mis-split.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_table: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub own_bound: Option<PgPartitionBound>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<PgPartitionKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_definition: Option<String>,
|
||||
#[serde(default)]
|
||||
pub key_columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_expression: Option<String>,
|
||||
/// Name of the default partition, when the parent has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_partition: Option<String>,
|
||||
/// Descendant partitions (the root itself is not included).
|
||||
#[serde(default)]
|
||||
pub partitions: Vec<PgPartitionNode>,
|
||||
/// Server version (`current_setting('server_version_num')`), used by the UI
|
||||
/// to gate `DETACH PARTITION CONCURRENTLY` (PostgreSQL 14+).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_version_num: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FunctionInfo {
|
||||
|
||||
@@ -519,6 +519,7 @@ async fn main() {
|
||||
.route("/schema/constraints", get(routes::schema::list_constraints))
|
||||
.route("/schema/partitions", get(routes::schema::list_partitions))
|
||||
.route("/schema/table-partition-status", get(routes::schema::get_table_partition_status))
|
||||
.route("/schema/table-partitioning", get(routes::schema::get_table_partitioning))
|
||||
.route("/schema/invalid-indexes", get(routes::schema::list_invalid_indexes))
|
||||
.route("/schema/subpartitions", get(routes::schema::list_subpartitions))
|
||||
.route("/schema/functions", get(routes::schema::list_functions))
|
||||
@@ -604,6 +605,8 @@ async fn main() {
|
||||
.route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql))
|
||||
.route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql))
|
||||
.route("/query/build-table-owner-change-sql", post(routes::query::build_table_owner_change_sql))
|
||||
.route("/query/build-table-partition-operation-sql", post(routes::query::build_table_partition_operation_sql))
|
||||
.route("/query/build-create-partitioned-table-sql", post(routes::query::build_create_partitioned_table_sql))
|
||||
.route(
|
||||
"/query/preview-sqlite-table-structure-change",
|
||||
post(routes::query::preview_sqlite_table_structure_change),
|
||||
|
||||
@@ -75,6 +75,9 @@ pub struct ExecuteBatchRequest {
|
||||
pub catalog: Option<String>,
|
||||
pub timeout_secs: Option<u64>,
|
||||
pub destructive_confirmed: Option<bool>,
|
||||
/// Opt-in single transaction for the whole batch (see
|
||||
/// [`dbx_core::query::execute_statements_with_transaction_option`]).
|
||||
pub use_transaction: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -273,6 +276,19 @@ pub struct BuildTableOwnerChangeSqlRequest {
|
||||
pub options: dbx_core::table_structure_sql::TableOwnerChangeSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTablePartitionOperationSqlRequest {
|
||||
pub options: dbx_core::table_structure_sql::TablePartitionSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildCreatePartitionedTableSqlRequest {
|
||||
pub options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
pub partitioning: dbx_core::table_structure_sql::TablePartitionDefinition,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewSqliteTableStructureChangeRequest {
|
||||
@@ -625,12 +641,13 @@ pub async fn execute_batch(
|
||||
super::mcp_policy::ensure_sql(&state, &headers, &req.connection_id, &database, statement, false).await?;
|
||||
}
|
||||
tracing::debug!(connection_id = %req.connection_id, "execute_batch");
|
||||
let result = dbx_core::query::execute_statements(
|
||||
let result = dbx_core::query::execute_statements_with_transaction_option(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&database,
|
||||
&req.statements,
|
||||
req.schema.as_deref(),
|
||||
req.use_transaction == Some(true),
|
||||
req.timeout_secs,
|
||||
)
|
||||
.await
|
||||
@@ -1027,6 +1044,18 @@ pub async fn build_table_owner_change_sql(
|
||||
Json(dbx_core::table_structure_sql::build_table_owner_change_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_table_partition_operation_sql(
|
||||
Json(req): Json<BuildTablePartitionOperationSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_table_partition_operation_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_create_partitioned_table_sql(
|
||||
Json(req): Json<BuildCreatePartitionedTableSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_create_partitioned_table_sql(req.options, req.partitioning))
|
||||
}
|
||||
|
||||
pub async fn preview_sqlite_table_structure_change(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<PreviewSqliteTableStructureChangeRequest>,
|
||||
@@ -1267,6 +1296,7 @@ mod tests {
|
||||
catalog: None,
|
||||
timeout_secs: None,
|
||||
destructive_confirmed: None,
|
||||
use_transaction: None,
|
||||
};
|
||||
|
||||
let result = execute_script_with_2pc(AxumState(state), Json(req))
|
||||
@@ -1292,6 +1322,7 @@ mod tests {
|
||||
catalog: None,
|
||||
timeout_secs: None,
|
||||
destructive_confirmed: None,
|
||||
use_transaction: None,
|
||||
};
|
||||
|
||||
let result = execute_script_with_2pc(AxumState(state), Json(req)).await.expect("empty deploy should succeed");
|
||||
@@ -1314,6 +1345,7 @@ mod tests {
|
||||
catalog: None,
|
||||
timeout_secs: None,
|
||||
destructive_confirmed: None,
|
||||
use_transaction: None,
|
||||
};
|
||||
|
||||
let result = execute_script_with_2pc(AxumState(state), Json(req))
|
||||
@@ -1338,6 +1370,7 @@ mod tests {
|
||||
catalog: None,
|
||||
timeout_secs: None,
|
||||
destructive_confirmed: None,
|
||||
use_transaction: None,
|
||||
};
|
||||
|
||||
let result = execute_script_with_2pc(AxumState(state), Json(req))
|
||||
|
||||
@@ -703,6 +703,19 @@ pub async fn get_table_partition_status(
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn get_table_partitioning(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<dbx_core::db::PgTablePartitioning>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
dbx_core::schema::get_table_partitioning_core(&state.app, &q.connection_id, database, schema, table)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn list_invalid_indexes(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# PostgreSQL 分区表管理功能开发方案
|
||||
|
||||
> 目标:在「表属性 / 表结构编辑器」中新增一个「分区」页签,提供与 Navicat 类似的分区表查看与维护能力。
|
||||
> 范围:优先 PostgreSQL(10+,声明式分区),架构预留 opengauss / kingbase / vastbase / GaussDB 等 PG 兼容系的扩展位。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状分析(已具备的能力)
|
||||
|
||||
### 1.1 后端
|
||||
|
||||
| 能力 | 位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 单表分区信息 | `crates/dbx-core/src/db/postgres.rs::get_table_partition_info` | 返回 `is_partition`、`parent_schema/table`、`bound`(`pg_get_expr(relpartbound)`)、`key`(`pg_get_partkeydef`)、是否外部表 |
|
||||
| 分区树 | `postgres.rs::fetch_postgres_partition_tree` | 一条 `WITH RECURSIVE` 取回根 + 全部层级后代(含每节点 bound/key),有 PG 9.x 兼容降级 SQL |
|
||||
| 分区本地对象 | `postgres.rs::get_table_partition_local_objects(_for_relations)` | 分区自己独有的 PK/UNIQUE/FK/CHECK/索引/列默认值 |
|
||||
| 分区树 DDL | `schema.rs::pg_ddl_with_partitions` | 把整棵树渲染成多段 `CREATE TABLE ... PARTITION OF ... FOR VALUES ...` |
|
||||
| 状态探针 | `schema.rs::table_partition_status_core` → 命令 `get_table_partition_status` | 只返回 `isPartitionedParent` / `isPartition`,已用于并发建索引禁用判断 |
|
||||
| 侧边栏分区子节点 | `apps/desktop/src/lib/table/tableTree.ts`、`objectBrowserRows.ts` | 已能把分区表在对象树里挂到父表下 |
|
||||
| Agent 系分区接口 | `schema.rs::list_partitions_core` / `list_subpartitions_core` | 目前只走 Agent 池(Xugu/Oracle 等),PG 池返回空 |
|
||||
|
||||
### 1.2 前端
|
||||
|
||||
- 表结构编辑器:`apps/desktop/src/components/structure/TableStructureEditor.vue`(5484 行),页签集合由 `TableInfoTab` 定义:
|
||||
`apps/desktop/src/types/database.ts:1490`
|
||||
```ts
|
||||
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "ddl";
|
||||
```
|
||||
- 页签可见性由 `tableMetadataCapabilities.ts` 的 `TableMetadataCapabilities` + `isStructureMetadataTabSupported` 控制。
|
||||
- 刷新范围由 `tableStructureMetadataLoading.ts` 的 `TableStructureRefreshScope` 控制。
|
||||
- DDL 生成统一走后端命令 `buildTableStructureChangeSql` / `buildCreateTableSql`(Rust `table_structure_sql`),前端只在 `tableStructureEditorSql.ts` 里声明 options 类型。
|
||||
- `TableStructureSqlOptions` 已有一个 `partitioned: bool` 字段,但**仅用于拒绝分区父表上的并发建索引**,不是分区管理数据。
|
||||
|
||||
### 1.3 结论
|
||||
|
||||
读取侧(分区树、bound、key、DDL 渲染)**已经基本齐全**,缺的是:
|
||||
|
||||
1. 一个**结构化的、面向前端的分区模型**(现在只有原始字符串 `key` / `bound`,且分散在树/DLL 内部);
|
||||
2. 一个**暴露给前端的读取命令**(现在没有 `get_table_partitioning` 这类命令,前端拿不到树);
|
||||
3. **分区维护 DDL 生成**(新增/删除/attach/detach/默认分区);
|
||||
4. **建表时声明分区策略**;
|
||||
5. **前端页签 UI** 与 i18n、测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. Navicat 分区功能对照与目标功能
|
||||
|
||||
Navicat 的「分区」页签(表设计器内)典型能力:
|
||||
|
||||
| 功能 | 目标 | PG 可行性 |
|
||||
| --- | --- | --- |
|
||||
| 显示分区策略(类型 + 键) | ✅ | `pg_get_partkeydef` / `pg_partitioned_table.partstrat` |
|
||||
| 显示分区列表(名称、边界值、是否默认) | ✅ | 分区树 + `relpartbound` |
|
||||
| 新增分区 | ✅ | `CREATE TABLE ... PARTITION OF ... FOR VALUES ...` |
|
||||
| 删除分区 | ✅ | `DROP TABLE child`(或 DETACH) |
|
||||
| 附加 / 分离分区 | ✅ | `ALTER TABLE ... ATTACH/DETACH PARTITION` |
|
||||
| 默认分区 | ✅ | `... PARTITION OF ... DEFAULT` |
|
||||
| 子分区(多级) | ✅(二期) | PG 支持分区表再分区 |
|
||||
| 修改已有表的分区策略 | ⚠️ 只读 + 重建 | PG 不支持 `ALTER ... PARTITION BY`,需重建表 |
|
||||
| 建表时指定分区 | ✅(二期) | `CREATE TABLE ... PARTITION BY RANGE/LIST/HASH` |
|
||||
|
||||
**一期只读展示 + 二期维护**是风险最低的落地路径(见 §7)。
|
||||
|
||||
---
|
||||
|
||||
## 3. PostgreSQL 语法与语义映射
|
||||
|
||||
### 3.1 分区策略
|
||||
|
||||
```sql
|
||||
PARTITION BY RANGE (col) -- 单列
|
||||
PARTITION BY RANGE (col1, col2) -- 多列(PG 11+)
|
||||
PARTITION BY RANGE (date_trunc('month', ts)) -- 表达式
|
||||
PARTITION BY LIST (col)
|
||||
PARTITION BY HASH (col)
|
||||
```
|
||||
|
||||
策略类型从 `pg_get_partkeydef()` 字符串前缀即可判定:`RANGE (` / `LIST (` / `HASH (`。
|
||||
是否需要解析列 vs 表达式:`pg_partitioned_table.partnatts` 与 `pg_get_partkeydef` 文本组合判断(多列时是元组,表达式时不是纯列清单)。
|
||||
|
||||
### 3.2 分区边界(子分区)
|
||||
|
||||
| 策略 | `relpartbound` 渲染 | 生成语法 |
|
||||
| --- | --- | --- |
|
||||
| RANGE | `FOR VALUES FROM ('a') TO ('b')` | `FOR VALUES FROM (...) TO (...)`,支持 `MINVALUE`/`MAXVALUE` |
|
||||
| LIST | `FOR VALUES IN (1, 2, 3)` | `FOR VALUES IN (...)` |
|
||||
| HASH | `FOR VALUES WITH (MODULUS 4, REMAINDER 0)` | `FOR VALUES WITH (MODULUS n, REMAINDER m)` |
|
||||
| DEFAULT | `DEFAULT` | `DEFAULT` |
|
||||
|
||||
### 3.3 版本差异(必须处理)
|
||||
|
||||
| 特性 | 最低版本 |
|
||||
| --- | --- |
|
||||
| 声明式分区 | 10 |
|
||||
| 多列 / 哈希分区 | 11 |
|
||||
| `DETACH PARTITION CONCURRENTLY` | 12 |
|
||||
| `ATTACH PARTITION` 支持表达式/多列、`DETACH FINALIZE` | 14 |
|
||||
| `<10` | 无声明式分区 → **隐藏分区页签** |
|
||||
|
||||
`fetch_postgres_partition_tree` 已有 9.x 兼容降级;新命令需沿用同一 `query_with_compat_fallback` 模式。
|
||||
|
||||
### 3.4 关键限制(UI 必须提示)
|
||||
|
||||
- 分区键列不能为 NULL 拆分;`DEFAULT` 分区最多一个。
|
||||
- 分区表上的唯一索引 / 主键必须包含分区键。
|
||||
- 有 `DEFAULT` 分区时 `ATTACH` 需要全表扫描校验不重叠(可能长时间锁)。
|
||||
- 分区父表不能 `CREATE INDEX CONCURRENTLY`(已实现,`validate_concurrent_index_scope`)。
|
||||
- 成员分区上的 DDL 大多需带 `ONLY` 才作用于该分区自身。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型设计
|
||||
|
||||
### 4.1 Rust:`crates/dbx-core/src/types.rs`(或新模块 `db/postgres/partition.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PgPartitionKind { Range, List, Hash }
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PgPartitionBound {
|
||||
Range { from: Vec<String>, to: Vec<String> }, // 保留 MINVALUE/MAXVALUE 原文
|
||||
List { values: Vec<String> },
|
||||
Hash { modulus: i32, remainder: i32 },
|
||||
Default,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgPartitionNode {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
/// 该节点自身是分区父表时的策略;叶子分区为 None
|
||||
pub strategy: Option<PgPartitionKind>,
|
||||
/// `pg_get_partkeydef` 原文,仅分区父表有
|
||||
pub key_definition: Option<String>,
|
||||
/// 该节点作为子分区时的边界
|
||||
pub bound: Option<PgPartitionBound>,
|
||||
pub bound_definition: Option<String>, // pg_get_expr 原文,便于展示/兜底
|
||||
pub is_leaf: bool,
|
||||
pub row_estimate: Option<i64>, // pg_class.reltuples,可选
|
||||
pub total_bytes: Option<i64>, // pg_total_relation_size,可选
|
||||
pub children: Vec<PgPartitionNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgTablePartitioning {
|
||||
/// 表本身是否为分区父表
|
||||
pub is_partitioned: bool,
|
||||
/// 表本身是否为某个父表的分区
|
||||
pub is_partition: bool,
|
||||
pub parent: Option<String>, // "schema.table"
|
||||
pub own_bound: Option<PgPartitionBound>,
|
||||
/// 分区策略(根节点,仅根是分区父表时有值)
|
||||
pub strategy: Option<PgPartitionKind>,
|
||||
pub key_definition: Option<String>,
|
||||
/// 键列(表达式键时为空)
|
||||
pub key_columns: Vec<String>,
|
||||
pub key_expression: Option<String>,
|
||||
pub default_partition: Option<String>,
|
||||
/// 完整的(子)分区树
|
||||
pub partitions: Vec<PgPartitionNode>,
|
||||
}
|
||||
```
|
||||
|
||||
新增命令(Rust → Tauri/Web):
|
||||
|
||||
```rust
|
||||
pub async fn get_table_partitioning_core(state, connection_id, database, schema, table)
|
||||
-> Result<PgTablePartitioning, String>
|
||||
```
|
||||
|
||||
- 复用 `fetch_postgres_partition_tree`(含 oid,便于 `pg_partition_tree()` 关联)与 `get_table_partition_info`;
|
||||
- 在 `postgres.rs` 内新增:
|
||||
- `list_partition_strategy(pool, schema, table)` → `pg_partitioned_table.partstrat` + `pg_get_partkeydef`;
|
||||
- `pg_partition_row_estimates(pool, oids)`(可选,一次批量查 `pg_class.reltuples` / `pg_total_relation_size`);
|
||||
- `parse_pg_partition_bound(text) -> PgPartitionBound`(纯函数,带单元测试);
|
||||
- 兼容 9.x 的降级查询。
|
||||
|
||||
### 4.2 Tauri 命令注册
|
||||
|
||||
- `src-tauri/src/commands/schema.rs`:新增 `get_table_partitioning` 命令。
|
||||
- `src-tauri/src/commands/mod.rs` / `lib.rs` 的 `invoke_handler` 里注册。
|
||||
- `src-tauri/src/commands/mcp.rs`、`crates/dbx-mcp` 是否暴露:一期不暴露(可选后续)。
|
||||
|
||||
### 4.3 前端类型
|
||||
|
||||
`apps/desktop/src/types/database.ts`
|
||||
|
||||
```ts
|
||||
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "ddl" | "partitions";
|
||||
|
||||
export type PgPartitionKind = "range" | "list" | "hash";
|
||||
export type PgPartitionBound =
|
||||
| { kind: "range"; from: string[]; to: string[] }
|
||||
| { kind: "list"; values: string[] }
|
||||
| { kind: "hash"; modulus: number; remainder: number }
|
||||
| { kind: "default" };
|
||||
|
||||
export interface PgPartitionNode { schema: string; name: string; strategy?: PgPartitionKind; keyDefinition?: string; bound?: PgPartitionBound; boundDefinition?: string; isLeaf: boolean; rowEstimate?: number; totalBytes?: number; children: PgPartitionNode[]; }
|
||||
export interface PgTablePartitioning { isPartitioned: boolean; isPartition: boolean; parent?: string; ownBound?: PgPartitionBound; strategy?: PgPartitionKind; keyDefinition?: string; keyColumns: string[]; keyExpression?: string; defaultPartition?: string; partitions: PgPartitionNode[]; }
|
||||
```
|
||||
|
||||
API 封装(三处都要加,保持一致):
|
||||
- `apps/desktop/src/lib/backend/tauri.ts`
|
||||
- `apps/desktop/src/lib/backend/http.ts`
|
||||
- `apps/desktop/src/lib/backend/api.ts`
|
||||
|
||||
另有 `apps/desktop/src/lib/backend/__tests__/` 下的契约测试需要补分支。
|
||||
|
||||
---
|
||||
|
||||
## 5. DDL 生成设计
|
||||
|
||||
### 5.1 新模块:`crates/dbx-core/src/table_structure_sql/partitions.rs`
|
||||
|
||||
```rust
|
||||
pub fn validate_partitioning(options) -> Vec<String>; // 非 PG 直接拒绝
|
||||
pub fn build_create_partitioned_table_clause(draft) -> String; // " PARTITION BY RANGE (col)"
|
||||
pub fn build_add_partition_sql(parent, draft) -> Vec<String>; // CREATE TABLE ... PARTITION OF ... FOR VALUES ...
|
||||
pub fn build_attach_partition_sql(parent, child, bound) -> Vec<String>;
|
||||
pub fn build_detach_partition_sql(parent, child, concurrently) -> Vec<String>;
|
||||
pub fn build_drop_partition_sql(parent, child) -> Vec<String>; // DROP TABLE child;
|
||||
```
|
||||
|
||||
要点:
|
||||
- 标识符统一走 `util::quote_ident` / 限定名,禁止拼接未转义的 schema/table。
|
||||
- `MINVALUE` / `MAXVALUE` 是关键字,**不能加引号**;普通值按列类型决定是否加引号(文本/日期加,数字不加)——一期可让 UI 直接输入 SQL 字面量并做「默认按文本引号、检测到纯数字/`MINVALUE`/`MAXVALUE` 时不加引号」的规范化。
|
||||
- `DEFAULT` 分区只允许一个;`validate_partitioning` 里做前置校验。
|
||||
- 非 PostgreSQL/opengauss/... 方言调用时返回 warning 并拒绝生成(fail closed,与现有 `validate_concurrent_index_scope` 风格一致)。
|
||||
|
||||
### 5.2 扩展 options
|
||||
|
||||
`table_structure_sql/types.rs` + `apps/desktop/src/lib/table/tableStructureEditorSql.ts`:
|
||||
|
||||
```rust
|
||||
pub struct TableStructureSqlOptions {
|
||||
// ... 现有字段
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub partitioning: Option<TablePartitioningDraft>,
|
||||
}
|
||||
|
||||
pub struct TablePartitioningDraft {
|
||||
pub kind: PgPartitionKind,
|
||||
#[serde(default)] pub key_columns: Vec<String>,
|
||||
#[serde(default)] pub key_expression: String,
|
||||
#[serde(default)] pub partitions: Vec<TablePartitionDraft>,
|
||||
}
|
||||
|
||||
pub struct TablePartitionDraft {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub bound: PgPartitionBoundDraft,
|
||||
#[serde(default)] pub original: Option<PgPartitionBoundDraft>, // 已存在分区
|
||||
#[serde(default)] pub marked_for_drop: bool,
|
||||
#[serde(default)] pub children: Vec<TablePartitionDraft>, // 二期:子分区
|
||||
}
|
||||
```
|
||||
|
||||
- **建表**:在 `build_create_table_sql` 末尾,若 `partitioning` 存在且非空,追加 `PARTITION BY ...`。
|
||||
- 注意:`PARTITION BY` 只能用于 `CREATE TABLE`,不能用于 `CREATE TABLE ... PARTITION OF`。
|
||||
- 分区父表上不应自动生成「子分区 CREATE」,由 draft 里的 `partitions` 决定是否一并生成。
|
||||
- **改现有表**:分区操作**不参与列/索引 diff**,而是作为独立语句追加到 `statements`(新增/删除/attach/detach)。这样既能复用现有「预览 SQL → 执行」通道,又不会和列 diff 相互干扰。
|
||||
- 策略字段在现有表上是只读的(PG 不支持改),只在建表模式可编辑。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端 UI 设计
|
||||
|
||||
### 6.1 页签接入
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `apps/desktop/src/types/database.ts` | `TableInfoTab` 加 `"partitions"` + 新类型 |
|
||||
| `apps/desktop/src/lib/table/tableMetadataCapabilities.ts` | `TableMetadataCapabilities` 加 `partitions: boolean`;`postgres: { constraints: true, partitions: true }`;`isStructureMetadataTabSupported` / `firstStructureMetadataTab` 补分支 |
|
||||
| `apps/desktop/src/lib/table/tableStructureMetadataLoading.ts` | `TableStructureRefreshScope` 加 `partitions`;`visibleTableStructureRefreshScope` 加 `case "partitions"` |
|
||||
| `apps/desktop/src/components/structure/TableStructureEditor.vue` | 新增 `TabsTrigger value="partitions"` 与 `TabsContent`;新增分区状态/加载/草稿逻辑 |
|
||||
| i18n:`locales/zh-CN.ts`、`en.ts`(其余语言走 `i18n-autofill.mjs`) | `structureEditor.partitions*` 一组 key |
|
||||
|
||||
### 6.2 页签内容
|
||||
|
||||
**A. 分区表(父表)**
|
||||
|
||||
- 顶部信息条:分区类型(RANGE/LIST/HASH)、分区键、默认分区名、分区总数。
|
||||
- 分区树/列表(参考 `constraints` 页签的只读卡片风格):
|
||||
- 层级缩进展示(一期可用扁平列表 + parent 列,二期再树形);
|
||||
- 每行:分区名、边界(`FROM (...) TO (...)` / `IN (...)` / `MODULUS..REMAINDER..` / DEFAULT)、是否默认、行数/大小(可选)。
|
||||
- 操作按钮:`新增分区`、`删除分区`、`附加已有表`、`分离分区`。
|
||||
- 所有操作 → 弹窗填写参数 → 把生成的 SQL 语句加入 `pendingStatements` → 复用底部「预览 SQL / 保存」流程执行。
|
||||
|
||||
**B. 分区(子表)**
|
||||
|
||||
- 只读展示:所属父表、自身边界。
|
||||
- 提供「分离此分区」入口(`DETACH PARTITION`)。
|
||||
|
||||
**C. 非分区表**
|
||||
|
||||
- 空态:提示「当前表不是分区表」;建表模式下可开启分区并配置(二期)。
|
||||
|
||||
### 6.3 交互细节
|
||||
|
||||
- 新增分区弹窗按策略切换表单:
|
||||
- RANGE:起始值 / 结束值(支持 `MINVALUE` / `MAXVALUE` 开关);
|
||||
- LIST:值列表(逗号分隔,支持多值);
|
||||
- HASH:`MODULUS`、`REMAINDER`;
|
||||
- 勾选「默认分区」则禁用边界输入。
|
||||
- 删除 / 分离需二次确认,文案提示影响(DROP 会连带删除数据)。
|
||||
- `DETACH CONCURRENTLY` 仅在服务端版本 ≥ 12 时可选(通过 `server_version_num` 或 `get_table_partitioning` 返回的版本能力位判断)。
|
||||
- 有 `DEFAULT` 分区时,新增分区提示可能触发表扫描。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分阶段实施计划
|
||||
|
||||
> **实施状态**:Phase 0 / 1 / 2a / 3a 已完成并通过真实库验证(PostgreSQL 14.19、KingbaseES V009R001C010)。
|
||||
> 待办:Phase 2b 子分区树编辑、Phase 3b openGauss / GaussDB / Vastbase(`pg_partition` catalog,非 PG 声明式分区模型)。
|
||||
|
||||
### Phase 0 — 只读展示(建议先落地,1–2 天)
|
||||
|
||||
1. 后端:`PgTablePartitioning` 模型 + `get_table_partitioning_core` + `parse_pg_partition_bound` + 单元测试。
|
||||
2. 命令注册 + 前端三个 api 封装 + 契约测试。
|
||||
3. 前端:`TableInfoTab` / capabilities / refresh scope / 页签 + 只读 UI + i18n。
|
||||
4. 测试:Rust 解析测试、前端 vitest 能力门控与渲染测试。
|
||||
5. 价值:立即可在表属性里看到分区结构与边界,且完全无写入风险。
|
||||
|
||||
**验收**:PostgreSQL 10–17 上打开分区父表 → 显示策略与全部(子)分区边界;子分区表显示父表与自身边界;普通表显示空态;PG 9.x 与 MySQL 不显示该页签。
|
||||
|
||||
### Phase 1 — 分区维护(3–5 天)
|
||||
|
||||
1. `partitions.rs` DDL 生成 + 校验 + 单元测试。
|
||||
2. options 扩展 + 前端 draft 类型。
|
||||
3. UI:新增 / 删除 / attach / detach 弹窗 + SQL 预览 + 执行。
|
||||
4. 事务/并发安全:`DETACH CONCURRENTLY` 版本门控;attach 有默认分区时的警告。
|
||||
5. 测试:`live_postgres_partitioning.rs` 真实库读 + 生成 DDL 执行。
|
||||
|
||||
### Phase 2 — 建表声明分区 + 子分区(2–3 天)
|
||||
|
||||
1. 建表模式下的分区策略编辑器;`build_create_table_sql` 追加 `PARTITION BY`。
|
||||
2. 子分区树编辑(可选,复杂度较高,可再拆)。
|
||||
3. 分区父表上并发建索引的禁用已存在,补充与新页签的联动提示。
|
||||
|
||||
### Phase 3 — 方言扩展(3–5 天,按需)
|
||||
|
||||
- **3a(已完成)**:kingbase — KingbaseES V9 与 PostgreSQL 共用 `pg_partitioned_table` / `relispartition` / `pg_get_partkeydef` catalog 与 `PARTITION OF` / `ATTACH` / `DETACH` 语法,直接复用同一模型;后端 `supports_partition_ddl` 放行 PostgreSQL + Kingbase,前端 capability 同步开启,已用真实 KingbaseES 实例跑通全部 live 用例。
|
||||
- **3b(待办)**:openGauss / GaussDB / Vastbase 使用 **openGauss 专有的 `pg_partition` catalog**(无 `relispartition`/`relpartbound`/`pg_get_partkeydef`),且分区是 Oracle 风格(`PARTITION BY RANGE (col) (PARTITION p1 VALUES LESS THAN (...))`),没有独立子表。需要单独的读取与 DDL 通道,不能复用本模型,因此暂不开启。
|
||||
- Oracle / Xugu 等已有 `list_partitions` Agent 通路,可复用新页签的只读形态,DDL 走各自方言。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试方案
|
||||
|
||||
### 8.1 Rust 单元测试
|
||||
|
||||
- `table_structure_sql/tests.rs`:
|
||||
- RANGE 单列 / 多列 / 表达式;
|
||||
- LIST(含字符串、数字、NULL 边界);
|
||||
- HASH MODULUS/REMAINDER;
|
||||
- DEFAULT 分区唯一性校验;
|
||||
- attach / detach(含 concurrently);
|
||||
- 非 PG 方言拒绝生成;
|
||||
- 标识符转义(含引号、schema 限定)。
|
||||
- `postgres.rs` 内 `parse_pg_partition_bound` 的往返测试(覆盖 `pg_get_expr` 的真实输出格式)。
|
||||
|
||||
### 8.2 Rust 集成测试
|
||||
|
||||
- 新增 `crates/dbx-core/tests/live_postgres_partitioning.rs`,沿用 `live_postgres_concurrent_index.rs` 的 env 约定(`DBX_LIVE_POSTGRES_*`,`#[ignore]`)。
|
||||
- 场景:建分区父表 → 读树 → 生成新增分区 DDL 并执行 → 读回校验 → detach → drop。
|
||||
|
||||
### 8.3 前端测试
|
||||
|
||||
- `apps/desktop/src/lib/__tests__/table/`:能力门控(postgres 有、mysql 无)、refresh scope、bound 格式化工具。
|
||||
- `apps/desktop/src/components/structure/__tests__/`:页签可见性、加载态、空态、错误态(参考 `TableStructureEditor.ddlTab.spec.ts` 的 mock 方式)。
|
||||
- `packages/app-tests/` 下如有跨包契约测试也需同步。
|
||||
|
||||
### 8.4 契约/回归
|
||||
|
||||
- `crates/dbx-core/tests/api_contract_verification.rs`、`packages/app-tests/*` 若枚举命令或页签,需同步更新。
|
||||
- CI:`.github/workflows/database-environments.yml` 已支持多数据库环境,PG 场景加进 live 测试矩阵。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与注意事项
|
||||
|
||||
| 风险 | 应对 |
|
||||
| --- | --- |
|
||||
| PG 版本差异(`DETACH CONCURRENTLY`、多列/哈希) | 版本能力位 + 完整降级 SQL;9.x 隐藏页签 |
|
||||
| 改分区策略需重建表 | 现有表策略只读,UI 明确提示「需重建」,不提供隐式重建 |
|
||||
| `ATTACH` 有默认分区时全表扫描 | UI 警告 + 建议在低峰执行;不做自动并发 |
|
||||
| 值字面量引号处理错误 | 单测覆盖;UI 提示「按 SQL 字面量输入」;后端规范化 |
|
||||
| 分区父表并发建索引 | 已实现拦截,新页签复用同一状态探针,避免重复查询 |
|
||||
| 大分区树(数千分区)渲染卡顿 | 复用对象树的分页/懒加载思路;一期限制一次渲染条数并提示 |
|
||||
| i18n 全语言缺失 | 新增 key 后跑 `.github/scripts/i18n-autofill.mjs` |
|
||||
| 与 DDL 页签重复 | DDL 页签继续展示整棵树(`pg_ddl_with_partitions`),分区页签负责结构化查看与操作,二者数据同源不冲突 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 涉及文件清单(速查)
|
||||
|
||||
**后端(Rust)**
|
||||
- `crates/dbx-core/src/db/postgres.rs`(策略查询、bound 解析、批量估算)
|
||||
- `crates/dbx-core/src/schema.rs`(`get_table_partitioning_core`)
|
||||
- `crates/dbx-core/src/types.rs`(分区模型)
|
||||
- `crates/dbx-core/src/table_structure_sql/partitions.rs`(新,DDL 生成)
|
||||
- `crates/dbx-core/src/table_structure_sql/{types.rs,create_table.rs,tests.rs,mod.rs}`
|
||||
- `src-tauri/src/commands/schema.rs` + 命令注册
|
||||
- `crates/dbx-core/tests/live_postgres_partitioning.rs`(新)
|
||||
|
||||
**前端(Vue/TS)**
|
||||
- `apps/desktop/src/types/database.ts`
|
||||
- `apps/desktop/src/lib/table/tableMetadataCapabilities.ts`
|
||||
- `apps/desktop/src/lib/table/tableStructureMetadataLoading.ts`
|
||||
- `apps/desktop/src/lib/table/tableStructureEditorSql.ts`
|
||||
- `apps/desktop/src/lib/backend/{api,tauri,http}.ts`
|
||||
- `apps/desktop/src/components/structure/TableStructureEditor.vue`
|
||||
- `apps/desktop/src/i18n/locales/{zh-CN,en,...}.ts`
|
||||
- 对应 `*.spec.ts` 测试
|
||||
|
||||
---
|
||||
|
||||
## 11. 估算
|
||||
|
||||
| 阶段 | 工作量 |
|
||||
| --- | --- |
|
||||
| Phase 0 只读 | 1–2 天 |
|
||||
| Phase 1 维护 | 3–5 天 |
|
||||
| Phase 2 建表 + 子分区 | 2–3 天 |
|
||||
| Phase 3 方言扩展 | 3–5 天(按需) |
|
||||
| 合计(PG 完整) | **约 6–10 天**(不含方言扩展) |
|
||||
|
||||
> 建议以 Phase 0 作为第一个可合并 PR:改动小、无写入风险、立刻可见收益,并为后续维护功能打下数据模型基础。
|
||||
+71
-1
@@ -1,6 +1,6 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { buildObjectBrowserRows, filterObjectBrowserRows, formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, sortObjectBrowserRows, summarizeObjectBrowserSearch } from "../../apps/desktop/src/lib/table/objectBrowserRows.ts";
|
||||
import { buildObjectBrowserRows, filterObjectBrowserRows, formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, groupObjectBrowserRows, sortObjectBrowserRows, summarizeObjectBrowserSearch } from "../../apps/desktop/src/lib/table/objectBrowserRows.ts";
|
||||
|
||||
test("builds unique row ids for overloaded routines with the same visible name", () => {
|
||||
const rows = buildObjectBrowserRows({
|
||||
@@ -386,3 +386,73 @@ test("object browser timestamp display strips timezone suffixes", () => {
|
||||
assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24+08"), "2026-05-22 10:18:24");
|
||||
assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24.123456+08:00"), "2026-05-22 10:18:24");
|
||||
});
|
||||
|
||||
function nestedPartitionRows() {
|
||||
return buildObjectBrowserRows({
|
||||
objects: [
|
||||
{ name: "catalog_nested", object_type: "TABLE" },
|
||||
{ name: "catalog_nested_2024", object_type: "TABLE", parent_schema: "public", parent_name: "catalog_nested" },
|
||||
{ name: "catalog_nested_2025", object_type: "TABLE", parent_schema: "public", parent_name: "catalog_nested" },
|
||||
{ name: "catalog_nested_2024_asia", object_type: "TABLE", parent_schema: "public", parent_name: "catalog_nested_2024" },
|
||||
{ name: "catalog_nested_2024_eu", object_type: "TABLE", parent_schema: "public", parent_name: "catalog_nested_2024" },
|
||||
{ name: "plain", object_type: "TABLE" },
|
||||
],
|
||||
database: "test",
|
||||
fallbackSchema: "public",
|
||||
needsSchema: false,
|
||||
});
|
||||
}
|
||||
|
||||
function group(rows, expandedIds, query = "", matchingRows = rows) {
|
||||
return groupObjectBrowserRows({
|
||||
rows,
|
||||
matchingRows,
|
||||
query,
|
||||
expandedPartitionParentIds: new Set(expandedIds),
|
||||
sortRows: (items) => sortObjectBrowserRows(items, "name", "asc"),
|
||||
});
|
||||
}
|
||||
|
||||
test("recurses through second-level sub-partitions instead of dropping them", () => {
|
||||
const rows = nestedPartitionRows();
|
||||
const root = rows.find((row) => row.name === "catalog_nested");
|
||||
const year = rows.find((row) => row.name === "catalog_nested_2024");
|
||||
assert.ok(root && year);
|
||||
|
||||
const result = group(rows, [root.id, year.id]);
|
||||
|
||||
assert.deepEqual(
|
||||
result.rows.map((row) => row.name),
|
||||
["catalog_nested", "catalog_nested_2024", "catalog_nested_2024_asia", "catalog_nested_2024_eu", "catalog_nested_2025", "plain"],
|
||||
);
|
||||
// Indentation depth drives the row indent in the UI.
|
||||
assert.deepEqual(
|
||||
result.rows.map((row) => result.depths.get(row.id)),
|
||||
[0, 1, 2, 2, 1, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test("only shows partition levels whose parent is expanded", () => {
|
||||
const rows = nestedPartitionRows();
|
||||
const root = rows.find((row) => row.name === "catalog_nested");
|
||||
assert.ok(root);
|
||||
|
||||
assert.deepEqual(group(rows, []).rows.map((row) => row.name), ["catalog_nested", "plain"]);
|
||||
assert.deepEqual(
|
||||
group(rows, [root.id]).rows.map((row) => row.name),
|
||||
["catalog_nested", "catalog_nested_2024", "catalog_nested_2025", "plain"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a deep search match stays reachable while collapsed", () => {
|
||||
const rows = nestedPartitionRows();
|
||||
const match = rows.filter((row) => row.name === "catalog_nested_2024_asia");
|
||||
assert.equal(match.length, 1);
|
||||
|
||||
// Collapsed (no expanded ids) and a query: the match must be shown under its
|
||||
// ancestor chain, not dropped as an orphaned third-level partition.
|
||||
const result = group(rows, [], "asia", match);
|
||||
|
||||
assert.deepEqual(result.rows.map((row) => row.name), ["catalog_nested", "catalog_nested_2024", "catalog_nested_2024_asia"]);
|
||||
assert.deepEqual(result.rows.map((row) => result.depths.get(row.id)), [0, 1, 2]);
|
||||
});
|
||||
+3
@@ -705,6 +705,9 @@ test("normalizes grid drawer widths", () => {
|
||||
assert.equal(normalizeEditorSettings({ cellDetailDrawerWidth: 200 } as any).cellDetailDrawerWidth, 260);
|
||||
assert.equal(normalizeEditorSettings({ tableInfoDrawerWidth: 1000 } as any).tableInfoDrawerWidth, 900);
|
||||
assert.equal(normalizeEditorSettings({ tableInfoActiveTab: "columns" } as any).tableInfoActiveTab, "columns");
|
||||
// The Partitions tab is a first-class table-info tab, so a saved preference
|
||||
// for it must survive normalization instead of falling back to DDL.
|
||||
assert.equal(normalizeEditorSettings({ tableInfoActiveTab: "partitions" } as any).tableInfoActiveTab, "partitions");
|
||||
assert.equal(normalizeEditorSettings({ tableInfoActiveTab: "invalid" } as any).tableInfoActiveTab, "ddl");
|
||||
assert.equal(normalizeEditorSettings({ cellDetailDrawerWidth: 456.7 } as any).cellDetailDrawerWidth, 457);
|
||||
assert.equal(normalizeEditorSettings({ cellDetailPanelLayout: "right" } as any).cellDetailPanelLayout, "right");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
// The table-properties drawer exists twice (DataGrid and ObjectBrowser), each
|
||||
// with its own tab list, plus a shared body component. A partitions tab must be
|
||||
// wired into all of them together, otherwise the drawer silently loses the tab
|
||||
// on one surface.
|
||||
const drawerSurfaces = [
|
||||
"apps/desktop/src/components/grid/DataGrid.vue",
|
||||
"apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
];
|
||||
|
||||
test("both table-properties drawers expose a capability-gated partitions tab", () => {
|
||||
for (const path of drawerSurfaces) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.match(source, /id:\s*"partitions"/, `${path} must add a partitions tab`);
|
||||
assert.match(
|
||||
source,
|
||||
/tableMetadataCapabilities\.value\.partitions/,
|
||||
`${path} must gate the partitions tab on the capability`,
|
||||
);
|
||||
// A plain table must not show the tab at all, so both drawers probe the
|
||||
// cheap partition status and gate on the result.
|
||||
assert.match(source, /api\.getTablePartitionStatus/, `${path} must probe the partition status`);
|
||||
assert.match(source, /isPartitionedTable|tableIsPartitioned/, `${path} must gate on the probed status`);
|
||||
assert.match(source, /fetchPartitions|fetchTablePartitions/, `${path} must load partition metadata`);
|
||||
}
|
||||
});
|
||||
|
||||
test("both drawers probe the partition status on demand, not only on table change", () => {
|
||||
// Regression: probing only in the table-change watcher meant opening the
|
||||
// drawer for an already-selected table never resolved the status, so the
|
||||
// Partitions tab was missing on every partitioned table.
|
||||
const expectations: Array<[string, RegExp]> = [
|
||||
["apps/desktop/src/components/grid/DataGrid.vue", /if \(!partitionStatusResolved\.value\) await probeTablePartitionStatus\(\)/],
|
||||
[
|
||||
"apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
/if \(!tablePartitionStatusResolved\.value\) await probeTablePartitionStatus\(\)/,
|
||||
],
|
||||
];
|
||||
for (const [path, pattern] of expectations) {
|
||||
assert.match(readFileSync(path, "utf8"), pattern, `${path} must probe on demand in selectTableInfoTab`);
|
||||
}
|
||||
});
|
||||
|
||||
test("both drawers render the shared partitions panel", () => {
|
||||
const panels = readFileSync("apps/desktop/src/components/grid/DataGridTableInfoPanels.vue", "utf8");
|
||||
assert.match(panels, /TablePartitionsPanel/);
|
||||
|
||||
const objectBrowser = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
assert.match(objectBrowser, /TablePartitionsPanel/);
|
||||
});
|
||||
|
||||
test("the partitions tab is a first-class persisted table-info tab", () => {
|
||||
const settings = readFileSync("apps/desktop/src/stores/settingsStore.ts", "utf8");
|
||||
assert.match(settings, /TABLE_INFO_TABS = new Set<TableInfoTab>\(\[[^\]]*"partitions"/);
|
||||
});
|
||||
@@ -345,9 +345,18 @@ pub async fn execute_batch(
|
||||
statements: Vec<String>,
|
||||
schema: Option<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
use_transaction: Option<bool>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
dbx_core::query::execute_statements(&state, &connection_id, &database, &statements, schema.as_deref(), timeout_secs)
|
||||
.await
|
||||
dbx_core::query::execute_statements_with_transaction_option(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&statements,
|
||||
schema.as_deref(),
|
||||
use_transaction == Some(true),
|
||||
timeout_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -780,6 +789,21 @@ pub fn build_create_table_sql(
|
||||
Ok(dbx_core::table_structure_sql::build_create_table_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_create_partitioned_table_sql(
|
||||
options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
partitioning: dbx_core::table_structure_sql::TablePartitionDefinition,
|
||||
) -> Result<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
|
||||
Ok(dbx_core::table_structure_sql::build_create_partitioned_table_sql(options, partitioning))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_table_partition_operation_sql(
|
||||
options: dbx_core::table_structure_sql::TablePartitionSqlOptions,
|
||||
) -> Result<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
|
||||
Ok(dbx_core::table_structure_sql::build_table_partition_operation_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_single_column_alter_sql(
|
||||
options: dbx_core::table_structure_sql::SingleColumnAlterSqlOptions,
|
||||
|
||||
@@ -545,6 +545,17 @@ pub async fn list_invalid_indexes(
|
||||
dbx_core::schema::list_invalid_indexes_core(&state, &connection_id, &database, &schema, &table).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_partitioning(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> Result<dbx_core::db::PgTablePartitioning, String> {
|
||||
dbx_core::schema::get_table_partitioning_core(&state, &connection_id, &database, &schema, &table).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_subpartitions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
||||
@@ -1965,6 +1965,7 @@ pub fn run() {
|
||||
commands::schema::list_constraints,
|
||||
commands::schema::list_partitions,
|
||||
commands::schema::get_table_partition_status,
|
||||
commands::schema::get_table_partitioning,
|
||||
commands::schema::list_invalid_indexes,
|
||||
commands::schema::list_subpartitions,
|
||||
commands::schema::get_table_ddl,
|
||||
@@ -2046,6 +2047,8 @@ pub fn run() {
|
||||
commands::query::preview_sqlite_table_structure_change,
|
||||
commands::query::apply_sqlite_table_structure_change,
|
||||
commands::query::build_create_table_sql,
|
||||
commands::query::build_create_partitioned_table_sql,
|
||||
commands::query::build_table_partition_operation_sql,
|
||||
commands::query::build_single_column_alter_sql,
|
||||
commands::query::analyze_editable_query_editability,
|
||||
commands::query::prepare_data_grid_save,
|
||||
|
||||
Reference in new issue
Block a user