mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(xugu): preserve index partition metadata
This commit is contained in:
@@ -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
|
||||
}
|
||||
+253
-6
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user