diff --git a/agents/drivers/xugu/index_partition_live_test.go b/agents/drivers/xugu/index_partition_live_test.go new file mode 100644 index 0000000000..2cb30b872d --- /dev/null +++ b/agents/drivers/xugu/index_partition_live_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// TestLiveXuguIndexPartitionDDL verifies the four index cases that are easy to +// conflate in reconstructed DDL: an ordinary index, a LOCAL index, a GLOBAL +// index with explicit partitions, and a GLOBAL index with subpartitions. It is +// opt-in because CI does not provide a XuguDB service. +func TestLiveXuguIndexPartitionDDL(t *testing.T) { + if os.Getenv("XUGU_LIVE_TEST") != "1" { + t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings to run the XuguDB integration test") + } + params := connectParams{ + Host: os.Getenv("XUGU_LIVE_HOST"), + Port: parsePort(os.Getenv("XUGU_LIVE_PORT")), + Database: os.Getenv("XUGU_LIVE_DATABASE"), + Username: os.Getenv("XUGU_LIVE_USERNAME"), + Password: os.Getenv("XUGU_LIVE_PASSWORD"), + } + if params.Host == "" || params.Database == "" || params.Username == "" || params.Password == "" { + t.Fatal("XUGU_LIVE_HOST, XUGU_LIVE_DATABASE, XUGU_LIVE_USERNAME, and XUGU_LIVE_PASSWORD are required") + } + + s := newServer() + db, err := openDB(params) + if err != nil { + t.Fatal(err) + } + s.db = db + s.params = params + s.currentDatabase = params.Database + defer s.disconnect() + + const ( + ordinaryTable = "DBX_IDX_SCOPE_LIVE_ORD_T" + localTable = "DBX_IDX_SCOPE_LIVE_LOC_T" + globalTable = "DBX_IDX_SCOPE_LIVE_GLB_T" + subTable = "DBX_IDX_SCOPE_LIVE_SUB_T" + ordinaryIndex = "DBX_IDX_SCOPE_LIVE_ORD_I" + localIndex = "DBX_IDX_SCOPE_LIVE_LOC_I" + globalIndex = "DBX_IDX_SCOPE_LIVE_GLB_I" + subIndex = "DBX_IDX_SCOPE_LIVE_SUB_I" + ) + tables := []string{ordinaryTable, localTable, globalTable, subTable} + for _, table := range tables { + _ = s.execWithReconnect("DROP TABLE IF EXISTS " + table) + } + defer func() { + for _, table := range tables { + _ = s.execWithReconnect("DROP TABLE IF EXISTS " + table) + } + }() + + statements := []string{ + "CREATE TABLE " + ordinaryTable + " (ID INTEGER, VALUE VARCHAR(20))", + "CREATE INDEX " + ordinaryIndex + " ON " + ordinaryTable + " (ID)", + "CREATE TABLE " + localTable + " (ID INTEGER, VALUE VARCHAR(20)) PARTITION BY RANGE (ID) PARTITIONS (P1 VALUES LESS THAN (100), P2 VALUES LESS THAN (MAXVALUES))", + "CREATE INDEX " + localIndex + " ON " + localTable + " (VALUE) LOCAL", + "CREATE TABLE " + globalTable + " (ID INTEGER, REGION VARCHAR(20))", + "CREATE INDEX " + globalIndex + " ON " + globalTable + " (ID) GLOBAL PARTITION BY LIST (REGION) PARTITIONS (P_CN VALUES ('CN'), P_US VALUES ('US'), P_OTHER VALUES (OTHERVALUES))", + "CREATE TABLE " + subTable + " (ID INTEGER, REGION VARCHAR(20))", + "CREATE INDEX " + subIndex + " ON " + subTable + " (ID) GLOBAL PARTITION BY RANGE (ID) PARTITIONS (P1 VALUES LESS THAN (100), P2 VALUES LESS THAN (MAXVALUES)) SUBPARTITION BY HASH (REGION) SUBPARTITIONS 2", + } + for _, statement := range statements { + if err := s.execWithReconnect(statement); err != nil { + t.Fatalf("setup statement failed: %s: %v", statement, err) + } + } + + tests := []struct { + name string + table string + index string + wantLocal bool + wantGlobal bool + wantSub bool + }{ + {name: "ordinary", table: ordinaryTable, index: ordinaryIndex}, + {name: "local", table: localTable, index: localIndex, wantLocal: true}, + {name: "global", table: globalTable, index: globalIndex, wantGlobal: true}, + {name: "global with subpartitions", table: subTable, index: subIndex, wantGlobal: true, wantSub: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Exercise the same reconstructed DDL path used by interactive table + // inspection and by the database-export metadata prefetcher. The + // lower-level index fragment assertion below is not sufficient: a + // regression in table/partition assembly could still omit the index. + fullDDL, err := s.getTableDDL(params.Username, tc.table) + if err != nil { + t.Fatalf("getTableDDL failed: %v", err) + } + if !strings.Contains(strings.ToUpper(fullDDL), "CREATE TABLE") { + t.Fatalf("getTableDDL did not return a CREATE TABLE statement: %s", fullDDL) + } + if !strings.Contains(strings.ToUpper(fullDDL), strings.ToUpper(tc.index)) { + t.Fatalf("getTableDDL omitted index %s: %s", tc.index, fullDDL) + } + t.Logf("full reconstructed table DDL: %s", fullDDL) + + indexes, err := s.listIndexes(params.Username, tc.table) + if err != nil { + t.Fatal(err) + } + var got *indexInfo + for i := range indexes { + if strings.EqualFold(indexes[i].Name, tc.index) { + got = &indexes[i] + break + } + } + if got == nil { + t.Fatalf("index %s was not listed: %#v", tc.index, indexes) + } + if got.IsLocal != tc.wantLocal { + t.Fatalf("IsLocal=%v, want %v: %#v", got.IsLocal, tc.wantLocal, *got) + } + if tc.wantGlobal && (got.PartitionType == 0 || !got.PartitionRowsLoaded || len(got.IndexPartitions) == 0) { + t.Fatalf("global partition metadata incomplete: %#v", *got) + } + if tc.wantSub && (got.SubpartitionType == 0 || len(got.IndexSubpartitions) == 0) { + t.Fatalf("subpartition metadata incomplete: %#v", *got) + } + // The table already exists; keep a harmless placeholder statement so + // appendDDLStatement produces the same script shape as a real table + // DDL request without attempting to recreate the table during replay. + ddl := s.appendTableIndexDDL(params.Username, tc.table, "/* existing table DDL */") + t.Logf("reconstructed DDL: %s", ddl) + if tc.wantLocal && !strings.Contains(ddl, " LOCAL") { + t.Fatalf("LOCAL was lost from DDL: %s", ddl) + } + if !tc.wantLocal && !tc.wantGlobal && strings.Contains(ddl, " GLOBAL") { + t.Fatalf("ordinary index was incorrectly labeled GLOBAL: %s", ddl) + } + if tc.wantGlobal && !strings.Contains(ddl, " GLOBAL PARTITION BY ") { + t.Fatalf("GLOBAL partition clause was lost from DDL: %s", ddl) + } + if tc.wantSub && !strings.Contains(ddl, " SUBPARTITION BY HASH ") { + t.Fatalf("GLOBAL subpartition clause was lost from DDL: %s", ddl) + } + + // Replay the exact reconstructed index DDL after dropping only this + // isolated test index. This catches syntactically plausible output + // that Xugu nevertheless rejects, especially LOCAL/GLOBAL ordering. + if err := s.execWithReconnect("DROP INDEX IF EXISTS " + tc.table + "." + tc.index); err != nil { + t.Fatalf("drop index before replay: %v", err) + } + if err := s.execWithReconnect(ddl); err != nil { + t.Fatalf("replay reconstructed DDL: %v\nDDL: %s", err, ddl) + } + + // Replay the complete table DDL as the database-export path writes it: + // one script containing CREATE TABLE followed by any dependent ALTER + // and CREATE INDEX statements. The agent executes one statement per + // request, so split only the generated statement terminators here. + if err := s.execWithReconnect("DROP TABLE IF EXISTS " + tc.table); err != nil { + t.Fatalf("drop table before full DDL replay: %v", err) + } + for _, statement := range splitGeneratedXuguDDL(fullDDL) { + if err := s.execWithReconnect(statement); err != nil { + t.Fatalf("replay full table DDL: %v\nStatement: %s\nFull DDL: %s", err, statement, fullDDL) + } + } + replayedIndexes, err := s.listIndexes(params.Username, tc.table) + if err != nil { + t.Fatalf("list indexes after full DDL replay: %v", err) + } + if !containsXuguIndex(replayedIndexes, tc.index) { + t.Fatalf("full DDL replay did not recreate index %s: %#v", tc.index, replayedIndexes) + } + }) + } +} + +func splitGeneratedXuguDDL(ddl string) []string { + var statements []string + for _, part := range strings.Split(ddl, ";") { + part = strings.TrimSpace(part) + if part != "" { + statements = append(statements, part+";") + } + } + return statements +} + +func containsXuguIndex(indexes []indexInfo, name string) bool { + for _, index := range indexes { + if strings.EqualFold(index.Name, name) { + return true + } + } + return false +} diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index e0c6ba05bf..411dc8789d 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -107,6 +107,43 @@ WHERE s.DB_ID = CURRENT_DB_ID AND s.SCHEMA_NAME = ? AND t.TABLE_NAME = ? ORDER BY i.INDEX_NAME` + +// Index scope and partition metadata are queried separately from the stable +// index listing query. This keeps ordinary index discovery compatible with +// older Xugu catalog versions while allowing newer versions to preserve +// LOCAL/GLOBAL partition semantics in reconstructed DDL. +const xuguIndexPartitionAttributesSQL = ` +SELECT i.INDEX_NAME, i.IS_LOCAL, i.PARTI_TYPE, i.PARTI_NUM, i.PARTI_KEY, + i.SUBPARTI_TYPE, i.SUBPARTI_NUM, i.SUBPARTI_KEY +FROM ALL_INDEXES i +JOIN ALL_TABLES t ON t.DB_ID = i.DB_ID AND t.TABLE_ID = i.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE s.DB_ID = CURRENT_DB_ID + AND s.SCHEMA_NAME = ? + AND t.TABLE_NAME = ? +ORDER BY i.INDEX_NAME` +const xuguIndexPartitionsSQL = ` +SELECT i.INDEX_NAME, p.PARTI_NO, p.PARTI_NAME, p.PARTI_VAL +FROM ALL_IDX_PARTIS p +JOIN ALL_INDEXES i ON i.DB_ID = p.DB_ID AND i.INDEX_ID = p.INDEX_ID +JOIN ALL_TABLES t ON t.DB_ID = i.DB_ID AND t.TABLE_ID = i.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE s.DB_ID = CURRENT_DB_ID + AND s.SCHEMA_NAME = ? + AND t.TABLE_NAME = ? + AND i.IS_PRIMARY = FALSE +ORDER BY i.INDEX_NAME, p.PARTI_NO` +const xuguIndexSubpartitionsSQL = ` +SELECT i.INDEX_NAME, p.SUBPARTI_NO, p.SUBPARTI_NAME, p.SUBPARTI_VAL +FROM ALL_IDX_SUBPARTIS p +JOIN ALL_INDEXES i ON i.DB_ID = p.DB_ID AND i.INDEX_ID = p.INDEX_ID +JOIN ALL_TABLES t ON t.DB_ID = i.DB_ID AND t.TABLE_ID = i.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE s.DB_ID = CURRENT_DB_ID + AND s.SCHEMA_NAME = ? + AND t.TABLE_NAME = ? + AND i.IS_PRIMARY = FALSE +ORDER BY i.INDEX_NAME, p.SUBPARTI_NO` const xuguTableMetadataSQL = ` SELECT t.TEMP_TYPE, t.ON_COMMIT_DEL, t.PCTFREE, t.COPY_NUM, t.PARTI_TYPE, t.PARTI_NUM, t.PARTI_KEY, @@ -386,7 +423,20 @@ type indexInfo struct { IndexType *string `json:"index_type"` IncludedColumns []string `json:"included_columns"` Comment *string `json:"comment"` - keys []xuguIndexKey + // Partition fields are intentionally internal. The generic DBX index + // protocol does not yet model Xugu-specific index partition clauses, but + // the DDL exporter must retain them to avoid changing index semantics. + IsLocal bool `json:"-"` + PartitionType int `json:"-"` + PartitionCount int `json:"-"` + PartitionKey string `json:"-"` + SubpartitionType int `json:"-"` + SubpartitionCount int `json:"-"` + SubpartitionKey string `json:"-"` + PartitionRowsLoaded bool `json:"-"` + IndexPartitions []xuguPartitionInfo `json:"-"` + IndexSubpartitions []xuguPartitionInfo `json:"-"` + keys []xuguIndexKey } func (i indexInfo) MarshalJSON() ([]byte, error) { @@ -1664,7 +1714,7 @@ func isXuguMetadataAccessError(err error) bool { catalogObject := false for _, object := range []string{ "DATABASES", "SCHEMAS", "TABLES", "VIEWS", "COLUMNS", "CONSTRAINTS", "INDEXES", - "TRIGGERS", "PARTIS", "SUBPARTIS", "SEQUENCES", "SYNONYMS", "PROCEDURES", "PACKAGES", "TYPES", + "TRIGGERS", "PARTIS", "SUBPARTIS", "IDX_PARTIS", "IDX_SUBPARTIS", "SEQUENCES", "SYNONYMS", "PROCEDURES", "PACKAGES", "TYPES", } { if strings.Contains(message, "ALL_"+object) || strings.Contains(message, "SYS_"+object) { catalogObject = true @@ -2648,9 +2698,90 @@ func (s *server) listIndexes(schema, table string) ([]indexInfo, error) { item.IncludedColumns = []string{} result = append(result, item) } + // LOCAL/GLOBAL attributes are best-effort metadata. Keep the stable index + // listing usable when an older Xugu catalog does not expose these columns. + s.loadIndexPartitionMetadata(catalogSchema, catalogTable, result) return emptyIfNil(result), rows.Err() } +// loadIndexPartitionMetadata enriches the stable index list with Xugu's +// partition scope and partition definitions. The generic DBX index payload +// does not expose these Xugu-specific fields, so they remain internal and are +// consumed by table DDL reconstruction only. +func (s *server) loadIndexPartitionMetadata(schema, table string, indexes []indexInfo) { + if len(indexes) == 0 { + return + } + byName := make(map[string]*indexInfo, len(indexes)) + for i := range indexes { + byName[indexes[i].Name] = &indexes[i] + } + + rows, err := s.queryRows(xuguTableCatalogQuery(xuguIndexPartitionAttributesSQL, schema, table), nil) + if err != nil { + return + } + func() { + defer s.closeRows(rows) + for rows.Next() { + var name, local, partitionType, partitionCount, partitionKey any + var subpartitionType, subpartitionCount, subpartitionKey any + if err := rows.Scan(&name, &local, &partitionType, &partitionCount, &partitionKey, + &subpartitionType, &subpartitionCount, &subpartitionKey); err != nil { + return + } + item := byName[xuguString(name)] + if item == nil { + continue + } + item.IsLocal = truthy(local) + item.PartitionType = xuguInt(partitionType) + item.PartitionCount = xuguInt(partitionCount) + item.PartitionKey = xuguString(partitionKey) + item.SubpartitionType = xuguInt(subpartitionType) + item.SubpartitionCount = xuguInt(subpartitionCount) + item.SubpartitionKey = xuguString(subpartitionKey) + } + }() + + rows, err = s.queryRows(xuguTableCatalogQuery(xuguIndexPartitionsSQL, schema, table), nil) + if err != nil { + return + } + func() { + defer s.closeRows(rows) + for rows.Next() { + var name, position, partitionName, partitionValue any + if err := rows.Scan(&name, &position, &partitionName, &partitionValue); err != nil { + return + } + if item := byName[xuguString(name)]; item != nil { + item.IndexPartitions = append(item.IndexPartitions, xuguPartitionInfo{ + Name: xuguString(partitionName), Value: xuguString(partitionValue), + }) + item.PartitionRowsLoaded = true + } + } + }() + + rows, err = s.queryRows(xuguTableCatalogQuery(xuguIndexSubpartitionsSQL, schema, table), nil) + if err != nil { + return + } + defer s.closeRows(rows) + for rows.Next() { + var name, position, partitionName, partitionValue any + if err := rows.Scan(&name, &position, &partitionName, &partitionValue); err != nil { + return + } + if item := byName[xuguString(name)]; item != nil { + item.IndexSubpartitions = append(item.IndexSubpartitions, xuguPartitionInfo{ + Name: xuguString(partitionName), Value: xuguString(partitionValue), + }) + } + } +} + func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error) { schema, err := s.normalizeSchema(schema) if err != nil { @@ -4555,10 +4686,7 @@ func (s *server) appendTableIndexDDL(schema, table, ddl string) string { builder.WriteString(renderXuguIndexKey(key)) } builder.WriteByte(')') - if index.IndexType != nil && strings.TrimSpace(*index.IndexType) != "" { - builder.WriteString(" INDEXTYPE IS ") - builder.WriteString(strings.TrimSpace(*index.IndexType)) - } + appendXuguIndexOptions(&builder, index) builder.WriteByte(';') } if builder.Len() == 0 { @@ -4567,6 +4695,125 @@ func (s *server) appendTableIndexDDL(schema, table, ddl string) string { return appendDDLStatement(ddl, builder.String()) } +func appendXuguIndexOptions(builder *strings.Builder, index indexInfo) { + if index.IndexType != nil && strings.TrimSpace(*index.IndexType) != "" { + builder.WriteString(" INDEXTYPE IS ") + builder.WriteString(strings.TrimSpace(*index.IndexType)) + } + if index.IsLocal { + builder.WriteString(" LOCAL") + return + } + if index.PartitionType == 0 || !index.PartitionRowsLoaded { + return + } + partitionDDL := renderXuguIndexPartitionDDL(index) + if partitionDDL != "" { + builder.WriteString(" GLOBAL") + builder.WriteString(partitionDDL) + } +} + +func renderXuguIndexPartitionDDL(index indexInfo) string { + typeName := xuguPartitionType(index.PartitionType) + key := strings.TrimSpace(index.PartitionKey) + if typeName == "" || key == "" { + return "" + } + + var builder strings.Builder + builder.WriteString(" PARTITION BY ") + builder.WriteString(typeName) + builder.WriteString(" (") + builder.WriteString(key) + builder.WriteByte(')') + if index.PartitionType == 3 { + if index.PartitionCount <= 0 { + return "" + } + builder.WriteString(fmt.Sprintf(" PARTITIONS %d", index.PartitionCount)) + } else if len(index.IndexPartitions) > 0 { + for _, partition := range index.IndexPartitions { + if strings.TrimSpace(partition.Name) == "" || strings.TrimSpace(partition.Value) == "" { + return "" + } + } + builder.WriteString(" PARTITIONS (\n") + for i, partition := range index.IndexPartitions { + if i > 0 { + builder.WriteString(",\n") + } + builder.WriteString(" ") + builder.WriteString(quoteIdentifier(partition.Name)) + builder.WriteByte(' ') + if index.PartitionType == 1 { + builder.WriteString("VALUES LESS THAN (") + } else { + builder.WriteString("VALUES (") + } + builder.WriteString(strings.TrimSpace(partition.Value)) + builder.WriteByte(')') + } + builder.WriteString("\n)") + } else { + return "" + } + + subType := xuguPartitionType(index.SubpartitionType) + subKey := strings.TrimSpace(index.SubpartitionKey) + if subType == "" || subKey == "" { + return builder.String() + } + builder.WriteString(" SUBPARTITION BY ") + builder.WriteString(subType) + builder.WriteString(" (") + builder.WriteString(subKey) + builder.WriteByte(')') + if index.SubpartitionType == 3 { + if index.SubpartitionCount <= 0 { + return builderWithoutSubpartitionDDL(index, builder) + } + builder.WriteString(fmt.Sprintf(" SUBPARTITIONS %d", index.SubpartitionCount)) + } else if len(index.IndexSubpartitions) > 0 { + for _, partition := range index.IndexSubpartitions { + if strings.TrimSpace(partition.Name) == "" || strings.TrimSpace(partition.Value) == "" { + return builderWithoutSubpartitionDDL(index, builder) + } + } + builder.WriteString(" SUBPARTITIONS (\n") + for i, partition := range index.IndexSubpartitions { + if i > 0 { + builder.WriteString(",\n") + } + builder.WriteString(" ") + builder.WriteString(quoteIdentifier(partition.Name)) + builder.WriteByte(' ') + if index.SubpartitionType == 1 { + builder.WriteString("VALUES LESS THAN (") + } else { + builder.WriteString("VALUES (") + } + builder.WriteString(strings.TrimSpace(partition.Value)) + builder.WriteByte(')') + } + builder.WriteString("\n)") + } + return builder.String() +} + +// builderWithoutSubpartitionDDL returns the complete first-level definition +// when Xugu exposes a subpartition marker but not enough detail to replay it. +// Emitting an incomplete SUBPARTITION clause is worse than preserving a valid +// global index DDL without that optional detail. +func builderWithoutSubpartitionDDL(index indexInfo, builder strings.Builder) string { + firstLevel := index + firstLevel.SubpartitionType = 0 + firstLevel.SubpartitionKey = "" + firstLevel.SubpartitionCount = 0 + firstLevel.IndexSubpartitions = nil + return renderXuguIndexPartitionDDL(firstLevel) +} + // uniqueKeyColumnSets returns column lists for PRIMARY KEY and UNIQUE constraints. // Xugu stores those definitions as quoted identifiers inside ALL_CONSTRAINTS.DEFINE. func uniqueKeyColumnSets(constraints []xuguConstraintInfo) [][]string { diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 6fb039d400..7f941a1fd6 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -964,6 +964,148 @@ func TestIndexSQLUsesLowPrivilegeDictionary(t *testing.T) { } } +func TestIndexPartitionMetadataUsesLowPrivilegeDictionary(t *testing.T) { + for name, query := range map[string]string{ + "index attributes": xuguIndexPartitionAttributesSQL, + "index partitions": xuguIndexPartitionsSQL, + "index subpartitions": xuguIndexSubpartitionsSQL, + } { + t.Run(name, func(t *testing.T) { + upper := strings.ToUpper(query) + for _, want := range []string{"ALL_INDEXES", "ALL_TABLES", "ALL_SCHEMAS", "CURRENT_DB_ID"} { + if !strings.Contains(upper, want) { + t.Fatalf("%s query should contain %s: %s", name, want, query) + } + } + if name != "index attributes" && !strings.Contains(upper, "ALL_IDX_") { + t.Fatalf("%s query should use the low-privilege index partition view: %s", name, query) + } + for _, forbidden := range []string{"SYS_INDEXES", "SYS_IDX_PARTIS", "SYS_IDX_SUBPARTIS"} { + if strings.Contains(upper, forbidden) { + t.Fatalf("%s query should not use %s: %s", name, forbidden, query) + } + } + }) + } + if !strings.Contains(strings.ToUpper(xuguIndexPartitionAttributesSQL), "IS_LOCAL") { + t.Fatal("index attributes query must preserve LOCAL scope") + } + for _, query := range []string{xuguIndexPartitionAttributesSQL, xuguIndexPartitionsSQL, xuguIndexSubpartitionsSQL} { + upper := strings.ToUpper(query) + if !strings.Contains(upper, "SCHEMA_NAME = ?") || !strings.Contains(upper, "TABLE_NAME = ?") { + t.Fatalf("index metadata query must be scoped to the resolved schema/table: %s", query) + } + } +} + +func TestXuguIndexScopeDDL(t *testing.T) { + indexType := "BTREE" + cases := []struct { + name string + index indexInfo + want string + }{ + { + name: "ordinary index does not invent GLOBAL", + index: indexInfo{IndexType: &indexType}, + want: " INDEXTYPE IS BTREE", + }, + { + name: "local partition index", + index: indexInfo{IndexType: &indexType, IsLocal: true}, + want: " INDEXTYPE IS BTREE LOCAL", + }, + { + name: "global range partition index", + index: indexInfo{ + IndexType: indexTypePtr("BTREE"), PartitionType: 1, PartitionKey: `"CREATED_AT"`, + PartitionRowsLoaded: true, + IndexPartitions: []xuguPartitionInfo{ + {Name: "P1", Value: "'2025-01-01'"}, + {Name: "P2", Value: "'2026-01-01'"}, + }, + }, + want: " GLOBAL PARTITION BY RANGE (\"CREATED_AT\") PARTITIONS (", + }, + { + name: "global hash partition index", + index: indexInfo{ + IndexType: indexTypePtr("BTREE"), PartitionType: 3, PartitionCount: 4, + PartitionKey: `"CUSTOMER_ID"`, PartitionRowsLoaded: true, + }, + want: " GLOBAL PARTITION BY HASH (\"CUSTOMER_ID\") PARTITIONS 4", + }, + { + name: "incomplete global metadata is not emitted", + index: indexInfo{IndexType: indexTypePtr("BTREE"), PartitionType: 1, PartitionKey: `"ID"`}, + want: " INDEXTYPE IS BTREE", + }, + { + name: "global hash without a count is not emitted", + index: indexInfo{IndexType: indexTypePtr("BTREE"), PartitionType: 3, PartitionKey: `"ID"`, PartitionRowsLoaded: true}, + want: " INDEXTYPE IS BTREE", + }, + { + name: "malformed global partition row is not emitted", + index: indexInfo{ + IndexType: indexTypePtr("BTREE"), PartitionType: 2, PartitionKey: `"REGION"`, + PartitionRowsLoaded: true, IndexPartitions: []xuguPartitionInfo{{Name: "P1"}}, + }, + want: " INDEXTYPE IS BTREE", + }, + { + name: "incomplete subpartition keeps valid first level", + index: indexInfo{ + IndexType: indexTypePtr("BTREE"), PartitionType: 2, PartitionKey: `"REGION"`, + PartitionRowsLoaded: true, IndexPartitions: []xuguPartitionInfo{{Name: "P1", Value: "'CN'"}}, + SubpartitionType: 3, SubpartitionKey: `"ID"`, + }, + want: " GLOBAL PARTITION BY LIST (\"REGION\") PARTITIONS (", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var builder strings.Builder + appendXuguIndexOptions(&builder, tc.index) + got := builder.String() + if got != tc.want && !strings.Contains(got, tc.want) { + t.Fatalf("index option DDL = %q, want %q", got, tc.want) + } + if tc.name == "ordinary index does not invent GLOBAL" && strings.Contains(got, "GLOBAL") { + t.Fatalf("ordinary index must not be labeled GLOBAL: %q", got) + } + if tc.name == "incomplete subpartition keeps valid first level" && strings.Contains(got, "SUBPARTITION") { + t.Fatalf("incomplete subpartition metadata must not produce a partial clause: %q", got) + } + }) + } +} + +func indexTypePtr(value string) *string { return &value } + +func TestXuguIndexPartitionDetailsStayInternalToTheGenericPayload(t *testing.T) { + data, err := json.Marshal(indexInfo{ + Name: "IDX_LOCAL", Columns: []string{"ID"}, IsLocal: true, + PartitionType: 1, PartitionKey: `"ID"`, PartitionRowsLoaded: true, + IndexPartitions: []xuguPartitionInfo{{Name: "P1", Value: "MAXVALUES"}}, + }) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, forbidden := range []string{"is_local", "partition_type", "partition_key", "index_partitions"} { + if strings.Contains(text, forbidden) { + t.Fatalf("Xugu-specific index partition field %q leaked into generic metadata: %s", forbidden, text) + } + } + for _, required := range []string{`"name":"IDX_LOCAL"`, `"columns":["ID"]`} { + if !strings.Contains(text, required) { + t.Fatalf("generic index metadata lost %q: %s", required, text) + } + } +} + func TestTableChildMetadataUsesLowPrivilegeDictionary(t *testing.T) { for name, query := range map[string]string{ "constraints": xuguTableConstraintsSQL, @@ -2448,6 +2590,7 @@ func init() { sql.Register("xugu-test-eof", &xuguEOFDriver{}) sql.Register("xugu-test-trigger-details", &xuguTriggerDetailsDriver{}) sql.Register("xugu-test-schema-listing", &xuguSchemaListingDriver{}) + sql.Register("xugu-test-index-partition-fallback", &xuguIndexPartitionFallbackDriver{}) } var xuguSchemaListingState struct { @@ -2604,6 +2747,24 @@ func TestMetadataPermissionFallbackDoesNotReturnRPCError(t *testing.T) { } } +func TestIndexListingSurvivesUnavailablePartitionCatalog(t *testing.T) { + db, err := sql.Open("xugu-test-index-partition-fallback", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + indexes, err := s.listIndexes("APP", "T") + if err != nil { + t.Fatal(err) + } + if len(indexes) != 1 || indexes[0].Name != "IDX_T" || indexes[0].IsLocal { + t.Fatalf("stable index listing should survive unavailable partition metadata: %#v", indexes) + } +} + func TestGetColumnsFallsBackToDirectObjectAccessOnMetadataPermission(t *testing.T) { db, err := sql.Open("xugu-test-permission-metadata", "") if err != nil { @@ -2722,6 +2883,40 @@ type xuguShowResultDriver struct{} type xuguPermissionMetadataDriver struct{} +type xuguIndexPartitionFallbackDriver struct{} + +func (d *xuguIndexPartitionFallbackDriver) Open(string) (driver.Conn, error) { + return &xuguIndexPartitionFallbackConn{}, nil +} + +type xuguIndexPartitionFallbackConn struct{} + +func (c *xuguIndexPartitionFallbackConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguIndexPartitionFallbackConn) Close() error { return nil } +func (c *xuguIndexPartitionFallbackConn) Begin() (driver.Tx, error) { + return nil, errors.New("not supported") +} +func (c *xuguIndexPartitionFallbackConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + upper := strings.ToUpper(query) + switch { + case strings.Contains(upper, "SELECT S.SCHEMA_NAME, T.TABLE_NAME"): + return &xuguStaticRows{columns: []string{"SCHEMA_NAME", "TABLE_NAME"}, values: [][]driver.Value{{"APP", "T"}}}, nil + case strings.Contains(upper, "I.IS_LOCAL"): + return nil, errors.New("unknown column IS_LOCAL in older Xugu catalog") + case strings.Contains(upper, "FROM ALL_IDX_PARTIS"), strings.Contains(upper, "FROM ALL_IDX_SUBPARTIS"): + return nil, errors.New("index partition views are unavailable in older Xugu catalog") + case strings.Contains(upper, "SELECT I.INDEX_NAME, I.KEYS"): + return &xuguStaticRows{ + columns: []string{"INDEX_NAME", "KEYS", "IS_UNIQUE", "IS_PRIMARY", "INDEX_TYPE", "FILTER"}, + values: [][]driver.Value{{"IDX_T", `"ID"`, false, false, int64(0), nil}}, + }, nil + default: + return nil, fmt.Errorf("unexpected index fallback query: %s", query) + } +} + type xuguFallbackErrorDriver struct{} func (d *xuguFallbackErrorDriver) Open(name string) (driver.Conn, error) {