diff --git a/.gitignore b/.gitignore index 6a93213319..fd1c11bdbd 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ DBX_*_x64-portable.zip # Local devtest connections (contains credentials; do not commit) dbx-devtest-connections.json .npmrc + +# Real database integration credentials (see docs/TEST_ENVIRONMENTS.md) +/docs/TEST_ENVIRONMENTS.md diff --git a/agents/drivers/kingbase-go/integration_test.go b/agents/drivers/kingbase-go/integration_test.go index dd8a28344f..b5e94cf570 100644 --- a/agents/drivers/kingbase-go/integration_test.go +++ b/agents/drivers/kingbase-go/integration_test.go @@ -144,6 +144,273 @@ func TestKingbaseIntegration(t *testing.T) { } } +func TestKingbaseCustomTypesIntegration(t *testing.T) { + host := os.Getenv("KINGBASE_TEST_HOST") + portText := os.Getenv("KINGBASE_TEST_PORT") + username := os.Getenv("KINGBASE_TEST_USERNAME") + password := os.Getenv("KINGBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Kingbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + database := os.Getenv("KINGBASE_TEST_DATABASE") + if database == "" { + database = "test" + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_types_" + suffix + schemaIdent := quoteIdentifier(schema) + statusType := schemaIdent + "." + quoteIdentifier("status") + emailDomain := schemaIdent + "." + quoteIdentifier("email") + addressType := schemaIdent + "." + quoteIdentifier("address") + ordersTable := schemaIdent + "." + quoteIdentifier("orders") + + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:kingbase8://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { + _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"}) + }) + + mustExecute(t, server, "CREATE SCHEMA "+schemaIdent) + + // Detect the compatibility mode before creating any type object: MySQL + // compatibility mode has no pg_type catalog contract and may reject type + // syntax, so the type feature degrades to an empty group (never an error) + // while the plain table listing keeps working. + if server.mode.mysqlCompat { + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (id bigint, state text, ship_to text)") + empty, err := server.listCustomTypes(schema) + if err != nil { + t.Fatalf("listCustomTypes failed in mysql compat mode: %v", err) + } + if len(empty) != 0 { + t.Fatalf("mysql compat mode must not list types, got %#v", empty) + } + typeOnly, err := server.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"TYPE"}}) + if err != nil { + t.Fatalf("listObjects([TYPE]) failed in mysql compat mode: %v", err) + } + if len(typeOnly) != 0 { + t.Fatalf("mysql compat mode TYPE request must be empty, got %#v", typeOnly) + } + all, err := server.listObjects(schema, metadataListConstraints{}) + if err != nil { + t.Fatalf("listObjects(all) failed in mysql compat mode: %v", err) + } + var sawOrders bool + for _, item := range all { + if item.Name == "orders" && item.ObjectType == "TABLE" { + sawOrders = true + } + if item.ObjectType == "TYPE" || strings.Contains(item.ObjectType, "FUNCTION") || strings.Contains(item.ObjectType, "PROCEDURE") { + t.Fatalf("mysql compat mode must not list types or routines: %#v", all) + } + } + if !sawOrders { + t.Fatalf("orders table missing from mysql compat listing: %#v", all) + } + return + } + + mustExecute(t, server, "CREATE TYPE "+statusType+" AS ENUM ('draft', 'published')") + mustExecute(t, server, "CREATE DOMAIN "+emailDomain+" AS text CHECK (VALUE ~ '.+@.+')") + mustExecute(t, server, "CREATE TYPE "+addressType+" AS (city text, zip text)") + mustExecute(t, server, "COMMENT ON TYPE "+statusType+" IS '订单状态'") + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (id bigint, state "+statusType+", ship_to "+addressType+")") + + customTypes, err := server.listCustomTypes(schema) + if err != nil { + t.Fatalf("listCustomTypes failed: %v", err) + } + typeNames := make(map[string]string, len(customTypes)) + for _, item := range customTypes { + comment := "" + if item.Comment != nil { + comment = *item.Comment + } + typeNames[item.Name] = comment + } + if len(customTypes) != 3 { + t.Fatalf("expected exactly the 3 user-created types, got %#v", customTypes) + } + for _, name := range []string{"status", "email", "address"} { + if _, ok := typeNames[name]; !ok { + t.Fatalf("user-created type %q missing from listing: %#v", name, customTypes) + } + } + if _, ok := typeNames["orders"]; ok { + t.Fatalf("relation auto-generated row type leaked into type listing: %#v", customTypes) + } + for _, name := range []string{"_status", "_email", "_address"} { + if _, ok := typeNames[name]; ok { + t.Fatalf("auto-generated array type %q leaked into type listing: %#v", name, customTypes) + } + } + if comment := typeNames["status"]; comment != "订单状态" { + t.Fatalf("type comment was lost: got %q, want %q", comment, "订单状态") + } + for _, item := range customTypes { + if item.ObjectType != "TYPE" { + t.Fatalf("type object_type was lost: %#v", item) + } + } + + // A dedicated TYPE request must return only types. + // The sidebar type group sends TYPE together with the TYPE_BODY companion kind. + for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} { + onlyTypes, err := server.listObjects(schema, metadataListConstraints{ObjectTypes: objectTypes}) + if err != nil { + t.Fatalf("listObjects(%v) failed: %v", objectTypes, err) + } + if len(onlyTypes) != 3 { + t.Fatalf("listObjects(%v) must return only the 3 types: %#v", objectTypes, onlyTypes) + } + for _, item := range onlyTypes { + if item.ObjectType != "TYPE" { + t.Fatalf("listObjects(%v) returned a non-type: %#v", objectTypes, onlyTypes) + } + } + } + + // The unfiltered object list keeps the table and the types, and never + // exposes the array companions or the relation row type. + all, err := server.listObjects(schema, metadataListConstraints{}) + if err != nil { + t.Fatalf("listObjects(all) failed: %v", err) + } + var sawOrders bool + for _, item := range all { + if item.Name == "orders" && item.ObjectType == "TABLE" { + sawOrders = true + } + if strings.HasPrefix(item.Name, "_") { + t.Fatalf("auto-generated array type leaked into object list: %#v", all) + } + } + if !sawOrders { + t.Fatalf("orders table missing from object list: %#v", all) + } +} + +func TestKingbaseCustomTypeDetailsIntegration(t *testing.T) { + host := os.Getenv("KINGBASE_TEST_HOST") + portText := os.Getenv("KINGBASE_TEST_PORT") + username := os.Getenv("KINGBASE_TEST_USERNAME") + password := os.Getenv("KINGBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Kingbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + database := os.Getenv("KINGBASE_TEST_DATABASE") + if database == "" { + database = "test" + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_details_" + suffix + schemaIdent := quoteIdentifier(schema) + statusType := schemaIdent + "." + quoteIdentifier("status") + emailDomain := schemaIdent + "." + quoteIdentifier("email") + addressType := schemaIdent + "." + quoteIdentifier("address") + priceRangeType := schemaIdent + "." + quoteIdentifier("price_range") + ordersTable := schemaIdent + "." + quoteIdentifier("orders") + + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:kingbase8://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { + _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"}) + }) + + mustExecute(t, server, "CREATE SCHEMA "+schemaIdent) + + // MySQL compatibility mode has no pg_type contract; details must return an + // explicit unsupported error instead of executing PG catalog SQL. + if server.mode.mysqlCompat { + if _, err := server.getTypeDetails(schema, "status"); err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") { + t.Fatalf("expected MySQL compat rejection, got %v", err) + } + return + } + + mustExecute(t, server, "CREATE TYPE "+statusType+" AS ENUM ('draft', 'published', '已归档')") + mustExecute(t, server, "CREATE DOMAIN "+emailDomain+" AS text DEFAULT '' CHECK (VALUE <> '')") + mustExecute(t, server, "CREATE TYPE "+addressType+" AS (city text, zip numeric(6))") + mustExecute(t, server, "COMMENT ON TYPE "+addressType+" IS 'shipping address'") + mustExecute(t, server, "COMMENT ON COLUMN "+addressType+".city IS 'city name'") + mustExecute(t, server, "CREATE TYPE "+priceRangeType+" AS RANGE (subtype = numeric)") + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (state "+statusType+", address "+addressType+")") + + status, err := server.getTypeDetails(schema, "status") + if err != nil { + t.Fatalf("getTypeDetails(status) failed: %v", err) + } + if status.Kind != customTypeKindEnum || len(status.Members) != 3 { + t.Fatalf("unexpected enum details: %+v", status) + } + if status.Members[0].EnumValue == nil || *status.Members[0].EnumValue != "draft" || status.Members[2].EnumValue == nil || *status.Members[2].EnumValue != "已归档" { + t.Fatalf("enum values out of order: %+v", status.Members) + } + if status.DDL == nil || !status.DDL.Complete || !strings.Contains(status.DDL.SQL, "AS ENUM ('draft', 'published', '已归档')") { + t.Fatalf("unexpected enum DDL: %+v", status.DDL) + } + + email, err := server.getTypeDetails(schema, "email") + if err != nil { + t.Fatalf("getTypeDetails(email) failed: %v", err) + } + if email.Kind != customTypeKindDomain || email.Properties.BaseType == nil || *email.Properties.BaseType != "text" { + t.Fatalf("unexpected domain details: %+v", email) + } + if len(email.Properties.DomainConstraints) == 0 || !strings.Contains(email.Properties.DomainConstraints[0].Definition, "VALUE") { + t.Fatalf("domain constraint lost: %+v", email.Properties.DomainConstraints) + } + + address, err := server.getTypeDetails(schema, "address") + if err != nil { + t.Fatalf("getTypeDetails(address) failed: %v", err) + } + if address.Kind != customTypeKindComposite || len(address.Members) != 2 || address.Members[0].Name != "city" || address.Members[0].Comment == nil || *address.Members[0].Comment != "city name" { + t.Fatalf("unexpected composite details: %+v", address) + } + if address.DDL == nil || !address.DDL.Complete || !strings.Contains(address.DDL.SQL, "COMMENT ON COLUMN "+schemaIdent+".\"address\".\"city\" IS 'city name';") { + t.Fatalf("unexpected composite DDL: %+v", address.DDL) + } + + priceRange, err := server.getTypeDetails(schema, "price_range") + if err != nil { + t.Fatalf("getTypeDetails(price_range) failed: %v", err) + } + if priceRange.Kind != customTypeKindRange || priceRange.Properties.RangeSubtype == nil || *priceRange.Properties.RangeSubtype != "numeric" { + t.Fatalf("unexpected range details: %+v", priceRange) + } + + if _, err := server.getTypeDetails(schema, "orders"); err == nil || !strings.Contains(err.Error(), "row type") { + t.Fatalf("relation row type must be rejected, got %v", err) + } + if _, err := server.getTypeDetails(schema, "_status"); err == nil || !strings.Contains(err.Error(), "array companion") { + t.Fatalf("array companion must be rejected, got %v", err) + } +} + func rawJSON(value any) json.RawMessage { data, err := json.Marshal(value) if err != nil { diff --git a/agents/drivers/kingbase-go/kingbase_metadata.go b/agents/drivers/kingbase-go/kingbase_metadata.go index 0686e847be..4c6dff53c5 100644 --- a/agents/drivers/kingbase-go/kingbase_metadata.go +++ b/agents/drivers/kingbase-go/kingbase_metadata.go @@ -50,11 +50,13 @@ type tableInfo struct { } type objectInfo struct { - Name string `json:"name"` - ObjectType string `json:"object_type"` - Schema string `json:"schema"` - Comment *string `json:"comment"` - Valid *bool `json:"valid,omitempty"` + Name string `json:"name"` + ObjectType string `json:"object_type"` + Schema string `json:"schema"` + Comment *string `json:"comment"` + Valid *bool `json:"valid,omitempty"` + CustomTypeKind *string `json:"custom_type_kind,omitempty"` + HasMembers *bool `json:"has_members,omitempty"` } type metadataListConstraints struct { @@ -324,20 +326,725 @@ LIMIT 1`, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLitera return nullStringPtr(comment), nil } +// listCustomTypes lists user-defined types visible in the given schema. +// +// Only explicitly created types are returned: base types (b), standalone +// composite types (c), domains (d), enums (e), ranges (r) and multiranges (m). +// Relation auto-generated row types (table/view/materialized view/foreign +// table/partitioned table) are excluded via `typrelid = 0 OR relkind = 'c'`, +// and array companion types are excluded via `typelem = 0`. MySQL +// compatibility mode has no pg_type catalog contract and returns nothing. +// +// The comment join scopes description entries to the type catalog itself via +// a regclass cast. `t.tableoid` cannot be used because Kingbase's native +// sys_type catalog has no tableoid system column. Kingbase and Vastbase both +// key COMMENT ON TYPE entries with the pg_type identity (oid 1247) even when +// the server is in sys_catalog compatibility mode, so the filter always +// references pg_catalog.pg_type. +func (s *server) listCustomTypes(schema string) ([]objectInfo, error) { + if s.mode.mysqlCompat { + return []objectInfo{}, nil + } + catalog := "sys_catalog" + if s.mode.postgresCatalog { + catalog = "pg_catalog" + } + prefix := catalogPrefix(catalog) + query := fmt.Sprintf(`SELECT t.typname, d.description, t.typtype::text, +CASE + WHEN t.typtype = 'c' THEN EXISTS ( + SELECT 1 FROM %s.%s_attribute a + WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped + ) + WHEN t.typtype = 'e' THEN EXISTS ( + SELECT 1 FROM %s.%s_enum e WHERE e.enumtypid = t.oid + ) + ELSE false +END AS has_members +FROM %s.%s_type t +JOIN %s.%s_namespace n ON n.oid = t.typnamespace +LEFT JOIN %s.%s_class c ON c.oid = t.typrelid +LEFT JOIN %s.%s_description d ON d.objoid = t.oid AND d.classoid = 'pg_catalog.pg_type'::regclass AND d.objsubid = 0 +WHERE n.nspname = %s + AND t.typtype IN ('b','c','d','e','r','m') + AND t.typisdefined + AND t.typelem = 0 + AND (t.typrelid = 0 OR c.relkind = 'c') + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + AND n.nspname NOT LIKE 'pg_toast%%' + AND n.nspname NOT LIKE 'pg_temp%%' +ORDER BY t.typname`, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema)) + rows, err := s.metadataQuery(query) + if err != nil { + return nil, err + } + defer rows.Close() + result := []objectInfo{} + for rows.Next() { + var name, kindCode string + var comment sql.NullString + var hasMembers bool + if err := rows.Scan(&name, &comment, &kindCode, &hasMembers); err != nil { + return nil, err + } + kind, ok := customTypeKindFromCode(kindCode) + if !ok { + continue + } + kindValue := string(kind) + result = append(result, objectInfo{Name: name, ObjectType: "TYPE", Schema: schema, Comment: nullStringPtr(comment), CustomTypeKind: &kindValue, HasMembers: &hasMembers}) + } + return result, rows.Err() +} + +func isSystemSchema(schema string) bool { + return schema == "pg_catalog" || schema == "information_schema" || strings.HasPrefix(schema, "pg_toast") || strings.HasPrefix(schema, "pg_temp") +} + +type customTypeKind string + +const ( + customTypeKindBase customTypeKind = "base" + customTypeKindComposite customTypeKind = "composite" + customTypeKindDomain customTypeKind = "domain" + customTypeKindEnum customTypeKind = "enum" + customTypeKindRange customTypeKind = "range" + customTypeKindMultirange customTypeKind = "multirange" +) + +type customTypeMember struct { + Name string `json:"name"` + DataType string `json:"dataType"` + Ordinal int32 `json:"ordinal"` + Nullable *bool `json:"nullable,omitempty"` + Default *string `json:"default,omitempty"` + Comment *string `json:"comment,omitempty"` + EnumValue *string `json:"enumValue,omitempty"` +} + +type customTypeDomainConstraint struct { + Name string `json:"name"` + Definition string `json:"definition"` +} + +type customTypeProperties struct { + BaseType *string `json:"baseType,omitempty"` + NotNull *bool `json:"notNull,omitempty"` + Default *string `json:"default,omitempty"` + Collation *string `json:"collation,omitempty"` + DomainConstraints []customTypeDomainConstraint `json:"domainConstraints"` + RangeSubtype *string `json:"rangeSubtype,omitempty"` + RangeMultirangeName *string `json:"rangeMultirangeName,omitempty"` + RangeCanonicalFunction *string `json:"rangeCanonicalFunction,omitempty"` + RangeSubtypeDiffFunction *string `json:"rangeSubtypeDiffFunction,omitempty"` + RangeSubtypeOpclass *string `json:"rangeSubtypeOpclass,omitempty"` + InputFunction *string `json:"inputFunction,omitempty"` + OutputFunction *string `json:"outputFunction,omitempty"` + ReceiveFunction *string `json:"receiveFunction,omitempty"` + SendFunction *string `json:"sendFunction,omitempty"` + AnalyzeFunction *string `json:"analyzeFunction,omitempty"` + Internallength *int32 `json:"internallength,omitempty"` + PassedByValue *bool `json:"passedByValue,omitempty"` + Alignment *string `json:"alignment,omitempty"` + Storage *string `json:"storage,omitempty"` +} + +type customTypeDdl struct { + SQL string `json:"sql"` + Complete bool `json:"complete"` + Warnings []string `json:"warnings,omitempty"` +} + +type customTypeDetails struct { + Name string `json:"name"` + Schema string `json:"schema"` + Kind customTypeKind `json:"kind"` + Comment *string `json:"comment,omitempty"` + Members []customTypeMember `json:"members"` + Properties customTypeProperties `json:"properties"` + DDL *customTypeDdl `json:"ddl,omitempty"` +} + +// customTypeCatalogQueries carries catalog-aware SQL fragments for type +// details. Kingbase exposes pg_get_expr/pg_get_constraintdef under the sys_ +// prefix in system-catalog mode, so the function names follow the catalog. +type customTypeCatalogQueries struct { + general string + enumMembers string + compositeMembers string + domainBaseType string + domainRenderedDefault string + domainConstraints string + rangeAttributes string + rangeAttributesForMultirange string + rangeMultirange string + collationName string +} + +// qualifiedCatalogTypeExpression keeps user-defined type references usable +// outside the current search_path while retaining format_type's typmod output +// for built-in pg_catalog types. +func qualifiedCatalogTypeExpression(typeAlias, namespaceAlias, elementAlias, elementNamespaceAlias, oidExpression, typmodExpression string) string { + return fmt.Sprintf(`CASE + WHEN %s.typelem <> 0 AND %s.nspname <> 'pg_catalog' + THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) || '[]' + WHEN %s.nspname <> 'pg_catalog' + THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) + ELSE format_type(%s, %s) +END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, oidExpression, typmodExpression) +} + +func customTypeCatalogQueriesFor(catalog, prefix, schema, name string) customTypeCatalogQueries { + getExpr := prefix + "_get_expr" + getConstraintDef := prefix + "_get_constraintdef" + typeTable := catalog + "." + prefix + "_type" + namespaceTable := catalog + "." + prefix + "_namespace" + classTable := catalog + "." + prefix + "_class" + descriptionTable := catalog + "." + prefix + "_description" + procTable := catalog + "." + prefix + "_proc" + collationTable := catalog + "." + prefix + "_collation" + enumTable := catalog + "." + prefix + "_enum" + attributeTable := catalog + "." + prefix + "_attribute" + attrdefTable := catalog + "." + prefix + "_attrdef" + constraintTable := catalog + "." + prefix + "_constraint" + rangeTable := catalog + "." + prefix + "_range" + opclassTable := catalog + "." + prefix + "_opclass" + quotedSchema := quoteLiteral(schema) + quotedName := quoteLiteral(name) + compositeMemberType := qualifiedCatalogTypeExpression("at", "atn", "elem", "elem_n", "a.atttypid", "a.atttypmod") + domainBaseType := qualifiedCatalogTypeExpression("t", "n", "elem", "elem_n", "t.oid", "%[2]d::int4") + rangeSubtype := qualifiedCatalogTypeExpression("st", "stn", "elem", "elem_n", "r.rngsubtype", "NULL::integer") + return customTypeCatalogQueries{ + general: fmt.Sprintf(`SELECT t.oid, t.typtype::text, t.typisdefined, +t.typbasetype, t.typnotnull, t.typrelid, t.typelem, t.typcollation, +t.typdefaultbin, t.typdefault, t.typlen, t.typbyval, +t.typalign::text, t.typstorage::text, t.typtypmod, +pi.proname, po.proname, pr.proname, ps.proname, pa.proname, +d.description, +CASE WHEN t.typrelid != 0 THEN (SELECT c.relkind::text FROM %s c WHERE c.oid = t.typrelid) END, +CASE WHEN cl.oid IS NULL THEN NULL ELSE quote_ident(ncl.nspname) || '.' || quote_ident(cl.collname) END +FROM %s t +JOIN %s n ON n.oid = t.typnamespace +LEFT JOIN %s d ON d.objoid = t.oid AND d.classoid = 'pg_catalog.pg_type'::regclass AND d.objsubid = 0 +LEFT JOIN %s pi ON pi.oid = t.typinput +LEFT JOIN %s po ON po.oid = t.typoutput +LEFT JOIN %s pr ON pr.oid = t.typreceive +LEFT JOIN %s ps ON ps.oid = t.typsend +LEFT JOIN %s pa ON pa.oid = t.typanalyze +LEFT JOIN %s cl ON cl.oid = t.typcollation +LEFT JOIN %s ncl ON ncl.oid = cl.collnamespace +WHERE n.nspname = %s AND t.typname = %s`, classTable, typeTable, namespaceTable, descriptionTable, procTable, procTable, procTable, procTable, procTable, collationTable, namespaceTable, quotedSchema, quotedName), + enumMembers: fmt.Sprintf(`SELECT e.enumlabel, e.enumsortorder +FROM %s e +WHERE e.enumtypid = %%d ORDER BY e.enumsortorder`, enumTable), + compositeMembers: fmt.Sprintf(`SELECT a.attname, %s, a.attnum, +NOT a.attnotnull, a.atthasdef, %s(ad.adbin, ad.adrelid), col_description(%%d, a.attnum) +FROM %s a +JOIN %s at ON at.oid = a.atttypid +JOIN %s atn ON atn.oid = at.typnamespace +LEFT JOIN %s elem ON elem.oid = at.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum +WHERE a.attrelid = %%d AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, compositeMemberType, getExpr, attributeTable, typeTable, namespaceTable, typeTable, namespaceTable, attrdefTable), + domainBaseType: fmt.Sprintf(`SELECT %s +FROM %s t +JOIN %s n ON n.oid = t.typnamespace +LEFT JOIN %s elem ON elem.oid = t.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +WHERE t.oid = %%[1]d`, domainBaseType, typeTable, namespaceTable, typeTable, namespaceTable), + domainRenderedDefault: fmt.Sprintf(`SELECT %s(t.typdefaultbin, 0) +FROM %s t WHERE t.oid = %%d`, getExpr, typeTable), + domainConstraints: fmt.Sprintf(`SELECT c.conname, %s(c.oid, true) FROM %s c WHERE c.contypid = %%d ORDER BY c.conname`, getConstraintDef, constraintTable), + rangeAttributes: fmt.Sprintf(`SELECT %s, quote_ident(ncan.nspname) || '.' || quote_ident(pcan.proname), quote_ident(ndiff.nspname) || '.' || quote_ident(pdiff.proname), quote_ident(nopc.nspname) || '.' || quote_ident(opc.opcname) +FROM %s r +JOIN %s st ON st.oid = r.rngsubtype +JOIN %s stn ON stn.oid = st.typnamespace +LEFT JOIN %s elem ON elem.oid = st.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s pcan ON pcan.oid = r.rngcanonical +LEFT JOIN %s pdiff ON pdiff.oid = r.rngsubdiff +LEFT JOIN %s opc ON opc.oid = r.rngsubopc +LEFT JOIN %s ncan ON ncan.oid = pcan.pronamespace +LEFT JOIN %s ndiff ON ndiff.oid = pdiff.pronamespace +LEFT JOIN %s nopc ON nopc.oid = opc.opcnamespace +WHERE r.rngtypid = %%d`, rangeSubtype, rangeTable, typeTable, namespaceTable, typeTable, namespaceTable, procTable, procTable, opclassTable, namespaceTable, namespaceTable, namespaceTable), + rangeAttributesForMultirange: fmt.Sprintf(`SELECT %s, quote_ident(ncan.nspname) || '.' || quote_ident(pcan.proname), quote_ident(ndiff.nspname) || '.' || quote_ident(pdiff.proname), quote_ident(nopc.nspname) || '.' || quote_ident(opc.opcname) +FROM %s r +JOIN %s st ON st.oid = r.rngsubtype +JOIN %s stn ON stn.oid = st.typnamespace +LEFT JOIN %s elem ON elem.oid = st.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s pcan ON pcan.oid = r.rngcanonical +LEFT JOIN %s pdiff ON pdiff.oid = r.rngsubdiff +LEFT JOIN %s opc ON opc.oid = r.rngsubopc +LEFT JOIN %s ncan ON ncan.oid = pcan.pronamespace +LEFT JOIN %s ndiff ON ndiff.oid = pdiff.pronamespace +LEFT JOIN %s nopc ON nopc.oid = opc.opcnamespace +WHERE r.rngmultitypid = %%d`, rangeSubtype, rangeTable, typeTable, namespaceTable, typeTable, namespaceTable, procTable, procTable, opclassTable, namespaceTable, namespaceTable, namespaceTable), + rangeMultirange: fmt.Sprintf(`SELECT mt.typname +FROM %s r +JOIN %s mt ON mt.oid = r.rngmultitypid +WHERE r.rngtypid = %%d`, rangeTable, typeTable), + collationName: fmt.Sprintf(`SELECT quote_ident(ncl.nspname) || '.' || quote_ident(cl.collname) FROM %s cl JOIN %s ncl ON ncl.oid = cl.collnamespace WHERE cl.oid = %%d`, collationTable, namespaceTable), + } +} + +// getTypeDetails returns read-only details of a user-defined type. MySQL +// compatibility mode is explicitly unsupported instead of running PostgreSQL +// catalog SQL against a MySQL-mode server. +func (s *server) getTypeDetails(schema, name string) (*customTypeDetails, error) { + if s.mode.mysqlCompat { + return nil, errors.New("type details are not supported in MySQL compatibility mode") + } + schema = strings.TrimSpace(schema) + name = strings.TrimSpace(name) + if schema == "" || name == "" { + return nil, errors.New("schema and type name are required") + } + if isSystemSchema(schema) { + return nil, fmt.Errorf("system schema %s is not supported for custom type details", schema) + } + catalog := "sys_catalog" + if s.mode.postgresCatalog { + catalog = "pg_catalog" + } + prefix := catalogPrefix(catalog) + queries := customTypeCatalogQueriesFor(catalog, prefix, schema, name) + + rows, err := s.metadataQuery(queries.general) + if err != nil { + return nil, fmt.Errorf("failed to locate custom type %s.%s: %w", schema, name, err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read type %s.%s: %w", schema, name, err) + } + return nil, fmt.Errorf("custom type %s.%s does not exist", schema, name) + } + var oid, typbasetype, typrelid, typelem, typcollation int64 + var typtype, typalign, typstorage string + var typisdefined, typnotnull, typbyval bool + var typdefaultbin, typdefault, inputFn, outputFn, receiveFn, sendFn, analyzeFn, comment, collname, relkind sql.NullString + var typlen sql.NullInt64 + var typtypmod int64 + if err := rows.Scan(&oid, &typtype, &typisdefined, &typbasetype, &typnotnull, &typrelid, &typelem, &typcollation, &typdefaultbin, &typdefault, &typlen, &typbyval, &typalign, &typstorage, &typtypmod, &inputFn, &outputFn, &receiveFn, &sendFn, &analyzeFn, &comment, &relkind, &collname); err != nil { + return nil, fmt.Errorf("failed to read type %s.%s: %w", schema, name, err) + } + if err := rows.Close(); err != nil { + return nil, err + } + if !typisdefined { + return nil, fmt.Errorf("custom type %s.%s is not fully defined", schema, name) + } + if typelem != 0 { + return nil, fmt.Errorf("custom type %s.%s is an array companion type", schema, name) + } + kind, ok := customTypeKindFromCode(typtype) + if !ok { + return nil, fmt.Errorf("custom type %s.%s is a pseudo type (typtype=%s)", schema, name, typtype) + } + if relkind.Valid && relkind.String != "" && relkind.String != "c" { + return nil, fmt.Errorf("%s.%s is the auto-generated row type of a relation, not an independent custom type", schema, name) + } + + properties := customTypeCommonProperties(inputFn, outputFn, receiveFn, sendFn, analyzeFn, typlen, typbyval, typalign, typstorage) + properties.DomainConstraints = []customTypeDomainConstraint{} + details := &customTypeDetails{ + Name: name, + Schema: schema, + Kind: kind, + Comment: nullStringPtr(comment), + Members: []customTypeMember{}, + Properties: properties, + } + + var warnings []string + switch kind { + case customTypeKindEnum: + details.Members, err = s.customTypeEnumMembers(queries.enumMembers, oid) + if err != nil { + return nil, err + } + case customTypeKindComposite: + details.Members, err = s.customTypeCompositeMembers(queries.compositeMembers, typrelid) + if err != nil { + return nil, err + } + case customTypeKindDomain: + warnings = append(warnings, s.customTypeDomainAttributes(queries, &details.Properties, oid, typbasetype, typtypmod, typnotnull, typdefaultbin, typdefault, typcollation, collname)...) + case customTypeKindRange: + warnings = append(warnings, s.customTypeRangeAttributes(queries, &details.Properties, oid, false)...) + case customTypeKindMultirange: + warnings = append(warnings, s.customTypeRangeAttributes(queries, &details.Properties, oid, true)...) + case customTypeKindBase: + } + details.DDL = buildCustomTypeDDL(schema, name, kind, inputFn, &details.Members, &details.Properties, warnings) + return details, nil +} + +func customTypeKindFromCode(code string) (customTypeKind, bool) { + switch code { + case "b": + return customTypeKindBase, true + case "c": + return customTypeKindComposite, true + case "d": + return customTypeKindDomain, true + case "e": + return customTypeKindEnum, true + case "r": + return customTypeKindRange, true + case "m": + return customTypeKindMultirange, true + default: + return "", false + } +} + +func customTypeCommonProperties(inputFn, outputFn, receiveFn, sendFn, analyzeFn sql.NullString, typlen sql.NullInt64, typbyval bool, typalign, typstorage string) customTypeProperties { + properties := customTypeProperties{} + properties.InputFunction = nullStringPtr(inputFn) + properties.OutputFunction = nullStringPtr(outputFn) + properties.ReceiveFunction = nullStringPtr(receiveFn) + properties.SendFunction = nullStringPtr(sendFn) + properties.AnalyzeFunction = nullStringPtr(analyzeFn) + if typlen.Valid && typlen.Int64 > 0 { + value := int32(typlen.Int64) + properties.Internallength = &value + } + properties.PassedByValue = &typbyval + if typalign != "" { + properties.Alignment = &typalign + } + if typstorage != "" { + properties.Storage = &typstorage + } + return properties +} + +func (s *server) customTypeEnumMembers(sqlTemplate string, oid int64) ([]customTypeMember, error) { + rows, err := s.metadataQuery(fmt.Sprintf(sqlTemplate, oid)) + if err != nil { + return nil, fmt.Errorf("failed to read enum values: %w", err) + } + defer rows.Close() + var members []customTypeMember + index := 0 + for rows.Next() { + var label string + var sortOrder float64 + if err := rows.Scan(&label, &sortOrder); err != nil { + return nil, err + } + // enumsortorder is float4; ALTER TYPE ... ADD VALUE BEFORE/AFTER can + // yield fractional values. Use the ORDER BY position for a unique key. + index++ + members = append(members, customTypeMember{Ordinal: int32(index), EnumValue: &label}) + } + return members, rows.Err() +} + +func (s *server) customTypeCompositeMembers(sqlTemplate string, typrelid int64) ([]customTypeMember, error) { + rows, err := s.metadataQuery(fmt.Sprintf(sqlTemplate, typrelid, typrelid)) + if err != nil { + return nil, fmt.Errorf("failed to read composite fields: %w", err) + } + defer rows.Close() + var members []customTypeMember + for rows.Next() { + var member customTypeMember + var hasDefault bool + var comment sql.NullString + if err := rows.Scan(&member.Name, &member.DataType, &member.Ordinal, &member.Nullable, &hasDefault, &member.Default, &comment); err != nil { + return nil, err + } + if !hasDefault { + member.Default = nil + } + member.Comment = nullStringPtr(comment) + members = append(members, member) + } + return members, rows.Err() +} + +func (s *server) customTypeDomainAttributes(queries customTypeCatalogQueries, properties *customTypeProperties, oid, typbasetype, typtypmod int64, typnotnull bool, typdefaultbin, typdefault sql.NullString, typcollation int64, collname sql.NullString) []string { + var warnings []string + if base := s.singleStringQuery(fmt.Sprintf(queries.domainBaseType, typbasetype, typtypmod)); base != "" { + properties.BaseType = &base + } + properties.NotNull = &typnotnull + defaultValue, defaultWarnings := resolveCustomTypeDomainDefault(typdefaultbin, typdefault, func() (string, error) { + return s.singleStringQueryResult(fmt.Sprintf(queries.domainRenderedDefault, oid)) + }) + properties.Default = defaultValue + warnings = append(warnings, defaultWarnings...) + if typcollation != 0 { + if collname.Valid && collname.String != "" { + properties.Collation = &collname.String + } else if value := s.singleStringQuery(fmt.Sprintf(queries.collationName, typcollation)); value != "" { + properties.Collation = &value + } + } + rows, err := s.metadataQuery(fmt.Sprintf(queries.domainConstraints, oid)) + if err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be read: %v", err)) + return warnings + } + for rows.Next() { + var constraint customTypeDomainConstraint + if err := rows.Scan(&constraint.Name, &constraint.Definition); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be decoded: %v", err)) + break + } + if constraint.Definition != "" { + properties.DomainConstraints = append(properties.DomainConstraints, constraint) + } + } + if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be read: %v", err)) + } + if err := rows.Close(); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be closed: %v", err)) + } + return warnings +} + +func (s *server) customTypeRangeAttributes(queries customTypeCatalogQueries, properties *customTypeProperties, oid int64, isMultirange bool) []string { + var warnings []string + // pg_range.rngtypid stores the RANGE oid; a multirange view resolves its + // owning range through rngmultitypid instead. + rangeTemplate := queries.rangeAttributes + if isMultirange { + rangeTemplate = queries.rangeAttributesForMultirange + } + rows, err := s.metadataQuery(fmt.Sprintf(rangeTemplate, oid)) + if err != nil { + return []string{fmt.Sprintf("range attributes could not be read: %v", err)} + } + if rows.Next() { + var subtype, canonical, subdiff, opclass sql.NullString + if err := rows.Scan(&subtype, &canonical, &subdiff, &opclass); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be decoded: %v", err)) + } else { + properties.RangeSubtype = nullStringPtr(subtype) + properties.RangeCanonicalFunction = nullStringPtr(canonical) + properties.RangeSubtypeDiffFunction = nullStringPtr(subdiff) + properties.RangeSubtypeOpclass = nullStringPtr(opclass) + } + } else if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be read: %v", err)) + } else { + warnings = append(warnings, "range attributes returned no rows") + } + if err := rows.Close(); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be closed: %v", err)) + } + // Optional PG 13+ multirange companion; older kernels have no column. + if multirangeRows, err := s.metadataQuery(fmt.Sprintf(queries.rangeMultirange, oid)); err == nil { + if multirangeRows.Next() { + var name string + if scanErr := multirangeRows.Scan(&name); scanErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be decoded: %v", scanErr)) + } else if name != "" { + properties.RangeMultirangeName = &name + } + } else if rowsErr := multirangeRows.Err(); rowsErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be read: %v", rowsErr)) + } + if closeErr := multirangeRows.Close(); closeErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be closed: %v", closeErr)) + } + } else { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be read: %v", err)) + } + return warnings +} + +func (s *server) singleStringQuery(query string) string { + value, _ := s.singleStringQueryResult(query) + return value +} + +func (s *server) singleStringQueryResult(query string) (string, error) { + rows, err := s.metadataQuery(query) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", rows.Err() + } + var value sql.NullString + if err := rows.Scan(&value); err != nil { + return "", err + } + if !value.Valid { + return "", nil + } + return value.String, nil +} + +func resolveCustomTypeDomainDefault(typdefaultbin, typdefault sql.NullString, render func() (string, error)) (*string, []string) { + if typdefault.Valid && typdefault.String != "" { + value := typdefault.String + return &value, nil + } + if !typdefaultbin.Valid || typdefaultbin.String == "" { + return nil, nil + } + value, err := render() + if err != nil { + return nil, []string{fmt.Sprintf("default value could not be rendered; the generated DDL is incomplete: %v", err)} + } + if value == "" { + return nil, []string{"default value could not be rendered; the generated DDL is incomplete"} + } + return &value, nil +} + +func quoteCatalogIdentifier(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) && strings.Contains(value, `"."`) { + return value + } + if separator := strings.LastIndexByte(value, '.'); separator >= 0 { + return quoteIdentifier(value[:separator]) + "." + quoteIdentifier(value[separator+1:]) + } + return quoteIdentifier(value) +} + +// buildCustomTypeDDL generates normalized CREATE TYPE text. complete is only +// true when the text can be executed standalone; multiranges and base types +// are marked incomplete with visible warnings. +func buildCustomTypeDDL(schema, name string, kind customTypeKind, inputFn sql.NullString, members *[]customTypeMember, properties *customTypeProperties, warnings []string) *customTypeDdl { + qualified := quoteIdentifier(schema) + "." + quoteIdentifier(name) + switch kind { + case customTypeKindEnum: + values := make([]string, 0, len(*members)) + for _, member := range *members { + if member.EnumValue != nil { + values = append(values, quoteLiteral(*member.EnumValue)) + } + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS ENUM (%s);", qualified, strings.Join(values, ", ")), + Complete: true, + Warnings: warnings, + } + case customTypeKindComposite: + fields := make([]string, 0, len(*members)) + comments := make([]string, 0, len(*members)) + for _, member := range *members { + fields = append(fields, quoteIdentifier(member.Name)+" "+member.DataType) + if member.Comment != nil { + comments = append(comments, fmt.Sprintf("COMMENT ON COLUMN %s.%s IS %s;", qualified, quoteIdentifier(member.Name), quoteLiteral(*member.Comment))) + } + } + sql := fmt.Sprintf("CREATE TYPE %s AS (\n %s\n);", qualified, strings.Join(fields, ",\n ")) + if len(comments) > 0 { + sql = sql + "\n" + strings.Join(comments, "\n") + } + return &customTypeDdl{SQL: sql, Complete: true, Warnings: warnings} + case customTypeKindDomain: + complete := true + base := "unknown" + if properties.BaseType != nil && *properties.BaseType != "" { + base = *properties.BaseType + } else { + complete = false + warnings = append(warnings, "base type could not be resolved; the generated DDL is incomplete") + } + parts := []string{fmt.Sprintf("CREATE DOMAIN %s AS %s", qualified, base)} + if properties.Collation != nil && *properties.Collation != "" { + parts = append(parts, "COLLATE "+quoteCatalogIdentifier(*properties.Collation)) + } + if properties.Default != nil && *properties.Default != "" { + parts = append(parts, "DEFAULT "+*properties.Default) + } + if properties.NotNull != nil && *properties.NotNull { + parts = append(parts, "NOT NULL") + } + for _, constraint := range properties.DomainConstraints { + body := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(constraint.Definition), "CHECK")) + constraintName := constraint.Name + if constraintName == "" { + constraintName = name + "_check" + } + parts = append(parts, fmt.Sprintf("CONSTRAINT %s CHECK %s", quoteIdentifier(constraintName), body)) + } + for _, warning := range warnings { + if strings.Contains(warning, "domain constraints") || strings.Contains(warning, "default value could not be rendered") { + complete = false + } + } + return &customTypeDdl{SQL: strings.Join(parts, "\n ") + ";", Complete: complete, Warnings: warnings} + case customTypeKindRange: + var args []string + if properties.RangeSubtype != nil && *properties.RangeSubtype != "" { + args = append(args, "subtype = "+*properties.RangeSubtype) + } + if properties.RangeSubtypeOpclass != nil && *properties.RangeSubtypeOpclass != "" { + args = append(args, "subtype_opclass = "+quoteCatalogIdentifier(*properties.RangeSubtypeOpclass)) + } + if properties.RangeCanonicalFunction != nil && *properties.RangeCanonicalFunction != "" { + args = append(args, "canonical = "+quoteCatalogIdentifier(*properties.RangeCanonicalFunction)) + } + if properties.RangeSubtypeDiffFunction != nil && *properties.RangeSubtypeDiffFunction != "" { + args = append(args, "subtype_diff = "+quoteCatalogIdentifier(*properties.RangeSubtypeDiffFunction)) + } + if properties.RangeMultirangeName != nil && *properties.RangeMultirangeName != "" { + args = append(args, "multirange_type_name = "+quoteCatalogIdentifier(*properties.RangeMultirangeName)) + } + if properties.RangeSubtype == nil || *properties.RangeSubtype == "" { + result := &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS RANGE (subtype = unknown);", qualified), + Complete: false, + Warnings: append(warnings, "range attributes could not be resolved"), + } + return result + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS RANGE (\n %s\n);", qualified, strings.Join(args, ",\n ")), + Complete: true, + Warnings: warnings, + } + case customTypeKindMultirange: + return &customTypeDdl{ + SQL: "", + Complete: false, + Warnings: append(append([]string{}, warnings...), fmt.Sprintf("%s is the auto-generated multirange companion of a range type; it has no standalone CREATE statement", qualified)), + } + default: // customTypeKindBase + inputName := "unknown" + if inputFn.Valid && inputFn.String != "" { + inputName = inputFn.String + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s; -- base type attributes require manual reconstruction", qualified), + Complete: false, + Warnings: append(append([]string{}, warnings...), fmt.Sprintf("%s is a base type; its input/output functions (%s) cannot be rebuilt from catalogs", qualified, inputName)), + } + } +} + func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) { effective, err := s.effectiveSchema(schema) if err != nil { return nil, err } - tables, err := s.listTables(effective, metadataListConstraints{}) - if err != nil { - return nil, err + result := []objectInfo{} + if constraintsAllowsTableLike(constraints) { + tables, err := s.listTables(effective, metadataListConstraints{}) + if err != nil { + return nil, err + } + for _, table := range tables { + result = append(result, objectInfo{Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment}) + } } - result := make([]objectInfo, 0, len(tables)) - for _, table := range tables { - result = append(result, objectInfo{Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment}) - } - if !s.mode.mysqlCompat { + if !s.mode.mysqlCompat && constraintsAllowRoutines(constraints) { catalog := "sys_catalog" function := "sys" if s.mode.postgresCatalog { @@ -359,6 +1066,16 @@ WHERE n.nspname = %s ORDER BY p.proname`, catalog, function, catalog, function, _ = rows.Close() } } + if constraintsAllowTypes(constraints) { + types, typesErr := s.listCustomTypes(effective) + if typesErr != nil { + // A type catalog failure is a real fault: surfacing it lets the user + // distinguish an incomplete “all objects” view from an actually + // empty schema, instead of silently dropping the type group. + return nil, fmt.Errorf("list custom types in schema %q: %w", effective, typesErr) + } + result = append(result, types...) + } filtered := result[:0] for _, item := range result { if constraintsMatch(constraints, item.Name, item.ObjectType) { @@ -1106,7 +1823,7 @@ func normalizeTableType(value string) string { switch normalized { case "BASE_TABLE", "PARTITIONED_TABLE": return "TABLE" - case "MATERIALIZED_VIEW", "FOREIGN_TABLE", "VIEW", "TABLE": + case "MATERIALIZED_VIEW", "FOREIGN_TABLE", "VIEW", "TABLE", "TYPE", "TYPE_BODY": return normalized default: return "TABLE" @@ -1150,6 +1867,38 @@ func constraintsAllowsTableLike(constraints metadataListConstraints) bool { return false } +// constraintsAllowTypes reports whether the object-type filter asks for +// user-defined types (or leaves the filter open). normalizeTableType treats +// "TYPE" and "TYPE_BODY" as first-class kinds, so table-like constraints never +// match them and a dedicated type request does not scan relations. +func constraintsAllowTypes(constraints metadataListConstraints) bool { + if len(constraints.ObjectTypes) == 0 { + return true + } + for _, kind := range constraints.ObjectTypes { + switch normalizeTableType(kind) { + case "TYPE", "TYPE_BODY": + return true + } + } + return false +} + +// constraintsAllowRoutines reports whether the object-type filter asks for +// procedures or functions (or leaves the filter open). +func constraintsAllowRoutines(constraints metadataListConstraints) bool { + if len(constraints.ObjectTypes) == 0 { + return true + } + for _, kind := range constraints.ObjectTypes { + upper := strings.ToUpper(strings.TrimSpace(kind)) + if strings.Contains(upper, "PROCEDURE") || strings.Contains(upper, "FUNCTION") { + return true + } + } + return false +} + func constraintsMatch(constraints metadataListConstraints, name, kind string) bool { if filter := strings.TrimSpace(constraints.Filter); filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) { return false @@ -1203,6 +1952,8 @@ func objectOrder(kind string) int { return 4 case "FUNCTION": return 5 + case "TYPE": + return 6 default: return 9 } diff --git a/agents/drivers/kingbase-go/main.go b/agents/drivers/kingbase-go/main.go index 23d45fb609..7e2c471abc 100644 --- a/agents/drivers/kingbase-go/main.go +++ b/agents/drivers/kingbase-go/main.go @@ -408,6 +408,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "get_object_source": result, err := s.getObjectSource(stringParam(params, "schema"), stringParam(params, "name"), stringParam(params, "object_type")) return result, false, err + case "get_type_details": + result, err := s.getTypeDetails(stringParam(params, "schema"), stringParam(params, "name")) + return result, false, err case "get_table_ddl": result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err diff --git a/agents/drivers/kingbase-go/main_test.go b/agents/drivers/kingbase-go/main_test.go index 8ff54c14a5..5ba754082b 100644 --- a/agents/drivers/kingbase-go/main_test.go +++ b/agents/drivers/kingbase-go/main_test.go @@ -6,6 +6,7 @@ import ( "database/sql/driver" "encoding/json" "errors" + "fmt" "io" "strings" "sync" @@ -106,9 +107,11 @@ type connectionAttemptConn struct { } type valueRows struct { - columns []string - rows [][]driver.Value - index int + columns []string + rows [][]driver.Value + index int + nextErr error + closeErr error } func (fakeDriver) Open(string) (driver.Conn, error) { @@ -286,10 +289,13 @@ func (connection *metadataConn) QueryContext(_ context.Context, query string, _ func (rows *valueRows) Columns() []string { return rows.columns } -func (*valueRows) Close() error { return nil } +func (rows *valueRows) Close() error { return rows.closeErr } func (rows *valueRows) Next(values []driver.Value) error { if rows.index >= len(rows.rows) { + if rows.nextErr != nil { + return rows.nextErr + } return io.EOF } copy(values, rows.rows[rows.index]) @@ -1100,6 +1106,467 @@ func TestListTablesPreservesKingbaseObjectTypesAndComments(t *testing.T) { } } +func TestListCustomTypesUsesPostgresCatalog(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, "FROM pg_catalog.pg_type t") || !strings.Contains(query, "t.typtype IN ('b','c','d','e','r','m')") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "t.typelem = 0") || !strings.Contains(query, "(t.typrelid = 0 OR c.relkind = 'c')") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "n.nspname <> 'information_schema'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_toast%'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_temp%'") { + return nil, errors.New("unexpected query: " + query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{ + {"status", "order status", "e", true}, + {"email", nil, "d", false}, + {"address", nil, "c", true}, + }, + }, nil + }} + server := newServer() + server.db = openMetadataDB(t, state) + server.mode.postgresCatalog = true + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 3 { + t.Fatalf("unexpected types: %#v", types) + } + for _, item := range types { + if item.ObjectType != "TYPE" || item.Schema != "public" { + t.Fatalf("type metadata was lost: %#v", item) + } + } + if types[0].Comment == nil || *types[0].Comment != "order status" { + t.Fatalf("type comment was lost: %#v", types[0]) + } + if types[1].Comment != nil { + t.Fatalf("nil comment became non-nil: %#v", types[1]) + } + if types[0].CustomTypeKind == nil || *types[0].CustomTypeKind != "enum" || types[0].HasMembers == nil || !*types[0].HasMembers { + t.Fatalf("type kind/member metadata was lost: %#v", types[0]) + } + if types[1].CustomTypeKind == nil || *types[1].CustomTypeKind != "domain" || types[1].HasMembers == nil || *types[1].HasMembers { + t.Fatalf("leaf type metadata was lost: %#v", types[1]) + } +} + +func TestListCustomTypesUsesSystemCatalog(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, "FROM sys_catalog.sys_type t") || strings.Contains(query, "FROM pg_catalog") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") { + return nil, errors.New("unexpected query: " + query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{{"status", "order status", "e", true}}, + }, nil + }} + server := newServer() + server.db = openMetadataDB(t, state) + server.mode.postgresCatalog = false + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 1 || types[0].Name != "status" { + t.Fatalf("unexpected types: %#v", types) + } +} + +func TestListCustomTypesSkipsMySQLCompatMode(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + return nil, errors.New("custom types query must not run in mysql compat mode: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + server.mode.mysqlCompat = true + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 0 { + t.Fatalf("expected no types in mysql compat mode: %#v", types) + } +} + +func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "sys_type t"): + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{ + {"status", "order status", "e", true}, + {"email", nil, "d", false}, + }, + }, nil + case strings.Contains(query, "sys_proc p"): + return &valueRows{ + columns: []string{"proname", "kind", "comment"}, + rows: [][]driver.Value{{"format_name", "FUNCTION", nil}}, + }, nil + case strings.Contains(query, "sys_class c"): + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + } + return nil, errors.New("unexpected query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + objects, err := server.listObjects("public", metadataListConstraints{}) + if err != nil { + t.Fatal(err) + } + var typeNames []string + for _, item := range objects { + if item.ObjectType == "TYPE" { + typeNames = append(typeNames, item.Name) + } + } + if len(typeNames) != 2 || typeNames[0] != "email" || typeNames[1] != "status" { + t.Fatalf("unexpected types in object list: %v (objects=%#v)", typeNames, objects) + } + if len(objects) != 4 { + t.Fatalf("expected table + function + 2 types, got %#v", objects) + } +} + +func TestListObjectsOnlyCustomTypesWhenTypeRequested(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "FROM sys_catalog.sys_class c") || strings.Contains(query, "sys_proc p") { + return nil, errors.New("type-only request must not scan relations or routines: " + query) + } + if !strings.Contains(query, "sys_type t") { + return nil, errors.New("unexpected query: " + query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{{"status", "order status", "e", true}}, + }, nil + }} + server := newServer() + server.db = openMetadataDB(t, state) + + // The sidebar type group sends TYPE together with the TYPE_BODY companion; + // both must resolve to a type-only request that never scans tables. + for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} { + objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: objectTypes}) + if err != nil { + t.Fatal(err) + } + if len(objects) != 1 || objects[0].Name != "status" || objects[0].ObjectType != "TYPE" || objects[0].Schema != "public" { + t.Fatalf("expected only the TYPE object for %v: %#v", objectTypes, objects) + } + } +} + +func TestTypeBodyConstraintIsNotTableLike(t *testing.T) { + constraints := metadataListConstraints{ObjectTypes: []string{"TYPE", "TYPE_BODY"}} + if !constraintsAllowTypes(constraints) { + t.Fatal("TYPE/TYPE_BODY request must allow types") + } + if constraintsAllowsTableLike(constraints) { + t.Fatal("TYPE/TYPE_BODY request must not be table-like; normalizeTableType must not map TYPE_BODY to TABLE") + } + if constraintsAllowRoutines(constraints) { + t.Fatal("TYPE/TYPE_BODY request must not be routine-like") + } +} + +func TestListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_type t") || strings.Contains(query, "sys_proc p") { + return nil, errors.New("table-only request must not scan types or routines: " + query) + } + if !strings.Contains(query, "sys_class c") { + return nil, errors.New("unexpected query: " + query) + } + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + }} + server := newServer() + server.db = openMetadataDB(t, state) + + objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TABLE"}}) + if err != nil { + t.Fatal(err) + } + for _, item := range objects { + if item.ObjectType == "TYPE" { + t.Fatalf("table-only request must not return types: %#v", objects) + } + } + if len(objects) == 0 { + t.Fatalf("expected the table to remain listed: %#v", objects) + } +} + +func TestListObjectsTypeOnlyPropagatesCustomTypesError(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + return nil, errors.New("catalog unavailable: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + _, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TYPE"}}) + if err == nil { + t.Fatal("dedicated type request must propagate the catalog error") + } + if !strings.Contains(err.Error(), "list custom types") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_type t") { + return nil, errors.New("pg_type unavailable") + } + switch { + case strings.Contains(query, "sys_proc p"): + return &valueRows{ + columns: []string{"proname", "kind", "comment"}, + rows: [][]driver.Value{{"format_name", "FUNCTION", nil}}, + }, nil + case strings.Contains(query, "sys_class c"): + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + } + return nil, errors.New("unexpected query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + // A failing type catalog must surface as an error even for the unfiltered + // “all objects” listing, so users never see a silently incomplete list. + _, err := server.listObjects("public", metadataListConstraints{}) + if err == nil { + t.Fatal("unfiltered request must propagate the type catalog error") + } + if !strings.Contains(err.Error(), "list custom types") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestKingbaseCustomTypeQueriesFollowCatalog(t *testing.T) { + pgQueries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "status") + for _, fragment := range []string{ + "pg_catalog.pg_type", "pg_catalog.pg_namespace", "pg_catalog.pg_description", "pg_catalog.pg_proc", + "pg_catalog.pg_collation", "pg_get_expr", "pg_get_constraintdef", + "n.nspname = 'app' AND t.typname = 'status'", + } { + if !strings.Contains(pgQueries.general, fragment) && !strings.Contains(pgQueries.compositeMembers, fragment) && !strings.Contains(pgQueries.domainConstraints, fragment) { + t.Fatalf("pg catalog queries missing %q: %s", fragment, pgQueries.general) + } + } + if !strings.Contains(pgQueries.enumMembers, "pg_catalog.pg_enum") { + t.Fatalf("enum query must use pg_catalog.pg_enum: %s", pgQueries.enumMembers) + } + if !strings.Contains(pgQueries.compositeMembers, "pg_catalog.pg_attribute") { + t.Fatalf("composite query must use pg_catalog.pg_attribute: %s", pgQueries.compositeMembers) + } + if !strings.Contains(pgQueries.rangeAttributes, "pg_catalog.pg_range") { + t.Fatalf("range query must use pg_catalog.pg_range: %s", pgQueries.rangeAttributes) + } + for _, fragment := range []string{"JOIN pg_catalog.pg_type at", "quote_ident(atn.nspname)", "LEFT JOIN pg_catalog.pg_type elem"} { + if !strings.Contains(pgQueries.compositeMembers, fragment) { + t.Fatalf("composite query must schema-qualify member types; missing %q: %s", fragment, pgQueries.compositeMembers) + } + } + for _, fragment := range []string{"JOIN pg_catalog.pg_namespace n", "quote_ident(n.nspname)", "WHERE t.oid = %[1]d"} { + if !strings.Contains(pgQueries.domainBaseType, fragment) { + t.Fatalf("domain query must schema-qualify its base type; missing %q: %s", fragment, pgQueries.domainBaseType) + } + } + formattedDomainBaseType := fmt.Sprintf(pgQueries.domainBaseType, 25, -1) + if strings.Contains(formattedDomainBaseType, "%") || !strings.Contains(formattedDomainBaseType, "format_type(t.oid, -1::int4)") { + t.Fatalf("domain base type query must format both OID and typmod: %s", formattedDomainBaseType) + } + for _, query := range []string{pgQueries.rangeAttributes, pgQueries.rangeAttributesForMultirange} { + for _, fragment := range []string{"JOIN pg_catalog.pg_type st", "quote_ident(stn.nspname)", "quote_ident(ncan.nspname)", "quote_ident(ndiff.nspname)", "quote_ident(nopc.nspname)", "ncan.oid = pcan.pronamespace", "ndiff.oid = pdiff.pronamespace", "nopc.oid = opc.opcnamespace"} { + if !strings.Contains(query, fragment) { + t.Fatalf("range query must qualify catalog names with schema; missing %q: %s", fragment, query) + } + } + if strings.Contains(query, "%!") { + t.Fatalf("range query contains an unresolved format directive: %s", query) + } + } + + sysQueries := customTypeCatalogQueriesFor("sys_catalog", "sys", "app", "status") + for _, fragment := range []string{"sys_catalog.sys_type", "sys_catalog.sys_namespace", "sys_catalog.sys_description", "sys_catalog.sys_proc", "sys_get_expr", "sys_get_constraintdef"} { + if !strings.Contains(sysQueries.general, fragment) && !strings.Contains(sysQueries.compositeMembers, fragment) && !strings.Contains(sysQueries.domainConstraints, fragment) { + t.Fatalf("sys catalog queries missing %q: %s", fragment, sysQueries.general) + } + } + if strings.Contains(sysQueries.general, "FROM pg_catalog") || strings.Contains(sysQueries.general, "pg_get_expr") { + t.Fatalf("sys catalog general query leaked pg_catalog references: %s", sysQueries.general) + } + if strings.Contains(pgQueries.general, "pg_get_expr") || strings.Contains(sysQueries.general, "sys_get_expr") { + t.Fatal("general type lookup must not depend on default-expression rendering") + } + if !strings.Contains(pgQueries.domainRenderedDefault, "pg_get_expr") || !strings.Contains(sysQueries.domainRenderedDefault, "sys_get_expr") { + t.Fatal("domain default renderer must follow the selected catalog") + } +} + +func TestKingbaseDomainDefaultRenderFailureIsDegradable(t *testing.T) { + bin := sql.NullString{String: "{CONST ...}", Valid: true} + value, warnings := resolveCustomTypeDomainDefault(bin, sql.NullString{}, func() (string, error) { + return "", errors.New("function sys_get_expr does not exist") + }) + if value != nil || len(warnings) != 1 || !strings.Contains(warnings[0], "DDL is incomplete") { + t.Fatalf("unexpected fallback result: value=%v warnings=%v", value, warnings) + } +} + +func TestKingbaseDomainConstraintReadFailuresMarkDDLIncomplete(t *testing.T) { + queries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "email") + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "WHERE t.oid = 1"): + return &valueRows{columns: []string{"base_type"}, rows: [][]driver.Value{{"text"}}}, nil + case strings.Contains(query, "WHERE c.contypid = 9"): + return &valueRows{columns: []string{"conname", "definition"}, rows: [][]driver.Value{{"email_valid"}}}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + properties := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}} + warnings := server.customTypeDomainAttributes(queries, &properties, 9, 1, -1, false, sql.NullString{}, sql.NullString{}, 0, sql.NullString{}) + if len(warnings) != 1 || !strings.Contains(warnings[0], "domain constraints could not be decoded") { + t.Fatalf("constraint scan failures must be retained as warnings: %v", warnings) + } + ddl := buildCustomTypeDDL("app", "email", customTypeKindDomain, sql.NullString{}, &[]customTypeMember{}, &properties, warnings) + if ddl.Complete { + t.Fatalf("domain DDL must be incomplete after a constraint scan failure: %+v", ddl) + } +} + +func TestKingbaseGetTypeDetailsRejectsMySQLCompat(t *testing.T) { + server := newServer() + server.mode.mysqlCompat = true + _, err := server.getTypeDetails("public", "status") + if err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") { + t.Fatalf("expected MySQL compat rejection, got %v", err) + } +} + +func TestKingbaseGetTypeDetailsPropagatesRowIterationError(t *testing.T) { + state := &metadataDriverState{query: func(string) (driver.Rows, error) { + return &valueRows{nextErr: errors.New("row stream failed")}, nil + }} + server := newServer() + server.db = openMetadataDB(t, state) + _, err := server.getTypeDetails("public", "status") + if err == nil || !strings.Contains(err.Error(), "failed to read type") || !strings.Contains(err.Error(), "row stream failed") { + t.Fatalf("expected row iteration error to be propagated, got %v", err) + } +} + +func TestKingbaseCustomTypeKindFromCode(t *testing.T) { + for code, expected := range map[string]customTypeKind{ + "b": customTypeKindBase, "c": customTypeKindComposite, "d": customTypeKindDomain, + "e": customTypeKindEnum, "r": customTypeKindRange, "m": customTypeKindMultirange, + } { + kind, ok := customTypeKindFromCode(code) + if !ok || kind != expected { + t.Fatalf("customTypeKindFromCode(%q) = %v, %v", code, kind, ok) + } + } + if _, ok := customTypeKindFromCode("p"); ok { + t.Fatal("pseudo type must not map to a kind") + } +} + +func TestKingbaseSystemSchemasAreRejectedForCustomTypeDetails(t *testing.T) { + for _, schema := range []string{"pg_catalog", "information_schema", "pg_toast", "pg_toast_temp_5", "pg_temp_5"} { + if !isSystemSchema(schema) { + t.Fatalf("%q should be recognized as a system schema", schema) + } + } + if isSystemSchema("public") || isSystemSchema("app") { + t.Fatal("user schemas must remain eligible for custom type details") + } + server := newServer() + if _, err := server.getTypeDetails("pg_catalog", "int4"); err == nil || !strings.Contains(err.Error(), "system schema") { + t.Fatalf("system schema must be rejected before catalog access, got %v", err) + } +} + +func TestKingbaseCustomTypeDDL(t *testing.T) { + nullInput := sql.NullString{} + enumMembers := []customTypeMember{ + {Ordinal: 1, EnumValue: stringPtr("draft")}, + {Ordinal: 2, EnumValue: stringPtr("已归档")}, + } + enumDDL := buildCustomTypeDDL("app", "status", customTypeKindEnum, nullInput, &enumMembers, &customTypeProperties{}, nil) + if enumDDL.SQL != "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', '已归档');" || !enumDDL.Complete { + t.Fatalf("unexpected enum DDL: %+v", enumDDL) + } + + compositeMembers := []customTypeMember{ + {Name: "city", DataType: "text", Ordinal: 1, Comment: stringPtr("city name")}, + } + compositeDDL := buildCustomTypeDDL("app", "address", customTypeKindComposite, nullInput, &compositeMembers, &customTypeProperties{}, nil) + if !strings.Contains(compositeDDL.SQL, "\"city\" text") || !strings.Contains(compositeDDL.SQL, "COMMENT ON COLUMN \"app\".\"address\".\"city\" IS 'city name';") { + t.Fatalf("unexpected composite DDL: %+v", compositeDDL) + } + + notNull := true + domainProps := customTypeProperties{BaseType: stringPtr("text"), NotNull: ¬Null, DomainConstraints: []customTypeDomainConstraint{{Name: "email_valid", Definition: "CHECK ((VALUE <> ''::text))"}}} + domainDDL := buildCustomTypeDDL("app", "email", customTypeKindDomain, nullInput, &[]customTypeMember{}, &domainProps, nil) + if !strings.Contains(domainDDL.SQL, "CREATE DOMAIN \"app\".\"email\" AS text") || !strings.Contains(domainDDL.SQL, "NOT NULL") || !strings.Contains(domainDDL.SQL, "CHECK ((VALUE <> ''::text))") { + t.Fatalf("unexpected domain DDL: %+v", domainDDL) + } + + rangeProps := customTypeProperties{RangeSubtype: stringPtr("numeric"), RangeCanonicalFunction: stringPtr("\"extensions\".\"numeric_range_canonical\"")} + rangeDDL := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &rangeProps, nil) + if !rangeDDL.Complete || !strings.Contains(rangeDDL.SQL, "subtype = numeric") || !strings.Contains(rangeDDL.SQL, "canonical = \"extensions\".\"numeric_range_canonical\"") { + t.Fatalf("unexpected range DDL: %+v", rangeDDL) + } + missingSubtype := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &customTypeProperties{RangeMultirangeName: stringPtr("price_multirange")}, nil) + if missingSubtype.Complete || missingSubtype.SQL != "CREATE TYPE \"app\".\"price_range\" AS RANGE (subtype = unknown);" { + t.Fatalf("range DDL without subtype must be incomplete: %+v", missingSubtype) + } + + multirangeDDL := buildCustomTypeDDL("app", "_price_range", customTypeKindMultirange, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil) + if multirangeDDL.Complete || len(multirangeDDL.Warnings) == 0 { + t.Fatalf("multirange DDL must be incomplete with warnings: %+v", multirangeDDL) + } + + baseDDL := buildCustomTypeDDL("app", "point2d", customTypeKindBase, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil) + if baseDDL.Complete || len(baseDDL.Warnings) == 0 { + t.Fatalf("base DDL must be incomplete with warnings: %+v", baseDDL) + } +} + +func TestCustomTypeDetailsJSONNeverNullsSlices(t *testing.T) { + props := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}} + details := customTypeDetails{Name: "status", Schema: "app", Kind: customTypeKindEnum, Members: []customTypeMember{}, Properties: props} + raw, err := json.Marshal(details) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if strings.Contains(text, "null") { + t.Fatalf("empty slices must encode as [] not null: %s", text) + } + if !strings.Contains(text, `"members":[]`) || !strings.Contains(text, `"domainConstraints":[]`) { + t.Fatalf("empty slices must be present as []: %s", text) + } +} + func TestListTriggersUsesCompatibilityCatalogAndDecodesTiming(t *testing.T) { state := &metadataDriverState{query: func(query string) (driver.Rows, error) { if !strings.Contains(query, "FROM pg_catalog.pg_trigger") || !strings.Contains(query, "NOT tg.tgisinternal") { diff --git a/agents/drivers/vastbase-go/integration_test.go b/agents/drivers/vastbase-go/integration_test.go index 6cd5780e00..b8fbcba1b6 100644 --- a/agents/drivers/vastbase-go/integration_test.go +++ b/agents/drivers/vastbase-go/integration_test.go @@ -140,6 +140,264 @@ func TestVastbaseIntegration(t *testing.T) { } } +func TestVastbaseCustomTypesIntegration(t *testing.T) { + host := os.Getenv("VASTBASE_TEST_HOST") + portText := os.Getenv("VASTBASE_TEST_PORT") + username := os.Getenv("VASTBASE_TEST_USERNAME") + password := os.Getenv("VASTBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Vastbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + database := os.Getenv("VASTBASE_TEST_DATABASE") + if database == "" { + database = "test" + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_types_" + suffix + schemaIdent := quoteIdentifier(schema) + statusType := schemaIdent + "." + quoteIdentifier("status") + emailDomain := schemaIdent + "." + quoteIdentifier("email") + addressType := schemaIdent + "." + quoteIdentifier("address") + ordersTable := schemaIdent + "." + quoteIdentifier("orders") + + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:vastbase://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { + _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"}) + }) + + mustExecute(t, server, "CREATE SCHEMA "+schemaIdent) + + // Detect the compatibility mode before creating any type object: MySQL + // compatibility mode has no pg_type catalog contract and may reject type + // syntax, so the type feature degrades to an empty group (never an error) + // while the plain table listing keeps working. + if server.mode.mysqlCompat { + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (id bigint, state text, ship_to text)") + empty, err := server.listCustomTypes(schema) + if err != nil { + t.Fatalf("listCustomTypes failed in mysql compat mode: %v", err) + } + if len(empty) != 0 { + t.Fatalf("mysql compat mode must not list types, got %#v", empty) + } + typeOnly, err := server.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"TYPE"}}) + if err != nil { + t.Fatalf("listObjects([TYPE]) failed in mysql compat mode: %v", err) + } + if len(typeOnly) != 0 { + t.Fatalf("mysql compat mode TYPE request must be empty, got %#v", typeOnly) + } + all, err := server.listObjects(schema, metadataListConstraints{}) + if err != nil { + t.Fatalf("listObjects(all) failed in mysql compat mode: %v", err) + } + var sawOrders bool + for _, item := range all { + if item.Name == "orders" && item.ObjectType == "TABLE" { + sawOrders = true + } + if item.ObjectType == "TYPE" || strings.Contains(item.ObjectType, "FUNCTION") || strings.Contains(item.ObjectType, "PROCEDURE") { + t.Fatalf("mysql compat mode must not list types or routines: %#v", all) + } + } + if !sawOrders { + t.Fatalf("orders table missing from mysql compat listing: %#v", all) + } + return + } + + mustExecute(t, server, "CREATE TYPE "+statusType+" AS ENUM ('draft', 'published')") + mustExecute(t, server, "CREATE DOMAIN "+emailDomain+" AS text CHECK (VALUE ~ '.+@.+')") + mustExecute(t, server, "CREATE TYPE "+addressType+" AS (city text, zip text)") + mustExecute(t, server, "COMMENT ON TYPE "+statusType+" IS '订单状态'") + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (id bigint, state "+statusType+", ship_to "+addressType+")") + + customTypes, err := server.listCustomTypes(schema) + if err != nil { + t.Fatalf("listCustomTypes failed: %v", err) + } + typeNames := make(map[string]string, len(customTypes)) + for _, item := range customTypes { + comment := "" + if item.Comment != nil { + comment = *item.Comment + } + typeNames[item.Name] = comment + } + if len(customTypes) != 3 { + t.Fatalf("expected exactly the 3 user-created types, got %#v", customTypes) + } + for _, name := range []string{"status", "email", "address"} { + if _, ok := typeNames[name]; !ok { + t.Fatalf("user-created type %q missing from listing: %#v", name, customTypes) + } + } + if _, ok := typeNames["orders"]; ok { + t.Fatalf("relation auto-generated row type leaked into type listing: %#v", customTypes) + } + for _, name := range []string{"_status", "_email", "_address"} { + if _, ok := typeNames[name]; ok { + t.Fatalf("auto-generated array type %q leaked into type listing: %#v", name, customTypes) + } + } + if comment := typeNames["status"]; comment != "订单状态" { + t.Fatalf("type comment was lost: got %q, want %q", comment, "订单状态") + } + for _, item := range customTypes { + if item.ObjectType != "TYPE" { + t.Fatalf("type object_type was lost: %#v", item) + } + } + + // A dedicated TYPE request must return only types. + // The sidebar type group sends TYPE together with the TYPE_BODY companion kind. + for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} { + onlyTypes, err := server.listObjects(schema, metadataListConstraints{ObjectTypes: objectTypes}) + if err != nil { + t.Fatalf("listObjects(%v) failed: %v", objectTypes, err) + } + if len(onlyTypes) != 3 { + t.Fatalf("listObjects(%v) must return only the 3 types: %#v", objectTypes, onlyTypes) + } + for _, item := range onlyTypes { + if item.ObjectType != "TYPE" { + t.Fatalf("listObjects(%v) returned a non-type: %#v", objectTypes, onlyTypes) + } + } + } + + // The unfiltered object list keeps the table and the types, and never + // exposes the array companions or the relation row type. + all, err := server.listObjects(schema, metadataListConstraints{}) + if err != nil { + t.Fatalf("listObjects(all) failed: %v", err) + } + var sawOrders bool + for _, item := range all { + if item.Name == "orders" && item.ObjectType == "TABLE" { + sawOrders = true + } + if strings.HasPrefix(item.Name, "_") { + t.Fatalf("auto-generated array type leaked into object list: %#v", all) + } + } + if !sawOrders { + t.Fatalf("orders table missing from object list: %#v", all) + } +} + +func TestVastbaseCustomTypeDetailsIntegration(t *testing.T) { + host := os.Getenv("VASTBASE_TEST_HOST") + portText := os.Getenv("VASTBASE_TEST_PORT") + username := os.Getenv("VASTBASE_TEST_USERNAME") + password := os.Getenv("VASTBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Vastbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + database := os.Getenv("VASTBASE_TEST_DATABASE") + if database == "" { + database = "test" + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_details_" + suffix + schemaIdent := quoteIdentifier(schema) + statusType := schemaIdent + "." + quoteIdentifier("status") + emailDomain := schemaIdent + "." + quoteIdentifier("email") + addressType := schemaIdent + "." + quoteIdentifier("address") + priceRangeType := schemaIdent + "." + quoteIdentifier("price_range") + ordersTable := schemaIdent + "." + quoteIdentifier("orders") + + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:vastbase://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { + _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"}) + }) + + mustExecute(t, server, "CREATE SCHEMA "+schemaIdent) + + // MySQL compatibility mode has no pg_type contract; details must return an + // explicit unsupported error instead of executing PG catalog SQL. + if server.mode.mysqlCompat { + if _, err := server.getTypeDetails(schema, "status"); err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") { + t.Fatalf("expected MySQL compat rejection, got %v", err) + } + return + } + + mustExecute(t, server, "CREATE TYPE "+statusType+" AS ENUM ('draft', 'published', '已归档')") + mustExecute(t, server, "CREATE DOMAIN "+emailDomain+" AS text DEFAULT '' CHECK (VALUE <> '')") + mustExecute(t, server, "CREATE TYPE "+addressType+" AS (city text, zip numeric(6))") + mustExecute(t, server, "COMMENT ON TYPE "+addressType+" IS 'shipping address'") + mustExecute(t, server, "COMMENT ON COLUMN "+addressType+".city IS 'city name'") + mustExecute(t, server, "CREATE TYPE "+priceRangeType+" AS RANGE (subtype = numeric)") + mustExecute(t, server, "CREATE TABLE "+ordersTable+" (state "+statusType+", address "+addressType+")") + + status, err := server.getTypeDetails(schema, "status") + if err != nil { + t.Fatalf("getTypeDetails(status) failed: %v", err) + } + if status.Kind != customTypeKindEnum || len(status.Members) != 3 || status.Members[0].EnumValue == nil || *status.Members[0].EnumValue != "draft" || status.Members[2].EnumValue == nil || *status.Members[2].EnumValue != "已归档" { + t.Fatalf("unexpected enum details: %+v", status) + } + if status.DDL == nil || !status.DDL.Complete || !strings.Contains(status.DDL.SQL, "AS ENUM ('draft', 'published', '已归档')") { + t.Fatalf("unexpected enum DDL: %+v", status.DDL) + } + + email, err := server.getTypeDetails(schema, "email") + if err != nil { + t.Fatalf("getTypeDetails(email) failed: %v", err) + } + if email.Kind != customTypeKindDomain || email.Properties.BaseType == nil || *email.Properties.BaseType != "text" || len(email.Properties.DomainConstraints) == 0 { + t.Fatalf("unexpected domain details: %+v", email) + } + + address, err := server.getTypeDetails(schema, "address") + if err != nil { + t.Fatalf("getTypeDetails(address) failed: %v", err) + } + if address.Kind != customTypeKindComposite || len(address.Members) != 2 || address.Members[0].Name != "city" || address.Members[0].Comment == nil || *address.Members[0].Comment != "city name" { + t.Fatalf("unexpected composite details: %+v", address) + } + + priceRange, err := server.getTypeDetails(schema, "price_range") + if err != nil { + t.Fatalf("getTypeDetails(price_range) failed: %v", err) + } + if priceRange.Kind != customTypeKindRange || priceRange.Properties.RangeSubtype == nil || *priceRange.Properties.RangeSubtype != "numeric" { + t.Fatalf("unexpected range details: %+v", priceRange) + } + + if _, err := server.getTypeDetails(schema, "orders"); err == nil || !strings.Contains(err.Error(), "row type") { + t.Fatalf("relation row type must be rejected, got %v", err) + } + if _, err := server.getTypeDetails(schema, "_status"); err == nil || !strings.Contains(err.Error(), "array companion") { + t.Fatalf("array companion must be rejected, got %v", err) + } +} + func rawJSON(value any) json.RawMessage { data, err := json.Marshal(value) if err != nil { diff --git a/agents/drivers/vastbase-go/main.go b/agents/drivers/vastbase-go/main.go index 7885bb0445..980ae5379c 100644 --- a/agents/drivers/vastbase-go/main.go +++ b/agents/drivers/vastbase-go/main.go @@ -439,6 +439,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "get_object_source": result, err := s.getObjectSource(stringParam(params, "schema"), stringParam(params, "name"), stringParam(params, "object_type")) return result, false, err + case "get_type_details": + result, err := s.getTypeDetails(stringParam(params, "schema"), stringParam(params, "name")) + return result, false, err case "get_table_ddl": result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err diff --git a/agents/drivers/vastbase-go/vastbase_metadata.go b/agents/drivers/vastbase-go/vastbase_metadata.go index 3ecbb02899..d42653e301 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata.go +++ b/agents/drivers/vastbase-go/vastbase_metadata.go @@ -45,11 +45,13 @@ type tableInfo struct { } type objectInfo struct { - Name string `json:"name"` - ObjectType string `json:"object_type"` - Schema string `json:"schema"` - Comment *string `json:"comment"` - Valid *bool `json:"valid,omitempty"` + Name string `json:"name"` + ObjectType string `json:"object_type"` + Schema string `json:"schema"` + Comment *string `json:"comment"` + Valid *bool `json:"valid,omitempty"` + CustomTypeKind *string `json:"custom_type_kind,omitempty"` + HasMembers *bool `json:"has_members,omitempty"` } type metadataListConstraints struct { @@ -353,20 +355,725 @@ LIMIT 1`, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLitera return nullStringPtr(comment), nil } +// listCustomTypes lists user-defined types visible in the given schema. +// +// Only explicitly created types are returned: base types (b), standalone +// composite types (c), domains (d), enums (e), ranges (r) and multiranges (m). +// Relation auto-generated row types (table/view/materialized view/foreign +// table/partitioned table) are excluded via `typrelid = 0 OR relkind = 'c'`, +// and array companion types are excluded via `typelem = 0`. MySQL +// compatibility mode has no pg_type catalog contract and returns nothing. +// +// The comment join scopes description entries to the type catalog itself via +// a regclass cast. `t.tableoid` cannot be used because Kingbase's native +// sys_type catalog has no tableoid system column. Kingbase and Vastbase both +// key COMMENT ON TYPE entries with the pg_type identity (oid 1247) even when +// the server is in sys_catalog compatibility mode, so the filter always +// references pg_catalog.pg_type. +func (s *server) listCustomTypes(schema string) ([]objectInfo, error) { + if s.mode.mysqlCompat { + return []objectInfo{}, nil + } + catalog := "sys_catalog" + if s.mode.postgresCatalog { + catalog = "pg_catalog" + } + prefix := catalogPrefix(catalog) + query := fmt.Sprintf(`SELECT t.typname, d.description, t.typtype::text, +CASE + WHEN t.typtype = 'c' THEN EXISTS ( + SELECT 1 FROM %s.%s_attribute a + WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped + ) + WHEN t.typtype = 'e' THEN EXISTS ( + SELECT 1 FROM %s.%s_enum e WHERE e.enumtypid = t.oid + ) + ELSE false +END AS has_members +FROM %s.%s_type t +JOIN %s.%s_namespace n ON n.oid = t.typnamespace +LEFT JOIN %s.%s_class c ON c.oid = t.typrelid +LEFT JOIN %s.%s_description d ON d.objoid = t.oid AND d.classoid = 'pg_catalog.pg_type'::regclass AND d.objsubid = 0 +WHERE n.nspname = %s + AND t.typtype IN ('b','c','d','e','r','m') + AND t.typisdefined + AND t.typelem = 0 + AND (t.typrelid = 0 OR c.relkind = 'c') + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + AND n.nspname NOT LIKE 'pg_toast%%' + AND n.nspname NOT LIKE 'pg_temp%%' +ORDER BY t.typname`, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema)) + rows, err := s.metadataQuery(query) + if err != nil { + return nil, err + } + defer rows.Close() + result := []objectInfo{} + for rows.Next() { + var name, kindCode string + var comment sql.NullString + var hasMembers bool + if err := rows.Scan(&name, &comment, &kindCode, &hasMembers); err != nil { + return nil, err + } + kind, ok := customTypeKindFromCode(kindCode) + if !ok { + continue + } + kindValue := string(kind) + result = append(result, objectInfo{Name: name, ObjectType: "TYPE", Schema: schema, Comment: nullStringPtr(comment), CustomTypeKind: &kindValue, HasMembers: &hasMembers}) + } + return result, rows.Err() +} + +func isSystemSchema(schema string) bool { + return schema == "pg_catalog" || schema == "information_schema" || strings.HasPrefix(schema, "pg_toast") || strings.HasPrefix(schema, "pg_temp") +} + +type customTypeKind string + +const ( + customTypeKindBase customTypeKind = "base" + customTypeKindComposite customTypeKind = "composite" + customTypeKindDomain customTypeKind = "domain" + customTypeKindEnum customTypeKind = "enum" + customTypeKindRange customTypeKind = "range" + customTypeKindMultirange customTypeKind = "multirange" +) + +type customTypeMember struct { + Name string `json:"name"` + DataType string `json:"dataType"` + Ordinal int32 `json:"ordinal"` + Nullable *bool `json:"nullable,omitempty"` + Default *string `json:"default,omitempty"` + Comment *string `json:"comment,omitempty"` + EnumValue *string `json:"enumValue,omitempty"` +} + +type customTypeDomainConstraint struct { + Name string `json:"name"` + Definition string `json:"definition"` +} + +type customTypeProperties struct { + BaseType *string `json:"baseType,omitempty"` + NotNull *bool `json:"notNull,omitempty"` + Default *string `json:"default,omitempty"` + Collation *string `json:"collation,omitempty"` + DomainConstraints []customTypeDomainConstraint `json:"domainConstraints"` + RangeSubtype *string `json:"rangeSubtype,omitempty"` + RangeMultirangeName *string `json:"rangeMultirangeName,omitempty"` + RangeCanonicalFunction *string `json:"rangeCanonicalFunction,omitempty"` + RangeSubtypeDiffFunction *string `json:"rangeSubtypeDiffFunction,omitempty"` + RangeSubtypeOpclass *string `json:"rangeSubtypeOpclass,omitempty"` + InputFunction *string `json:"inputFunction,omitempty"` + OutputFunction *string `json:"outputFunction,omitempty"` + ReceiveFunction *string `json:"receiveFunction,omitempty"` + SendFunction *string `json:"sendFunction,omitempty"` + AnalyzeFunction *string `json:"analyzeFunction,omitempty"` + Internallength *int32 `json:"internallength,omitempty"` + PassedByValue *bool `json:"passedByValue,omitempty"` + Alignment *string `json:"alignment,omitempty"` + Storage *string `json:"storage,omitempty"` +} + +type customTypeDdl struct { + SQL string `json:"sql"` + Complete bool `json:"complete"` + Warnings []string `json:"warnings,omitempty"` +} + +type customTypeDetails struct { + Name string `json:"name"` + Schema string `json:"schema"` + Kind customTypeKind `json:"kind"` + Comment *string `json:"comment,omitempty"` + Members []customTypeMember `json:"members"` + Properties customTypeProperties `json:"properties"` + DDL *customTypeDdl `json:"ddl,omitempty"` +} + +// customTypeCatalogQueries carries catalog-aware SQL fragments for type +// details. Vastbase exposes pg_get_expr/pg_get_constraintdef under the sys_ +// prefix in system-catalog mode, so the function names follow the catalog. +type customTypeCatalogQueries struct { + general string + enumMembers string + compositeMembers string + domainBaseType string + domainRenderedDefault string + domainConstraints string + rangeAttributes string + rangeAttributesForMultirange string + rangeMultirange string + collationName string +} + +// qualifiedCatalogTypeExpression keeps user-defined type references usable +// outside the current search_path while retaining format_type's typmod output +// for built-in pg_catalog types. +func qualifiedCatalogTypeExpression(typeAlias, namespaceAlias, elementAlias, elementNamespaceAlias, oidExpression, typmodExpression string) string { + return fmt.Sprintf(`CASE + WHEN %s.typelem <> 0 AND %s.nspname <> 'pg_catalog' + THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) || '[]' + WHEN %s.nspname <> 'pg_catalog' + THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) + ELSE format_type(%s, %s) +END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, oidExpression, typmodExpression) +} + +func customTypeCatalogQueriesFor(catalog, prefix, schema, name string) customTypeCatalogQueries { + getExpr := prefix + "_get_expr" + getConstraintDef := prefix + "_get_constraintdef" + typeTable := catalog + "." + prefix + "_type" + namespaceTable := catalog + "." + prefix + "_namespace" + classTable := catalog + "." + prefix + "_class" + descriptionTable := catalog + "." + prefix + "_description" + procTable := catalog + "." + prefix + "_proc" + collationTable := catalog + "." + prefix + "_collation" + enumTable := catalog + "." + prefix + "_enum" + attributeTable := catalog + "." + prefix + "_attribute" + attrdefTable := catalog + "." + prefix + "_attrdef" + constraintTable := catalog + "." + prefix + "_constraint" + rangeTable := catalog + "." + prefix + "_range" + opclassTable := catalog + "." + prefix + "_opclass" + quotedSchema := quoteLiteral(schema) + quotedName := quoteLiteral(name) + compositeMemberType := qualifiedCatalogTypeExpression("at", "atn", "elem", "elem_n", "a.atttypid", "a.atttypmod") + domainBaseType := qualifiedCatalogTypeExpression("t", "n", "elem", "elem_n", "t.oid", "%[2]d::int4") + rangeSubtype := qualifiedCatalogTypeExpression("st", "stn", "elem", "elem_n", "r.rngsubtype", "NULL::integer") + return customTypeCatalogQueries{ + general: fmt.Sprintf(`SELECT t.oid, t.typtype::text, t.typisdefined, +t.typbasetype, t.typnotnull, t.typrelid, t.typelem, t.typcollation, +t.typdefaultbin, t.typdefault, t.typlen, t.typbyval, +t.typalign::text, t.typstorage::text, t.typtypmod, +pi.proname, po.proname, pr.proname, ps.proname, pa.proname, +d.description, +CASE WHEN t.typrelid != 0 THEN (SELECT c.relkind::text FROM %s c WHERE c.oid = t.typrelid) END, +CASE WHEN cl.oid IS NULL THEN NULL ELSE quote_ident(ncl.nspname) || '.' || quote_ident(cl.collname) END +FROM %s t +JOIN %s n ON n.oid = t.typnamespace +LEFT JOIN %s d ON d.objoid = t.oid AND d.classoid = 'pg_catalog.pg_type'::regclass AND d.objsubid = 0 +LEFT JOIN %s pi ON pi.oid = t.typinput +LEFT JOIN %s po ON po.oid = t.typoutput +LEFT JOIN %s pr ON pr.oid = t.typreceive +LEFT JOIN %s ps ON ps.oid = t.typsend +LEFT JOIN %s pa ON pa.oid = t.typanalyze +LEFT JOIN %s cl ON cl.oid = t.typcollation +LEFT JOIN %s ncl ON ncl.oid = cl.collnamespace +WHERE n.nspname = %s AND t.typname = %s`, classTable, typeTable, namespaceTable, descriptionTable, procTable, procTable, procTable, procTable, procTable, collationTable, namespaceTable, quotedSchema, quotedName), + enumMembers: fmt.Sprintf(`SELECT e.enumlabel, e.enumsortorder +FROM %s e +WHERE e.enumtypid = %%d ORDER BY e.enumsortorder`, enumTable), + compositeMembers: fmt.Sprintf(`SELECT a.attname, %s, a.attnum, +NOT a.attnotnull, a.atthasdef, %s(ad.adbin, ad.adrelid), col_description(%%d, a.attnum) +FROM %s a +JOIN %s at ON at.oid = a.atttypid +JOIN %s atn ON atn.oid = at.typnamespace +LEFT JOIN %s elem ON elem.oid = at.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum +WHERE a.attrelid = %%d AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, compositeMemberType, getExpr, attributeTable, typeTable, namespaceTable, typeTable, namespaceTable, attrdefTable), + domainBaseType: fmt.Sprintf(`SELECT %s +FROM %s t +JOIN %s n ON n.oid = t.typnamespace +LEFT JOIN %s elem ON elem.oid = t.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +WHERE t.oid = %%[1]d`, domainBaseType, typeTable, namespaceTable, typeTable, namespaceTable), + domainRenderedDefault: fmt.Sprintf(`SELECT %s(t.typdefaultbin, 0) +FROM %s t WHERE t.oid = %%d`, getExpr, typeTable), + domainConstraints: fmt.Sprintf(`SELECT c.conname, %s(c.oid, true) FROM %s c WHERE c.contypid = %%d ORDER BY c.conname`, getConstraintDef, constraintTable), + rangeAttributes: fmt.Sprintf(`SELECT %s, quote_ident(ncan.nspname) || '.' || quote_ident(pcan.proname), quote_ident(ndiff.nspname) || '.' || quote_ident(pdiff.proname), quote_ident(nopc.nspname) || '.' || quote_ident(opc.opcname) +FROM %s r +JOIN %s st ON st.oid = r.rngsubtype +JOIN %s stn ON stn.oid = st.typnamespace +LEFT JOIN %s elem ON elem.oid = st.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s pcan ON pcan.oid = r.rngcanonical +LEFT JOIN %s pdiff ON pdiff.oid = r.rngsubdiff +LEFT JOIN %s opc ON opc.oid = r.rngsubopc +LEFT JOIN %s ncan ON ncan.oid = pcan.pronamespace +LEFT JOIN %s ndiff ON ndiff.oid = pdiff.pronamespace +LEFT JOIN %s nopc ON nopc.oid = opc.opcnamespace +WHERE r.rngtypid = %%d`, rangeSubtype, rangeTable, typeTable, namespaceTable, typeTable, namespaceTable, procTable, procTable, opclassTable, namespaceTable, namespaceTable, namespaceTable), + rangeAttributesForMultirange: fmt.Sprintf(`SELECT %s, quote_ident(ncan.nspname) || '.' || quote_ident(pcan.proname), quote_ident(ndiff.nspname) || '.' || quote_ident(pdiff.proname), quote_ident(nopc.nspname) || '.' || quote_ident(opc.opcname) +FROM %s r +JOIN %s st ON st.oid = r.rngsubtype +JOIN %s stn ON stn.oid = st.typnamespace +LEFT JOIN %s elem ON elem.oid = st.typelem +LEFT JOIN %s elem_n ON elem_n.oid = elem.typnamespace +LEFT JOIN %s pcan ON pcan.oid = r.rngcanonical +LEFT JOIN %s pdiff ON pdiff.oid = r.rngsubdiff +LEFT JOIN %s opc ON opc.oid = r.rngsubopc +LEFT JOIN %s ncan ON ncan.oid = pcan.pronamespace +LEFT JOIN %s ndiff ON ndiff.oid = pdiff.pronamespace +LEFT JOIN %s nopc ON nopc.oid = opc.opcnamespace +WHERE r.rngmultitypid = %%d`, rangeSubtype, rangeTable, typeTable, namespaceTable, typeTable, namespaceTable, procTable, procTable, opclassTable, namespaceTable, namespaceTable, namespaceTable), + rangeMultirange: fmt.Sprintf(`SELECT mt.typname +FROM %s r +JOIN %s mt ON mt.oid = r.rngmultitypid +WHERE r.rngtypid = %%d`, rangeTable, typeTable), + collationName: fmt.Sprintf(`SELECT quote_ident(ncl.nspname) || '.' || quote_ident(cl.collname) FROM %s cl JOIN %s ncl ON ncl.oid = cl.collnamespace WHERE cl.oid = %%d`, collationTable, namespaceTable), + } +} + +// getTypeDetails returns read-only details of a user-defined type. MySQL +// compatibility mode is explicitly unsupported instead of running PostgreSQL +// catalog SQL against a MySQL-mode server. +func (s *server) getTypeDetails(schema, name string) (*customTypeDetails, error) { + if s.mode.mysqlCompat { + return nil, errors.New("type details are not supported in MySQL compatibility mode") + } + schema = strings.TrimSpace(schema) + name = strings.TrimSpace(name) + if schema == "" || name == "" { + return nil, errors.New("schema and type name are required") + } + if isSystemSchema(schema) { + return nil, fmt.Errorf("system schema %s is not supported for custom type details", schema) + } + catalog := "sys_catalog" + if s.mode.postgresCatalog { + catalog = "pg_catalog" + } + prefix := catalogPrefix(catalog) + queries := customTypeCatalogQueriesFor(catalog, prefix, schema, name) + + rows, err := s.metadataQuery(queries.general) + if err != nil { + return nil, fmt.Errorf("failed to locate custom type %s.%s: %w", schema, name, err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read type %s.%s: %w", schema, name, err) + } + return nil, fmt.Errorf("custom type %s.%s does not exist", schema, name) + } + var oid, typbasetype, typrelid, typelem, typcollation int64 + var typtype, typalign, typstorage string + var typisdefined, typnotnull, typbyval bool + var typdefaultbin, typdefault, inputFn, outputFn, receiveFn, sendFn, analyzeFn, comment, collname, relkind sql.NullString + var typlen sql.NullInt64 + var typtypmod int64 + if err := rows.Scan(&oid, &typtype, &typisdefined, &typbasetype, &typnotnull, &typrelid, &typelem, &typcollation, &typdefaultbin, &typdefault, &typlen, &typbyval, &typalign, &typstorage, &typtypmod, &inputFn, &outputFn, &receiveFn, &sendFn, &analyzeFn, &comment, &relkind, &collname); err != nil { + return nil, fmt.Errorf("failed to read type %s.%s: %w", schema, name, err) + } + if err := rows.Close(); err != nil { + return nil, err + } + if !typisdefined { + return nil, fmt.Errorf("custom type %s.%s is not fully defined", schema, name) + } + if typelem != 0 { + return nil, fmt.Errorf("custom type %s.%s is an array companion type", schema, name) + } + kind, ok := customTypeKindFromCode(typtype) + if !ok { + return nil, fmt.Errorf("custom type %s.%s is a pseudo type (typtype=%s)", schema, name, typtype) + } + if relkind.Valid && relkind.String != "" && relkind.String != "c" { + return nil, fmt.Errorf("%s.%s is the auto-generated row type of a relation, not an independent custom type", schema, name) + } + + properties := customTypeCommonProperties(inputFn, outputFn, receiveFn, sendFn, analyzeFn, typlen, typbyval, typalign, typstorage) + properties.DomainConstraints = []customTypeDomainConstraint{} + details := &customTypeDetails{ + Name: name, + Schema: schema, + Kind: kind, + Comment: nullStringPtr(comment), + Members: []customTypeMember{}, + Properties: properties, + } + + var warnings []string + switch kind { + case customTypeKindEnum: + details.Members, err = s.customTypeEnumMembers(queries.enumMembers, oid) + if err != nil { + return nil, err + } + case customTypeKindComposite: + details.Members, err = s.customTypeCompositeMembers(queries.compositeMembers, typrelid) + if err != nil { + return nil, err + } + case customTypeKindDomain: + warnings = append(warnings, s.customTypeDomainAttributes(queries, &details.Properties, oid, typbasetype, typtypmod, typnotnull, typdefaultbin, typdefault, typcollation, collname)...) + case customTypeKindRange: + warnings = append(warnings, s.customTypeRangeAttributes(queries, &details.Properties, oid, false)...) + case customTypeKindMultirange: + warnings = append(warnings, s.customTypeRangeAttributes(queries, &details.Properties, oid, true)...) + case customTypeKindBase: + } + details.DDL = buildCustomTypeDDL(schema, name, kind, inputFn, &details.Members, &details.Properties, warnings) + return details, nil +} + +func customTypeKindFromCode(code string) (customTypeKind, bool) { + switch code { + case "b": + return customTypeKindBase, true + case "c": + return customTypeKindComposite, true + case "d": + return customTypeKindDomain, true + case "e": + return customTypeKindEnum, true + case "r": + return customTypeKindRange, true + case "m": + return customTypeKindMultirange, true + default: + return "", false + } +} + +func customTypeCommonProperties(inputFn, outputFn, receiveFn, sendFn, analyzeFn sql.NullString, typlen sql.NullInt64, typbyval bool, typalign, typstorage string) customTypeProperties { + properties := customTypeProperties{} + properties.InputFunction = nullStringPtr(inputFn) + properties.OutputFunction = nullStringPtr(outputFn) + properties.ReceiveFunction = nullStringPtr(receiveFn) + properties.SendFunction = nullStringPtr(sendFn) + properties.AnalyzeFunction = nullStringPtr(analyzeFn) + if typlen.Valid && typlen.Int64 > 0 { + value := int32(typlen.Int64) + properties.Internallength = &value + } + properties.PassedByValue = &typbyval + if typalign != "" { + properties.Alignment = &typalign + } + if typstorage != "" { + properties.Storage = &typstorage + } + return properties +} + +func (s *server) customTypeEnumMembers(sqlTemplate string, oid int64) ([]customTypeMember, error) { + rows, err := s.metadataQuery(fmt.Sprintf(sqlTemplate, oid)) + if err != nil { + return nil, fmt.Errorf("failed to read enum values: %w", err) + } + defer rows.Close() + var members []customTypeMember + index := 0 + for rows.Next() { + var label string + var sortOrder float64 + if err := rows.Scan(&label, &sortOrder); err != nil { + return nil, err + } + // enumsortorder is float4; ALTER TYPE ... ADD VALUE BEFORE/AFTER can + // yield fractional values. Use the ORDER BY position for a unique key. + index++ + members = append(members, customTypeMember{Ordinal: int32(index), EnumValue: &label}) + } + return members, rows.Err() +} + +func (s *server) customTypeCompositeMembers(sqlTemplate string, typrelid int64) ([]customTypeMember, error) { + rows, err := s.metadataQuery(fmt.Sprintf(sqlTemplate, typrelid, typrelid)) + if err != nil { + return nil, fmt.Errorf("failed to read composite fields: %w", err) + } + defer rows.Close() + var members []customTypeMember + for rows.Next() { + var member customTypeMember + var hasDefault bool + var comment sql.NullString + if err := rows.Scan(&member.Name, &member.DataType, &member.Ordinal, &member.Nullable, &hasDefault, &member.Default, &comment); err != nil { + return nil, err + } + if !hasDefault { + member.Default = nil + } + member.Comment = nullStringPtr(comment) + members = append(members, member) + } + return members, rows.Err() +} + +func (s *server) customTypeDomainAttributes(queries customTypeCatalogQueries, properties *customTypeProperties, oid, typbasetype, typtypmod int64, typnotnull bool, typdefaultbin, typdefault sql.NullString, typcollation int64, collname sql.NullString) []string { + var warnings []string + if base := s.singleStringQuery(fmt.Sprintf(queries.domainBaseType, typbasetype, typtypmod)); base != "" { + properties.BaseType = &base + } + properties.NotNull = &typnotnull + defaultValue, defaultWarnings := resolveCustomTypeDomainDefault(typdefaultbin, typdefault, func() (string, error) { + return s.singleStringQueryResult(fmt.Sprintf(queries.domainRenderedDefault, oid)) + }) + properties.Default = defaultValue + warnings = append(warnings, defaultWarnings...) + if typcollation != 0 { + if collname.Valid && collname.String != "" { + properties.Collation = &collname.String + } else if value := s.singleStringQuery(fmt.Sprintf(queries.collationName, typcollation)); value != "" { + properties.Collation = &value + } + } + rows, err := s.metadataQuery(fmt.Sprintf(queries.domainConstraints, oid)) + if err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be read: %v", err)) + return warnings + } + for rows.Next() { + var constraint customTypeDomainConstraint + if err := rows.Scan(&constraint.Name, &constraint.Definition); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be decoded: %v", err)) + break + } + if constraint.Definition != "" { + properties.DomainConstraints = append(properties.DomainConstraints, constraint) + } + } + if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be read: %v", err)) + } + if err := rows.Close(); err != nil { + warnings = append(warnings, fmt.Sprintf("domain constraints could not be closed: %v", err)) + } + return warnings +} + +func (s *server) customTypeRangeAttributes(queries customTypeCatalogQueries, properties *customTypeProperties, oid int64, isMultirange bool) []string { + var warnings []string + // pg_range.rngtypid stores the RANGE oid; a multirange view resolves its + // owning range through rngmultitypid instead. + rangeTemplate := queries.rangeAttributes + if isMultirange { + rangeTemplate = queries.rangeAttributesForMultirange + } + rows, err := s.metadataQuery(fmt.Sprintf(rangeTemplate, oid)) + if err != nil { + return []string{fmt.Sprintf("range attributes could not be read: %v", err)} + } + if rows.Next() { + var subtype, canonical, subdiff, opclass sql.NullString + if err := rows.Scan(&subtype, &canonical, &subdiff, &opclass); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be decoded: %v", err)) + } else { + properties.RangeSubtype = nullStringPtr(subtype) + properties.RangeCanonicalFunction = nullStringPtr(canonical) + properties.RangeSubtypeDiffFunction = nullStringPtr(subdiff) + properties.RangeSubtypeOpclass = nullStringPtr(opclass) + } + } else if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be read: %v", err)) + } else { + warnings = append(warnings, "range attributes returned no rows") + } + if err := rows.Close(); err != nil { + warnings = append(warnings, fmt.Sprintf("range attributes could not be closed: %v", err)) + } + // Optional PG 13+ multirange companion; older kernels have no column. + if multirangeRows, err := s.metadataQuery(fmt.Sprintf(queries.rangeMultirange, oid)); err == nil { + if multirangeRows.Next() { + var name string + if scanErr := multirangeRows.Scan(&name); scanErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be decoded: %v", scanErr)) + } else if name != "" { + properties.RangeMultirangeName = &name + } + } else if rowsErr := multirangeRows.Err(); rowsErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be read: %v", rowsErr)) + } + if closeErr := multirangeRows.Close(); closeErr != nil { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be closed: %v", closeErr)) + } + } else { + warnings = append(warnings, fmt.Sprintf("multirange companion could not be read: %v", err)) + } + return warnings +} + +func (s *server) singleStringQuery(query string) string { + value, _ := s.singleStringQueryResult(query) + return value +} + +func (s *server) singleStringQueryResult(query string) (string, error) { + rows, err := s.metadataQuery(query) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", rows.Err() + } + var value sql.NullString + if err := rows.Scan(&value); err != nil { + return "", err + } + if !value.Valid { + return "", nil + } + return value.String, nil +} + +func resolveCustomTypeDomainDefault(typdefaultbin, typdefault sql.NullString, render func() (string, error)) (*string, []string) { + if typdefault.Valid && typdefault.String != "" { + value := typdefault.String + return &value, nil + } + if !typdefaultbin.Valid || typdefaultbin.String == "" { + return nil, nil + } + value, err := render() + if err != nil { + return nil, []string{fmt.Sprintf("default value could not be rendered; the generated DDL is incomplete: %v", err)} + } + if value == "" { + return nil, []string{"default value could not be rendered; the generated DDL is incomplete"} + } + return &value, nil +} + +func quoteCatalogIdentifier(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) && strings.Contains(value, `"."`) { + return value + } + if separator := strings.LastIndexByte(value, '.'); separator >= 0 { + return quoteIdentifier(value[:separator]) + "." + quoteIdentifier(value[separator+1:]) + } + return quoteIdentifier(value) +} + +// buildCustomTypeDDL generates normalized CREATE TYPE text. complete is only +// true when the text can be executed standalone; multiranges and base types +// are marked incomplete with visible warnings. +func buildCustomTypeDDL(schema, name string, kind customTypeKind, inputFn sql.NullString, members *[]customTypeMember, properties *customTypeProperties, warnings []string) *customTypeDdl { + qualified := quoteIdentifier(schema) + "." + quoteIdentifier(name) + switch kind { + case customTypeKindEnum: + values := make([]string, 0, len(*members)) + for _, member := range *members { + if member.EnumValue != nil { + values = append(values, quoteLiteral(*member.EnumValue)) + } + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS ENUM (%s);", qualified, strings.Join(values, ", ")), + Complete: true, + Warnings: warnings, + } + case customTypeKindComposite: + fields := make([]string, 0, len(*members)) + comments := make([]string, 0, len(*members)) + for _, member := range *members { + fields = append(fields, quoteIdentifier(member.Name)+" "+member.DataType) + if member.Comment != nil { + comments = append(comments, fmt.Sprintf("COMMENT ON COLUMN %s.%s IS %s;", qualified, quoteIdentifier(member.Name), quoteLiteral(*member.Comment))) + } + } + sql := fmt.Sprintf("CREATE TYPE %s AS (\n %s\n);", qualified, strings.Join(fields, ",\n ")) + if len(comments) > 0 { + sql = sql + "\n" + strings.Join(comments, "\n") + } + return &customTypeDdl{SQL: sql, Complete: true, Warnings: warnings} + case customTypeKindDomain: + complete := true + base := "unknown" + if properties.BaseType != nil && *properties.BaseType != "" { + base = *properties.BaseType + } else { + complete = false + warnings = append(warnings, "base type could not be resolved; the generated DDL is incomplete") + } + parts := []string{fmt.Sprintf("CREATE DOMAIN %s AS %s", qualified, base)} + if properties.Collation != nil && *properties.Collation != "" { + parts = append(parts, "COLLATE "+quoteCatalogIdentifier(*properties.Collation)) + } + if properties.Default != nil && *properties.Default != "" { + parts = append(parts, "DEFAULT "+*properties.Default) + } + if properties.NotNull != nil && *properties.NotNull { + parts = append(parts, "NOT NULL") + } + for _, constraint := range properties.DomainConstraints { + body := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(constraint.Definition), "CHECK")) + constraintName := constraint.Name + if constraintName == "" { + constraintName = name + "_check" + } + parts = append(parts, fmt.Sprintf("CONSTRAINT %s CHECK %s", quoteIdentifier(constraintName), body)) + } + for _, warning := range warnings { + if strings.Contains(warning, "domain constraints") || strings.Contains(warning, "default value could not be rendered") { + complete = false + } + } + return &customTypeDdl{SQL: strings.Join(parts, "\n ") + ";", Complete: complete, Warnings: warnings} + case customTypeKindRange: + var args []string + if properties.RangeSubtype != nil && *properties.RangeSubtype != "" { + args = append(args, "subtype = "+*properties.RangeSubtype) + } + if properties.RangeSubtypeOpclass != nil && *properties.RangeSubtypeOpclass != "" { + args = append(args, "subtype_opclass = "+quoteCatalogIdentifier(*properties.RangeSubtypeOpclass)) + } + if properties.RangeCanonicalFunction != nil && *properties.RangeCanonicalFunction != "" { + args = append(args, "canonical = "+quoteCatalogIdentifier(*properties.RangeCanonicalFunction)) + } + if properties.RangeSubtypeDiffFunction != nil && *properties.RangeSubtypeDiffFunction != "" { + args = append(args, "subtype_diff = "+quoteCatalogIdentifier(*properties.RangeSubtypeDiffFunction)) + } + if properties.RangeMultirangeName != nil && *properties.RangeMultirangeName != "" { + args = append(args, "multirange_type_name = "+quoteCatalogIdentifier(*properties.RangeMultirangeName)) + } + if properties.RangeSubtype == nil || *properties.RangeSubtype == "" { + result := &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS RANGE (subtype = unknown);", qualified), + Complete: false, + Warnings: append(warnings, "range attributes could not be resolved"), + } + return result + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s AS RANGE (\n %s\n);", qualified, strings.Join(args, ",\n ")), + Complete: true, + Warnings: warnings, + } + case customTypeKindMultirange: + return &customTypeDdl{ + SQL: "", + Complete: false, + Warnings: append(append([]string{}, warnings...), fmt.Sprintf("%s is the auto-generated multirange companion of a range type; it has no standalone CREATE statement", qualified)), + } + default: // customTypeKindBase + inputName := "unknown" + if inputFn.Valid && inputFn.String != "" { + inputName = inputFn.String + } + return &customTypeDdl{ + SQL: fmt.Sprintf("CREATE TYPE %s; -- base type attributes require manual reconstruction", qualified), + Complete: false, + Warnings: append(append([]string{}, warnings...), fmt.Sprintf("%s is a base type; its input/output functions (%s) cannot be rebuilt from catalogs", qualified, inputName)), + } + } +} + func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) { effective, err := s.effectiveSchema(schema) if err != nil { return nil, err } - tables, err := s.listTables(effective, metadataListConstraints{}) - if err != nil { - return nil, err + result := []objectInfo{} + if constraintsAllowsTableLike(constraints) { + tables, err := s.listTables(effective, metadataListConstraints{}) + if err != nil { + return nil, err + } + for _, table := range tables { + result = append(result, objectInfo{Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment}) + } } - result := make([]objectInfo, 0, len(tables)) - for _, table := range tables { - result = append(result, objectInfo{Name: table.Name, ObjectType: table.TableType, Schema: effective, Comment: table.Comment}) - } - if !s.mode.mysqlCompat { + if !s.mode.mysqlCompat && constraintsAllowRoutines(constraints) { catalog := "sys_catalog" function := "sys" if s.mode.postgresCatalog { @@ -388,6 +1095,16 @@ WHERE n.nspname = %s ORDER BY p.proname`, catalog, function, catalog, function, _ = rows.Close() } } + if constraintsAllowTypes(constraints) { + types, typesErr := s.listCustomTypes(effective) + if typesErr != nil { + // A type catalog failure is a real fault: surfacing it lets the user + // distinguish an incomplete “all objects” view from an actually + // empty schema, instead of silently dropping the type group. + return nil, fmt.Errorf("list custom types in schema %q: %w", effective, typesErr) + } + result = append(result, types...) + } filtered := result[:0] for _, item := range result { if constraintsMatch(constraints, item.Name, item.ObjectType) { @@ -1172,7 +1889,7 @@ func normalizeTableType(value string) string { switch normalized { case "BASE_TABLE", "PARTITIONED_TABLE": return "TABLE" - case "MATERIALIZED_VIEW", "FOREIGN_TABLE", "VIEW", "TABLE": + case "MATERIALIZED_VIEW", "FOREIGN_TABLE", "VIEW", "TABLE", "TYPE", "TYPE_BODY": return normalized default: return "TABLE" @@ -1216,6 +1933,38 @@ func constraintsAllowsTableLike(constraints metadataListConstraints) bool { return false } +// constraintsAllowTypes reports whether the object-type filter asks for +// user-defined types (or leaves the filter open). normalizeTableType treats +// "TYPE" and "TYPE_BODY" as first-class kinds, so table-like constraints never +// match them and a dedicated type request does not scan relations. +func constraintsAllowTypes(constraints metadataListConstraints) bool { + if len(constraints.ObjectTypes) == 0 { + return true + } + for _, kind := range constraints.ObjectTypes { + switch normalizeTableType(kind) { + case "TYPE", "TYPE_BODY": + return true + } + } + return false +} + +// constraintsAllowRoutines reports whether the object-type filter asks for +// procedures or functions (or leaves the filter open). +func constraintsAllowRoutines(constraints metadataListConstraints) bool { + if len(constraints.ObjectTypes) == 0 { + return true + } + for _, kind := range constraints.ObjectTypes { + upper := strings.ToUpper(strings.TrimSpace(kind)) + if strings.Contains(upper, "PROCEDURE") || strings.Contains(upper, "FUNCTION") { + return true + } + } + return false +} + func constraintsMatch(constraints metadataListConstraints, name, kind string) bool { if filter := strings.TrimSpace(constraints.Filter); filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) { return false @@ -1269,6 +2018,8 @@ func objectOrder(kind string) int { return 4 case "FUNCTION": return 5 + case "TYPE": + return 6 default: return 9 } diff --git a/agents/drivers/vastbase-go/vastbase_metadata_test.go b/agents/drivers/vastbase-go/vastbase_metadata_test.go index d3b22013e8..7c7032f3ae 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata_test.go +++ b/agents/drivers/vastbase-go/vastbase_metadata_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "database/sql/driver" + "errors" "fmt" "io" "strings" @@ -112,6 +113,498 @@ func assertVastbaseIndex(t *testing.T, index indexInfo, name string, columns []s } } +var vastbaseCustomTypesDriverSequence atomic.Uint64 + +type vastbaseCustomTypesTestState struct { + query func(string) (driver.Rows, error) +} + +type valueRows struct { + columns []string + rows [][]driver.Value + index int + nextErr error + closeErr error +} + +func (rows *valueRows) Columns() []string { + if len(rows.columns) > 0 { + return rows.columns + } + return []string{"value"} +} + +func (rows *valueRows) Close() error { return rows.closeErr } + +func (rows *valueRows) Next(destination []driver.Value) error { + if rows.index >= len(rows.rows) { + if rows.nextErr != nil { + return rows.nextErr + } + return io.EOF + } + copy(destination, rows.rows[rows.index]) + rows.index++ + return nil +} + +type vastbaseCustomTypesTestDriver struct { + state *vastbaseCustomTypesTestState +} + +func (testDriver *vastbaseCustomTypesTestDriver) Open(string) (driver.Conn, error) { + return &vastbaseCustomTypesTestConn{state: testDriver.state}, nil +} + +type vastbaseCustomTypesTestConn struct { + state *vastbaseCustomTypesTestState +} + +func (conn *vastbaseCustomTypesTestConn) Prepare(query string) (driver.Stmt, error) { + return &vastbaseCustomTypesTestStmt{state: conn.state, query: query}, nil +} +func (*vastbaseCustomTypesTestConn) Close() error { return nil } +func (*vastbaseCustomTypesTestConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip } + +func (conn *vastbaseCustomTypesTestConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + return conn.state.query(query) +} + +type vastbaseCustomTypesTestStmt struct { + state *vastbaseCustomTypesTestState + query string +} + +func (*vastbaseCustomTypesTestStmt) Close() error { return nil } +func (*vastbaseCustomTypesTestStmt) NumInput() int { return 1 } +func (*vastbaseCustomTypesTestStmt) Exec([]driver.Value) (driver.Result, error) { + return nil, driver.ErrSkip +} +func (stmt *vastbaseCustomTypesTestStmt) Query([]driver.Value) (driver.Rows, error) { + return stmt.state.query(stmt.query) +} + +func openVastbaseCustomTypesDB(t *testing.T, state *vastbaseCustomTypesTestState) *sql.DB { + t.Helper() + driverName := fmt.Sprintf("vastbase-custom-types-%d", vastbaseCustomTypesDriverSequence.Add(1)) + sql.Register(driverName, &vastbaseCustomTypesTestDriver{state: state}) + db, err := sql.Open(driverName, "") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestVastbaseListCustomTypesUsesPostgresCatalog(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, "FROM pg_catalog.pg_type t") || !strings.Contains(query, "t.typtype IN ('b','c','d','e','r','m')") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "t.typelem = 0") || !strings.Contains(query, "(t.typrelid = 0 OR c.relkind = 'c')") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "n.nspname <> 'information_schema'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_toast%'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_temp%'") { + return nil, fmt.Errorf("unexpected query: %s", query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{ + {"status", "order status", "e", true}, + {"email", nil, "d", false}, + {"address", nil, "c", true}, + }, + }, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + server.mode.postgresCatalog = true + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 3 { + t.Fatalf("unexpected types: %#v", types) + } + for _, item := range types { + if item.ObjectType != "TYPE" || item.Schema != "public" { + t.Fatalf("type metadata was lost: %#v", item) + } + } + if types[0].Comment == nil || *types[0].Comment != "order status" { + t.Fatalf("type comment was lost: %#v", types[0]) + } + if types[1].Comment != nil { + t.Fatalf("nil comment became non-nil: %#v", types[1]) + } + if types[0].CustomTypeKind == nil || *types[0].CustomTypeKind != "enum" || types[0].HasMembers == nil || !*types[0].HasMembers { + t.Fatalf("type kind/member metadata was lost: %#v", types[0]) + } + if types[1].CustomTypeKind == nil || *types[1].CustomTypeKind != "domain" || types[1].HasMembers == nil || *types[1].HasMembers { + t.Fatalf("leaf type metadata was lost: %#v", types[1]) + } +} + +func TestVastbaseListCustomTypesUsesSystemCatalog(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, "FROM sys_catalog.sys_type t") || strings.Contains(query, "FROM pg_catalog") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") { + return nil, fmt.Errorf("unexpected query: %s", query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{{"status", "order status", "e", true}}, + }, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + server.mode.postgresCatalog = false + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 1 || types[0].Name != "status" { + t.Fatalf("unexpected types: %#v", types) + } +} + +func TestVastbaseListCustomTypesSkipsMySQLCompatMode(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + return nil, fmt.Errorf("custom types query must not run in mysql compat mode: %s", query) + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + server.mode.mysqlCompat = true + + types, err := server.listCustomTypes("public") + if err != nil { + t.Fatal(err) + } + if len(types) != 0 { + t.Fatalf("expected no types in mysql compat mode: %#v", types) + } +} + +func TestVastbaseCustomTypeQueriesFollowCatalog(t *testing.T) { + pgQueries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "status") + for _, fragment := range []string{ + "pg_catalog.pg_type", "pg_catalog.pg_namespace", "pg_catalog.pg_description", "pg_catalog.pg_proc", + "pg_get_expr", "pg_get_constraintdef", + "n.nspname = 'app' AND t.typname = 'status'", + } { + if !strings.Contains(pgQueries.general, fragment) && !strings.Contains(pgQueries.compositeMembers, fragment) && !strings.Contains(pgQueries.domainConstraints, fragment) { + t.Fatalf("pg catalog queries missing %q: %s", fragment, pgQueries.general) + } + } + sysQueries := customTypeCatalogQueriesFor("sys_catalog", "sys", "app", "status") + for _, fragment := range []string{"sys_catalog.sys_type", "sys_catalog.sys_namespace", "sys_get_expr", "sys_get_constraintdef"} { + if !strings.Contains(sysQueries.general, fragment) && !strings.Contains(sysQueries.compositeMembers, fragment) && !strings.Contains(sysQueries.domainConstraints, fragment) { + t.Fatalf("sys catalog queries missing %q: %s", fragment, sysQueries.general) + } + } + if strings.Contains(sysQueries.general, "FROM pg_catalog") || strings.Contains(sysQueries.general, "pg_get_expr") { + t.Fatalf("sys catalog general query leaked pg_catalog references: %s", sysQueries.general) + } + if strings.Contains(pgQueries.general, "pg_get_expr") || strings.Contains(sysQueries.general, "sys_get_expr") { + t.Fatal("general type lookup must not depend on default-expression rendering") + } + if !strings.Contains(pgQueries.domainRenderedDefault, "pg_get_expr") || !strings.Contains(sysQueries.domainRenderedDefault, "sys_get_expr") { + t.Fatal("domain default renderer must follow the selected catalog") + } + for _, fragment := range []string{"JOIN pg_catalog.pg_type at", "quote_ident(atn.nspname)", "LEFT JOIN pg_catalog.pg_type elem"} { + if !strings.Contains(pgQueries.compositeMembers, fragment) { + t.Fatalf("composite query must schema-qualify member types; missing %q: %s", fragment, pgQueries.compositeMembers) + } + } + for _, fragment := range []string{"JOIN pg_catalog.pg_namespace n", "quote_ident(n.nspname)", "WHERE t.oid = %[1]d"} { + if !strings.Contains(pgQueries.domainBaseType, fragment) { + t.Fatalf("domain query must schema-qualify its base type; missing %q: %s", fragment, pgQueries.domainBaseType) + } + } + formattedDomainBaseType := fmt.Sprintf(pgQueries.domainBaseType, 25, -1) + if strings.Contains(formattedDomainBaseType, "%") || !strings.Contains(formattedDomainBaseType, "format_type(t.oid, -1::int4)") { + t.Fatalf("domain base type query must format both OID and typmod: %s", formattedDomainBaseType) + } + for _, query := range []string{pgQueries.rangeAttributes, pgQueries.rangeAttributesForMultirange} { + for _, fragment := range []string{"JOIN pg_catalog.pg_type st", "quote_ident(stn.nspname)", "quote_ident(ncan.nspname)", "quote_ident(ndiff.nspname)", "quote_ident(nopc.nspname)", "ncan.oid = pcan.pronamespace", "ndiff.oid = pdiff.pronamespace", "nopc.oid = opc.opcnamespace"} { + if !strings.Contains(query, fragment) { + t.Fatalf("range query must qualify catalog names with schema; missing %q: %s", fragment, query) + } + } + if strings.Contains(query, "%!") { + t.Fatalf("range query contains an unresolved format directive: %s", query) + } + } +} + +func TestVastbaseDomainDefaultRenderFailureIsDegradable(t *testing.T) { + bin := sql.NullString{String: "{CONST ...}", Valid: true} + value, warnings := resolveCustomTypeDomainDefault(bin, sql.NullString{}, func() (string, error) { + return "", errors.New("function sys_get_expr does not exist") + }) + if value != nil || len(warnings) != 1 || !strings.Contains(warnings[0], "DDL is incomplete") { + t.Fatalf("unexpected fallback result: value=%v warnings=%v", value, warnings) + } +} + +func TestVastbaseDomainConstraintReadFailuresMarkDDLIncomplete(t *testing.T) { + queries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "email") + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "WHERE t.oid = 1"): + return &valueRows{columns: []string{"base_type"}, rows: [][]driver.Value{{"text"}}}, nil + case strings.Contains(query, "WHERE c.contypid = 9"): + return &valueRows{columns: []string{"conname", "definition"}, rows: [][]driver.Value{{"email_valid"}}}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + properties := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}} + warnings := server.customTypeDomainAttributes(queries, &properties, 9, 1, -1, false, sql.NullString{}, sql.NullString{}, 0, sql.NullString{}) + if len(warnings) != 1 || !strings.Contains(warnings[0], "domain constraints could not be decoded") { + t.Fatalf("constraint scan failures must be retained as warnings: %v", warnings) + } + ddl := buildCustomTypeDDL("app", "email", customTypeKindDomain, sql.NullString{}, &[]customTypeMember{}, &properties, warnings) + if ddl.Complete { + t.Fatalf("domain DDL must be incomplete after a constraint scan failure: %+v", ddl) + } +} + +func TestVastbaseGetTypeDetailsRejectsMySQLCompat(t *testing.T) { + server := newServer() + server.mode.mysqlCompat = true + _, err := server.getTypeDetails("public", "status") + if err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") { + t.Fatalf("expected MySQL compat rejection, got %v", err) + } +} + +func TestVastbaseGetTypeDetailsPropagatesRowIterationError(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(string) (driver.Rows, error) { + return &valueRows{nextErr: errors.New("row stream failed")}, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + _, err := server.getTypeDetails("public", "status") + if err == nil || !strings.Contains(err.Error(), "failed to read type") || !strings.Contains(err.Error(), "row stream failed") { + t.Fatalf("expected row iteration error to be propagated, got %v", err) + } +} + +func TestVastbaseSystemSchemasAreRejectedForCustomTypeDetails(t *testing.T) { + for _, schema := range []string{"pg_catalog", "information_schema", "pg_toast", "pg_toast_temp_5", "pg_temp_5"} { + if !isSystemSchema(schema) { + t.Fatalf("%q should be recognized as a system schema", schema) + } + } + if isSystemSchema("public") || isSystemSchema("app") { + t.Fatal("user schemas must remain eligible for custom type details") + } + server := newServer() + if _, err := server.getTypeDetails("pg_catalog", "int4"); err == nil || !strings.Contains(err.Error(), "system schema") { + t.Fatalf("system schema must be rejected before catalog access, got %v", err) + } +} + +func TestVastbaseCustomTypeDDL(t *testing.T) { + nullInput := sql.NullString{} + enumMembers := []customTypeMember{ + {Ordinal: 1, EnumValue: stringPtr("draft")}, + {Ordinal: 2, EnumValue: stringPtr("已归档")}, + } + enumDDL := buildCustomTypeDDL("app", "status", customTypeKindEnum, nullInput, &enumMembers, &customTypeProperties{}, nil) + if enumDDL.SQL != "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', '已归档');" || !enumDDL.Complete { + t.Fatalf("unexpected enum DDL: %+v", enumDDL) + } + notNull := true + domainProps := customTypeProperties{BaseType: stringPtr("text"), NotNull: ¬Null, DomainConstraints: []customTypeDomainConstraint{{Name: "email_valid", Definition: "CHECK ((VALUE <> ''::text))"}}} + domainDDL := buildCustomTypeDDL("app", "email", customTypeKindDomain, nullInput, &[]customTypeMember{}, &domainProps, nil) + if !strings.Contains(domainDDL.SQL, "CREATE DOMAIN \"app\".\"email\" AS text") || !strings.Contains(domainDDL.SQL, "NOT NULL") { + t.Fatalf("unexpected domain DDL: %+v", domainDDL) + } + rangeProps := customTypeProperties{RangeSubtype: stringPtr("numeric"), RangeCanonicalFunction: stringPtr("\"extensions\".\"numeric_range_canonical\"")} + rangeDDL := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &rangeProps, nil) + if !rangeDDL.Complete || !strings.Contains(rangeDDL.SQL, "canonical = \"extensions\".\"numeric_range_canonical\"") { + t.Fatalf("unexpected range DDL: %+v", rangeDDL) + } + missingSubtype := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &customTypeProperties{RangeMultirangeName: stringPtr("price_multirange")}, nil) + if missingSubtype.Complete || missingSubtype.SQL != "CREATE TYPE \"app\".\"price_range\" AS RANGE (subtype = unknown);" { + t.Fatalf("range DDL without subtype must be incomplete: %+v", missingSubtype) + } + multirangeDDL := buildCustomTypeDDL("app", "_price_range", customTypeKindMultirange, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil) + if multirangeDDL.Complete || len(multirangeDDL.Warnings) == 0 { + t.Fatalf("multirange DDL must be incomplete with warnings: %+v", multirangeDDL) + } + baseDDL := buildCustomTypeDDL("app", "point2d", customTypeKindBase, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil) + if baseDDL.Complete || len(baseDDL.Warnings) == 0 { + t.Fatalf("base DDL must be incomplete with warnings: %+v", baseDDL) + } +} + +func TestVastbaseListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "sys_type t"): + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{ + {"status", "order status", "e", true}, + {"email", nil, "d", false}, + }, + }, nil + case strings.Contains(query, "sys_proc p"): + return &valueRows{ + columns: []string{"proname", "kind", "comment"}, + rows: [][]driver.Value{{"format_name", "FUNCTION", nil}}, + }, nil + case strings.Contains(query, "sys_class c"): + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + } + return nil, fmt.Errorf("unexpected query: %s", query) + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + objects, err := server.listObjects("public", metadataListConstraints{}) + if err != nil { + t.Fatal(err) + } + var typeNames []string + for _, item := range objects { + if item.ObjectType == "TYPE" { + typeNames = append(typeNames, item.Name) + } + } + if len(typeNames) != 2 || typeNames[0] != "email" || typeNames[1] != "status" { + t.Fatalf("unexpected types in object list: %v (objects=%#v)", typeNames, objects) + } + if len(objects) != 4 { + t.Fatalf("expected table + function + 2 types, got %#v", objects) + } +} + +func TestVastbaseListObjectsOnlyCustomTypesWhenTypeRequested(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "FROM sys_catalog.sys_class c") || strings.Contains(query, "sys_proc p") { + return nil, fmt.Errorf("type-only request must not scan relations or routines: %s", query) + } + if !strings.Contains(query, "sys_type t") { + return nil, fmt.Errorf("unexpected query: %s", query) + } + return &valueRows{ + columns: []string{"typname", "description", "typtype", "has_members"}, + rows: [][]driver.Value{{"status", "order status", "e", true}}, + }, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + // The sidebar type group sends TYPE together with the TYPE_BODY companion; + // both must resolve to a type-only request that never scans tables. + for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} { + objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: objectTypes}) + if err != nil { + t.Fatal(err) + } + if len(objects) != 1 || objects[0].Name != "status" || objects[0].ObjectType != "TYPE" || objects[0].Schema != "public" { + t.Fatalf("expected only the TYPE object for %v: %#v", objectTypes, objects) + } + } +} + +func TestVastbaseTypeBodyConstraintIsNotTableLike(t *testing.T) { + constraints := metadataListConstraints{ObjectTypes: []string{"TYPE", "TYPE_BODY"}} + if !constraintsAllowTypes(constraints) { + t.Fatal("TYPE/TYPE_BODY request must allow types") + } + if constraintsAllowsTableLike(constraints) { + t.Fatal("TYPE/TYPE_BODY request must not be table-like; normalizeTableType must not map TYPE_BODY to TABLE") + } + if constraintsAllowRoutines(constraints) { + t.Fatal("TYPE/TYPE_BODY request must not be routine-like") + } +} + +func TestVastbaseListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_type t") || strings.Contains(query, "sys_proc p") { + return nil, fmt.Errorf("table-only request must not scan types or routines: %s", query) + } + if !strings.Contains(query, "FROM sys_catalog.sys_class c") { + return nil, fmt.Errorf("unexpected query: %s", query) + } + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TABLE"}}) + if err != nil { + t.Fatal(err) + } + for _, item := range objects { + if item.ObjectType == "TYPE" { + t.Fatalf("table-only request must not return types: %#v", objects) + } + } + if len(objects) == 0 { + t.Fatalf("expected the table to remain listed: %#v", objects) + } +} + +func TestVastbaseListObjectsTypeOnlyPropagatesCustomTypesError(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + return nil, fmt.Errorf("catalog unavailable: %s", query) + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + _, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TYPE"}}) + if err == nil { + t.Fatal("dedicated type request must propagate the catalog error") + } + if !strings.Contains(err.Error(), "list custom types") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestVastbaseListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_type t") { + return nil, fmt.Errorf("pg_type unavailable") + } + switch { + case strings.Contains(query, "sys_proc p"): + return &valueRows{ + columns: []string{"proname", "kind", "comment"}, + rows: [][]driver.Value{{"format_name", "FUNCTION", nil}}, + }, nil + case strings.Contains(query, "FROM sys_catalog.sys_class c"): + return &valueRows{ + columns: []string{"relname", "relkind", "comment"}, + rows: [][]driver.Value{{"orders", "TABLE", nil}}, + }, nil + } + return nil, fmt.Errorf("unexpected query: %s", query) + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + // A failing type catalog must surface as an error even for the unfiltered + // “all objects” listing, so users never see a silently incomplete list. + _, err := server.listObjects("public", metadataListConstraints{}) + if err == nil { + t.Fatal("unfiltered request must propagate the type catalog error") + } + if !strings.Contains(err.Error(), "list custom types") { + t.Fatalf("unexpected error: %v", err) + } +} + var vastbaseIndexMetadataDriverSequence atomic.Uint64 type vastbaseIndexMetadataTestState struct { diff --git a/apps/desktop/src/components/objects/CustomTypeInfoPanel.vue b/apps/desktop/src/components/objects/CustomTypeInfoPanel.vue new file mode 100644 index 0000000000..3e2aac77f1 --- /dev/null +++ b/apps/desktop/src/components/objects/CustomTypeInfoPanel.vue @@ -0,0 +1,218 @@ + + + diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index cfadfb7a00..6683d618da 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -18,6 +18,7 @@ import { Eraser, Eye, FileCode, + Info, GripVertical, KeyRound, LayoutGrid, @@ -55,6 +56,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; +import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue"; import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"; import * as api from "@/lib/backend/api"; import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database"; @@ -118,6 +120,7 @@ import { type ObjectBrowserSortKey, } from "@/lib/table/objectBrowserRows"; import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction"; +import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo"; import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; @@ -173,7 +176,7 @@ const sourceCanEdit = ref(true); // --- Right-side panel state --- // Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.) const sidePanelRow = ref(null); -const sidePanelMode = ref<"table-info" | "source">("source"); +const sidePanelMode = ref<"table-info" | "source" | "type-info">("source"); // Table info panel state const tableInfoTab = ref("ddl"); const tableColumns = ref([]); @@ -196,6 +199,7 @@ let sidePanelResizeStartX = 0; let sidePanelResizeStartWidth = 0; const isResizingSidePanel = ref(false); const sidePanelGuard = createSidePanelRequestGuard(); +const sidePanelRef = ref | null>(null); const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(effectiveDatabaseType.value)); const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type); const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics"); @@ -822,6 +826,9 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction) case "table-info": void openTableInfo(row); break; + case "type-info": + void openTypeInfo(row); + break; case "open-table": emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog }); break; @@ -833,7 +840,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction) function onRowClick(row: ObjectBrowserRow, event: MouseEvent) { const activation = settingsStore.editorSettings.sidebarActivation; - const { action, isDouble } = resolveRowClickAction(row, event.detail, activation); + const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value); // Double click: cancel any pending single-click and fire immediately if (isDouble) { if (singleClickTimer) { @@ -1107,6 +1114,28 @@ function closeSidePanel() { sidePanelGuard.bump(); } +async function openTypeInfo(row: ObjectBrowserRow) { + if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "type-info") { + closeSidePanel(); + return; + } + sidePanelRow.value = row; + sidePanelMode.value = "type-info"; + sidePanelGuard.bump(); +} + +function openTypeDdl(row: ObjectBrowserRow) { + sidePanelRow.value = row; + sidePanelMode.value = "type-info"; + sidePanelGuard.bump(); + // DDL tab is selected by the panel once details load; switching the tab is + // deferred via a dedicated request so the panel can focus it. + nextTick(() => { + const panel = sidePanelRef.value; + panel?.selectTab("ddl"); + }); +} + const canOpenTableStructureEditor = computed(() => sidePanelRow.value?.type === "TABLE" && canOpenStructureEditor.value); function openTableStructureEditor() { @@ -2737,9 +2766,32 @@ function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { ]; } +function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + const items: ContextMenuItem[] = []; + const capabilities = customTypeCapabilities(effectiveDatabaseType.value); + // Verified PG-family types open the read-only details panel; Xugu keeps its + // “view source” entry for TYPE/TYPE_BODY rows. + if (supportsTypeObjectSource(effectiveDatabaseType.value)) { + items.push({ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }); + } + if (capabilities.details) { + items.push({ label: t("contextMenu.viewDetails"), action: () => void openTypeInfo(item), icon: Info }); + if (capabilities.ddl) { + items.push({ label: t("contextMenu.viewDdl"), action: () => openTypeDdl(item), icon: FileCode }); + } + } + // Only separate when an action precedes copy-name. + if (items.length > 0) { + items.push({ label: "", separator: true }); + } + items.push({ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy }); + return items; +} + function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { if (item.type === "TABLE") return getTableMenuItems(item); if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item); + if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item); if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item); return getProcFuncMenuItems(item); } @@ -3233,6 +3285,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + +