feat(postgres): list user-defined types with details and DDL for PG-family databases

This commit is contained in:
amwps290
2026-08-11 15:00:24 +08:00
committed by GitHub
parent 0c6c1fada2
commit 154c460657
59 changed files with 6538 additions and 150 deletions
+3
View File
@@ -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
@@ -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 {
+765 -14
View File
@@ -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
}
+3
View File
@@ -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
+471 -4
View File
@@ -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: &notNull, 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") {
@@ -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 {
+3
View File
@@ -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
+765 -14
View File
@@ -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
}
@@ -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: &notNull, 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 {
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, RefreshCw, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ConnectionConfig, CustomTypeDetails, CustomTypeKind } from "@/types/database";
import * as api from "@/lib/backend/api";
const props = defineProps<{
connection: ConnectionConfig;
database: string;
schema: string;
name: string;
catalog?: string;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const toast = useToast();
const guard = createSidePanelRequestGuard();
const details = ref<CustomTypeDetails | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const tab = ref<"members" | "properties" | "ddl">("members");
const kindLabel = computed(() => (details.value ? t(`customType.kinds.${details.value.kind}`) : ""));
const hasMemberTab = computed(() => details.value?.kind === "composite" || details.value?.kind === "enum");
const tabs = computed(() => {
const items: Array<{ id: "members" | "properties" | "ddl"; label: string }> = [];
if (hasMemberTab.value) items.push({ id: "members", label: t("customType.tabs.members") });
items.push({ id: "properties", label: t("customType.tabs.properties") });
items.push({ id: "ddl", label: t("customType.tabs.ddl") });
return items;
});
async function load() {
// start() bumps the epoch so an in-flight request from a previously selected
// type can never overwrite the current panel state.
const epoch = guard.start();
loading.value = true;
error.value = null;
details.value = null;
tab.value = "members";
try {
const result = await api.getCustomTypeDetails(props.connection.id, props.database, props.schema, props.name);
if (guard.isStale(epoch)) return;
details.value = result;
if (tab.value === "members" && result.kind !== "composite" && result.kind !== "enum") {
tab.value = "properties";
}
} catch (e: any) {
if (guard.isStale(epoch)) return;
error.value = e?.message || String(e);
} finally {
if (guard.isFresh(epoch)) loading.value = false;
}
}
watch(
() => [props.connection.id, props.database, props.schema, props.name] as const,
() => load(),
{ immediate: true },
);
const ddlText = computed(() => details.value?.ddl?.sql ?? "");
async function copyDdl() {
if (!ddlText.value) return;
try {
await copyToClipboard(ddlText.value);
toast.toast(t("contextMenu.ddlCopied"));
} catch (error) {
toast.toast(t("grid.copyFailed", { message: translateBackendError(t, error) }));
}
}
const propertyRows = computed<Array<{ label: string; value: string }>>(() => {
const p = details.value?.properties;
if (!p) return [];
const rows: Array<{ label: string; value: string }> = [];
const push = (label: string, value: string | null | undefined) => {
if (value != null && value !== "") rows.push({ label, value: String(value) });
};
push(t("customType.properties.baseType"), p.baseType);
if (p.notNull) push(t("customType.properties.notNull"), "true");
push(t("customType.properties.default"), p.default);
push(t("customType.properties.collation"), p.collation);
for (const constraint of p.domainConstraints) {
rows.push({
label: constraint.name ? `${t("customType.properties.domainConstraint")} · ${constraint.name}` : t("customType.properties.domainConstraint"),
value: constraint.definition,
});
}
push(t("customType.properties.rangeSubtype"), p.rangeSubtype);
push(t("customType.properties.rangeMultirange"), p.rangeMultirangeName);
return rows;
});
function isFieldKind(kind: CustomTypeKind | undefined): boolean {
return kind === "composite";
}
function isEnumKind(kind: CustomTypeKind | undefined): boolean {
return kind === "enum";
}
function selectTab(next: "members" | "properties" | "ddl") {
tab.value = next;
}
defineExpose({ selectTab });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-2 border-b px-3 py-1.5 bg-muted/20 h-9 shrink-0">
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ name }}</span>
<span class="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0">{{ kindLabel }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
<X class="w-3 h-3" />
</Button>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="error" class="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
<div class="text-xs text-destructive break-words max-w-full">{{ error }}</div>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="load">
<RefreshCw class="w-3 h-3" />
{{ t("common.retry") }}
</Button>
</div>
<template v-else-if="details">
<div class="flex items-center gap-1 border-b px-2 py-1 shrink-0">
<button v-for="item in tabs" :key="item.id" type="button" class="rounded px-2 py-1 text-xs" :class="tab === item.id ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'" @click="tab = item.id">
{{ item.label }}
</button>
<span class="flex-1" />
<Button v-if="tab === 'ddl' && ddlText" variant="ghost" size="sm" class="h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
<Copy class="w-3 h-3" />
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
</Button>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<!-- Members: empty state for composite/enum -->
<div v-if="tab === 'members' && details.members.length === 0" class="flex flex-col items-center justify-center gap-1 px-4 py-8 text-center">
<span class="text-xs text-muted-foreground">{{ t("customType.members.empty") }}</span>
</div>
<!-- Members: composite fields -->
<table v-else-if="tab === 'members' && isFieldKind(details.kind)" class="w-full text-[11px]">
<thead class="sticky top-0 bg-muted/40 text-left text-muted-foreground">
<tr>
<th class="px-3 py-1.5 font-medium">#</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.name") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.type") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.nullable") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.default") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.comment") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in details.members" :key="member.ordinal" class="border-t border-border/60">
<td class="px-3 py-1.5 text-muted-foreground">{{ member.ordinal }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.name }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.dataType }}</td>
<td class="px-3 py-1.5">{{ member.nullable == null ? "—" : member.nullable ? "✓" : "✗" }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.default ?? "—" }}</td>
<td class="px-3 py-1.5">{{ member.comment ?? "—" }}</td>
</tr>
</tbody>
</table>
<!-- Members: enum values -->
<ul v-else-if="tab === 'members' && isEnumKind(details.kind) && details.members.length > 0" class="divide-y divide-border/60">
<li v-for="member in details.members" :key="member.ordinal" class="flex items-center gap-3 px-3 py-1.5 text-[11px]">
<span class="w-6 text-muted-foreground text-right">{{ member.ordinal }}</span>
<span class="font-mono">{{ member.enumValue }}</span>
</li>
</ul>
<!-- Properties -->
<table v-else-if="tab === 'properties'" class="w-full text-[11px]">
<tbody>
<tr v-if="details.comment" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44">{{ t("customType.members.comment") }}</td>
<td class="px-3 py-1.5">{{ details.comment }}</td>
</tr>
<tr v-for="row in propertyRows" :key="row.label" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44 align-top">{{ row.label }}</td>
<td class="px-3 py-1.5 font-mono whitespace-pre-wrap break-all">{{ row.value }}</td>
</tr>
<tr v-if="propertyRows.length === 0 && !details.comment" class="border-b border-border/60">
<td class="px-3 py-2 text-xs text-muted-foreground" colspan="2">{{ t("customType.properties.empty") }}</td>
</tr>
</tbody>
</table>
<!-- DDL -->
<div v-else class="flex h-full flex-col">
<div v-if="details.ddl && !details.ddl.complete" class="border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5">
<div v-for="warning in details.ddl.warnings" :key="warning" class="text-[11px] text-amber-600 dark:text-amber-400">{{ warning }}</div>
</div>
<pre class="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed">{{ ddlText || t("customType.ddl.empty") }}</pre>
</div>
</div>
</template>
</div>
</template>
@@ -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<ObjectBrowserRow | null>(null);
const sidePanelMode = ref<"table-info" | "source">("source");
const sidePanelMode = ref<"table-info" | "source" | "type-info">("source");
// Table info panel state
const tableInfoTab = ref<TableInfoTab>("ddl");
const tableColumns = ref<ColumnInfo[]>([]);
@@ -196,6 +199,7 @@ let sidePanelResizeStartX = 0;
let sidePanelResizeStartWidth = 0;
const isResizingSidePanel = ref(false);
const sidePanelGuard = createSidePanelRequestGuard();
const sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => 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[] {
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</template>
<!-- Type info mode (read-only user-defined type details) -->
<template v-else-if="sidePanelMode === 'type-info'">
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
</template>
<!-- Source mode (views, procedures, functions, sequences) -->
<template v-else>
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
@@ -0,0 +1,246 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import type { ConnectionConfig, CustomTypeDetails } from "@/types/database";
const mocks = vi.hoisted(() => ({
getCustomTypeDetails: vi.fn(),
copyToClipboard: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
getCustomTypeDetails: (...args: unknown[]) => mocks.getCustomTypeDetails(...args),
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return {
Copy: Icon,
RefreshCw: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
const connection: ConnectionConfig = {
id: "pg-1",
name: "pg",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "test",
password: "secret",
database: Some("demo"),
visible_databases: null,
visible_schemas: null,
show_system_schemas: false,
attached_databases: [],
init_script: null,
color: null,
transport_layers: [],
connection_string: null,
driver_profile: null,
driver_label: null,
note: "",
url_params: null,
agent_java_options: [],
} as unknown as ConnectionConfig;
function Some<T>(value: T): T | null {
return value;
}
function enumDetails(overrides: Partial<CustomTypeDetails> = {}): CustomTypeDetails {
return {
name: "status",
schema: "app",
kind: "enum",
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
properties: { domainConstraints: [] },
ddl: { sql: "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');", complete: true, warnings: [] },
...overrides,
};
}
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
function mountPanel(props: Record<string, unknown>) {
root = document.createElement("div");
document.body.append(root);
app = createApp(CustomTypeInfoPanel, props);
app.mount(root);
}
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function flush() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function clickButton(text: string) {
const button = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent?.trim() === text);
button?.dispatchEvent(new MouseEvent("click"));
return button;
}
describe("CustomTypeInfoPanel", () => {
it("shows loading while details are being fetched", async () => {
let release!: () => void;
mocks.getCustomTypeDetails.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await nextTick();
expect(root!.textContent).toContain("common.loading");
release();
});
it("renders enum values and complete DDL", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("draft");
expect(root!.textContent).toContain("published");
});
it("shows the explanatory empty state for an enum with no values", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ members: [] }));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows the explanatory empty state for a composite with no fields", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ kind: "composite", members: [], ddl: { sql: "CREATE TYPE ...;", complete: true, warnings: [] } }));
mountPanel({ connection, database: "demo", schema: "app", name: "address" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows DDL warnings when the DDL is incomplete", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
ddl: { sql: "", complete: false, warnings: ["multirange companion; no standalone CREATE"] },
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "_price_range" });
await flush();
const ddlButton = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent === "customType.tabs.ddl");
ddlButton?.dispatchEvent(new MouseEvent("click"));
await nextTick();
expect(root!.textContent).toContain("multirange companion; no standalone CREATE");
});
it("hides kernel-level type implementation attributes", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
kind: "base",
members: [],
properties: {
domainConstraints: [],
inputFunction: "base_in",
alignment: "i",
storage: "x",
},
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "base_type" });
await flush();
expect(clickButton("customType.tabs.members")).toBeUndefined();
expect(root!.textContent).toContain("customType.tabs.properties");
expect(root!.textContent).not.toContain("base_in");
expect(root!.textContent).not.toContain("customType.properties.alignment");
expect(root!.textContent).not.toContain("customType.properties.storage");
});
it("renders an error state with a working retry button", async () => {
mocks.getCustomTypeDetails.mockRejectedValueOnce(new Error("catalog unavailable"));
mocks.getCustomTypeDetails.mockResolvedValueOnce(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("catalog unavailable");
clickButton("common.retry");
await vi.waitFor(() => expect(root!.textContent).toContain("draft"));
});
it("reports copy success through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockResolvedValue(undefined);
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');"));
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.ddlCopied");
});
it("reports copy failure through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockRejectedValue(new Error("denied"));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed"));
});
it("ignores a stale response when the type is switched quickly on the same instance", async () => {
let releaseFirst!: () => void;
const first = new Promise<CustomTypeDetails>((resolve) => (releaseFirst = () => resolve(enumDetails({ name: "status" }))));
const second = Promise.resolve(enumDetails({ name: "email", kind: "domain", members: [], properties: { baseType: "text", domainConstraints: [] } }));
mocks.getCustomTypeDetails.mockReturnValueOnce(first).mockReturnValueOnce(second);
const hostName = ref("status");
const Host = defineComponent({
setup() {
return () => h(CustomTypeInfoPanel, { connection, database: "demo", schema: "app", name: hostName.value });
},
});
root = document.createElement("div");
document.body.append(root);
app = createApp(Host);
app.mount(root);
await nextTick();
hostName.value = "email";
await nextTick();
releaseFirst();
await flush();
// The stale "status" response must not overwrite the "email" details.
expect(root!.textContent).toContain("email");
expect(root!.textContent).not.toContain("draft");
});
});
@@ -13,6 +13,7 @@ import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
@@ -1338,6 +1339,9 @@ function openSidebarDdlForSelection(): boolean {
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction();
void store
@@ -97,6 +97,7 @@ import {
isSingleDatabase,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
@@ -565,6 +566,9 @@ async function toggle() {
}
return;
}
// PostgreSQL-family custom types without members (enum/domain/range/base) do
// not toggle; only composite types expand into their member list.
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.hasMembers === false) return;
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
@@ -593,6 +597,12 @@ async function toggle() {
return;
}
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.children !== undefined) {
node.isExpanded = node.children.length > 0 ? !node.isExpanded : false;
emitNodeToggled(node, wasExpanded);
return;
}
// Keep the click path aligned with every object-group definition. In
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
@@ -625,6 +635,12 @@ async function toggle() {
}
try {
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
await connectionStore.loadCustomTypeChildren(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (await loadSidebarObjectGroup(node, connectionStore)) {
emitNodeToggled(node, wasExpanded);
return;
@@ -803,7 +819,7 @@ function runRowClickAction(clickDetail: number) {
openMongoTreeData(node);
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType());
if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node))) return;
if (action === "open-data") {
scheduleOpenData(node);
@@ -1112,7 +1128,7 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value);
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType());
if (action === "open-object-browser") {
void openObjectBrowser();
} else if (action === "open-object-browser-and-expand") {
@@ -1506,6 +1522,28 @@ async function copyName() {
}
}
async function copyCustomTypeDdl() {
const node = createSidebarActionTarget(activeNode.value);
if (!node.connectionId || !node.database) return;
try {
await connectionStore.ensureConnected(node.connectionId);
const details = await api.getCustomTypeDetails(node.connectionId, node.database, node.schema || node.database, node.objectName || node.label);
const sql = details.ddl?.sql?.trim();
if (!sql) {
toast(t("customType.ddl.empty"), 3000);
return;
}
if (!details.ddl?.complete) {
toast(t("customType.ddl.incomplete"), 5000);
return;
}
await copyToClipboard(sql);
toast(t("contextMenu.ddlCopied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: translateBackendError(t, error) }), 5000);
}
}
async function copySelectedNames() {
const selectedNodes = selectedTreeNodesInVisibleOrder();
const nodes = selectedNodes.length > 1 && selectedNodes.some((node) => node.id === activeNode.value.id) ? selectedNodes : [activeNode.value];
@@ -1686,6 +1724,9 @@ async function refreshDropTableChildObjectPreviewSql() {
function openObjectSourceDialog(initialEditing: boolean) {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(currentDatabaseType())) return;
const connectionId = node.connectionId;
const database = node.database;
const sourceTarget = objectSourceTargetForTreeNode(node);
@@ -3408,13 +3449,16 @@ function createMysqlObjectTemplate() {
queryStore.updateSql(tabId, template.sql);
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const canPin = computed(() => canTreeNodePin(activeNode.value.type));
@@ -4702,7 +4746,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "trigger" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") {
if (node.type === "trigger" || node.type === "package" || node.type === "package-body") {
if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) {
items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench });
}
@@ -4711,6 +4755,23 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
// PostgreSQL-family types expand in the tree and expose DDL as a lightweight
// copy action. Xugu keeps its source/change-open-mode entries.
if (node.type === "type" || node.type === "type-body") {
if (supportsTypeObjectSource(currentDatabaseType())) {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
if (customTypeCapabilities(currentDatabaseType()).details) {
items.push({ label: t("contextMenu.copyDdl"), action: copyCustomTypeDdl, icon: Copy });
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return true;
}
return false;
}
@@ -49,7 +49,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
@@ -72,6 +72,7 @@ import { useDragSort } from "@/composables/useDragSort";
import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime";
import { treeNodePinKey } from "@/lib/app/pinnedItems";
import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup";
import { customTypeCapabilities } from "@/lib/database/databaseObjectCapabilities";
import { shouldActivateTreeNodeOnSingleClick } from "@/lib/sidebar/treeNodeClick";
const { t } = useI18n();
@@ -299,6 +300,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Braces, colorClass: "text-violet-500" };
case "type-body":
return { icon: FileCode, colorClass: "text-violet-400" };
case "type-member":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "group-tables":
return { icon: Table, colorClass: "text-green-500" };
case "group-views":
@@ -343,6 +346,12 @@ function displayLabel(node: TreeNode): string {
return isGroupLabel(node) ? t(node.label) : node.label;
}
function treeNodeSecondaryValue(node: TreeNode): string | undefined {
if (node.type === "type" && node.customTypeKind) return t(`customType.kinds.${node.customTypeKind}`);
if (node.type === "type-member") return (node.meta as CustomTypeTreeMemberMeta | undefined)?.displayValue;
return undefined;
}
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
@@ -572,13 +581,19 @@ async function cancelConnectionAttempt() {
}
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
// PostgreSQL-family custom types: only show the expander when the type has
// loadable members; types without members (enum/domain/range/base) do not
// expand even if the catalog row lists child metadata.
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const isPinned = computed(() => activeNode.value.pinned || connectionStore.isTreeNodePinned(activeNode.value));
@@ -1205,6 +1220,7 @@ function onKeydown(event: KeyboardEvent) {
@click.stop
/>
<span v-else ref="labelRef" :class="[labelWidthClass, { 'flex-1': node.type === 'connection' && !trailingComment }]">{{ visibleLabel(node) }}</span>
<span v-if="treeNodeSecondaryValue(node)" class="min-w-0 max-w-[55%] shrink truncate text-xs text-muted-foreground" :title="treeNodeSecondaryValue(node)">{{ treeNodeSecondaryValue(node) }}</span>
<button
v-if="canDragPinnedOrder()"
type="button"
@@ -1226,9 +1242,11 @@ function onKeydown(event: KeyboardEvent) {
node.type === 'group-materialized-views' ||
node.type === 'group-procedures' ||
node.type === 'group-functions' ||
node.type === 'group-triggers' ||
node.type === 'group-sequences' ||
node.type === 'group-synonyms' ||
node.type === 'group-packages' ||
node.type === 'group-types' ||
node.type === 'group-partitions') &&
node.objectCount != null
"
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en";
import es from "@/i18n/locales/es";
import itLocale from "@/i18n/locales/it";
import ja from "@/i18n/locales/ja";
import ko from "@/i18n/locales/ko";
import ptBR from "@/i18n/locales/pt-BR";
import zhCN from "@/i18n/locales/zh-CN";
import zhTW from "@/i18n/locales/zh-TW";
type Messages = {
customType: typeof en.customType;
contextMenu: typeof en.contextMenu;
};
const locales = { es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW } as unknown as Record<string, Messages>;
describe("custom type translations", () => {
it("provides localized labels in every supported non-English locale", () => {
for (const [localeName, messages] of Object.entries(locales)) {
expect(messages.customType.kinds.composite, localeName).not.toBe(en.customType.kinds.composite);
expect(messages.customType.tabs.properties, localeName).not.toBe(en.customType.tabs.properties);
expect(messages.customType.members.empty, localeName).not.toBe(en.customType.members.empty);
expect(messages.customType.properties.empty, localeName).not.toBe(en.customType.properties.empty);
expect(messages.customType.ddl.empty, localeName).not.toBe(en.customType.ddl.empty);
expect(messages.customType.ddl.incomplete, localeName).not.toBe(en.customType.ddl.incomplete);
expect(messages.contextMenu.viewDetails, localeName).not.toBe(en.contextMenu.viewDetails);
}
});
});
+38
View File
@@ -77,6 +77,43 @@ const consul = {
};
export default {
customType: {
kinds: {
base: "Base",
composite: "Composite",
domain: "Domain",
enum: "Enum",
range: "Range",
multirange: "Multirange",
},
tabs: {
members: "Members",
properties: "Properties",
ddl: "DDL",
},
members: {
empty: "This type has no members.",
name: "Name",
type: "Type",
nullable: "Nullable",
default: "Default",
comment: "Comment",
},
properties: {
empty: "No attributes available.",
baseType: "Base type",
notNull: "Not null",
default: "Default",
collation: "Collation",
domainConstraint: "Constraint",
rangeSubtype: "Range subtype",
rangeMultirange: "Multirange type",
},
ddl: {
empty: "No DDL is available for this type.",
incomplete: "The generated DDL is incomplete and was not copied. Open the type details to review its warnings.",
},
},
app: {
name: "DBX",
},
@@ -2588,6 +2625,7 @@ export default {
editView: "Edit View",
viewSource: "View Source",
changeOpenMode: "Change open mode",
viewDetails: "View Details",
viewDdl: "View DDL",
viewDdlLoading: "Loading DDL...",
ddlCopied: "DDL copied",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Compuesto",
domain: "Dominio",
enum: "Enumeración",
range: "Rango",
multirange: "Multirrango",
},
tabs: {
members: "Miembros",
properties: "Propiedades",
ddl: "DDL",
},
members: {
empty: "Este tipo no tiene miembros.",
name: "Nombre",
type: "Tipo",
nullable: "Admite NULL",
default: "Valor predeterminado",
comment: "Comentario",
},
properties: {
empty: "No hay atributos disponibles.",
baseType: "Tipo base",
notNull: "No nulo",
default: "Valor predeterminado",
collation: "Intercalación",
domainConstraint: "Restricción",
rangeSubtype: "Subtipo del rango",
rangeMultirange: "Tipo multirrango",
},
ddl: {
empty: "No hay DDL disponible para este tipo.",
incomplete: "El DDL generado está incompleto y no se copió. Abra los detalles del tipo para revisar las advertencias.",
},
},
app: {
name: "DBX",
},
@@ -2522,6 +2559,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabla "{name}" eliminada',
editView: "Editar vista",
viewSource: "Ver código fuente",
viewDetails: "Ver detalles",
viewDdl: "Ver DDL",
viewDdlLoading: "Cargando DDL...",
ddlCopied: "DDL copiado",
+38
View File
@@ -77,6 +77,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Composito",
domain: "Dominio",
enum: "Enumerazione",
range: "Intervallo",
multirange: "Intervallo multiplo",
},
tabs: {
members: "Membri",
properties: "Proprietà",
ddl: "DDL",
},
members: {
empty: "Questo tipo non contiene membri.",
name: "Nome",
type: "Tipo",
nullable: "Ammette NULL",
default: "Valore predefinito",
comment: "Commento",
},
properties: {
empty: "Nessun attributo disponibile.",
baseType: "Tipo base",
notNull: "Non nullo",
default: "Valore predefinito",
collation: "Regole di confronto",
domainConstraint: "Vincolo",
rangeSubtype: "Sottotipo dell'intervallo",
rangeMultirange: "Tipo intervallo multiplo",
},
ddl: {
empty: "Nessun DDL disponibile per questo tipo.",
incomplete: "Il DDL generato è incompleto e non è stato copiato. Apri i dettagli del tipo per controllare gli avvisi.",
},
},
app: {
name: "DBX",
},
@@ -2520,6 +2557,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabella "{name}" eliminata',
editView: "Modifica Vista",
viewSource: "Visualizza Origine",
viewDetails: "Visualizza dettagli",
viewDdl: "Visualizza DDL",
viewDdlLoading: "Caricamento DDL...",
ddlCopied: "DDL copiato",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "基本型",
composite: "複合型",
domain: "ドメイン",
enum: "列挙型",
range: "範囲型",
multirange: "多重範囲型",
},
tabs: {
members: "メンバー",
properties: "プロパティ",
ddl: "DDL",
},
members: {
empty: "この型にはメンバーがありません。",
name: "名前",
type: "型",
nullable: "NULL 許可",
default: "デフォルト",
comment: "コメント",
},
properties: {
empty: "利用可能な属性がありません。",
baseType: "基底型",
notNull: "NOT NULL",
default: "デフォルト",
collation: "照合順序",
domainConstraint: "制約",
rangeSubtype: "範囲のサブタイプ",
rangeMultirange: "多重範囲型",
},
ddl: {
empty: "この型で利用可能な DDL はありません。",
incomplete: "生成された DDL は不完全なためコピーされませんでした。型の詳細で警告を確認してください。",
},
},
app: {
name: "DBX",
},
@@ -2545,6 +2582,7 @@ export default withEnglishFallback({
dropTableSuccess: "テーブル「{name}」をドロップ(削除)しました",
editView: "ビューを編集",
viewSource: "ソースを表示",
viewDetails: "詳細を表示",
viewDdl: "DDLを表示",
dropObject: "オブジェクトを削除",
dropView: "ビューを削除",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "기본형",
composite: "복합형",
domain: "도메인",
enum: "열거형",
range: "범위형",
multirange: "다중 범위형",
},
tabs: {
members: "멤버",
properties: "속성",
ddl: "DDL",
},
members: {
empty: "이 형식에는 멤버가 없습니다.",
name: "이름",
type: "형식",
nullable: "NULL 허용",
default: "기본값",
comment: "설명",
},
properties: {
empty: "사용 가능한 속성이 없습니다.",
baseType: "기본 형식",
notNull: "NULL 불가",
default: "기본값",
collation: "정렬 규칙",
domainConstraint: "제약 조건",
rangeSubtype: "범위 하위 형식",
rangeMultirange: "다중 범위 형식",
},
ddl: {
empty: "이 형식에 사용할 수 있는 DDL이 없습니다.",
incomplete: "생성된 DDL이 불완전하여 복사되지 않았습니다. 형식 세부 정보에서 경고를 확인하세요.",
},
},
app: {
name: "DBX",
},
@@ -2443,6 +2480,7 @@ export default withEnglishFallback({
dropTableSuccess: '테이블 "{name}" 삭제됨',
editView: "뷰 편집",
viewSource: "소스 보기",
viewDetails: "세부 정보 보기",
viewDdl: "DDL 보기",
viewDdlLoading: "DDL을 불러오는 중...",
ddlCopied: "DDL을 복사했습니다",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Composto",
domain: "Domínio",
enum: "Enumeração",
range: "Intervalo",
multirange: "Multi-intervalo",
},
tabs: {
members: "Membros",
properties: "Propriedades",
ddl: "DDL",
},
members: {
empty: "Este tipo não possui membros.",
name: "Nome",
type: "Tipo",
nullable: "Aceita NULL",
default: "Valor padrão",
comment: "Comentário",
},
properties: {
empty: "Nenhum atributo disponível.",
baseType: "Tipo base",
notNull: "Não nulo",
default: "Valor padrão",
collation: "Ordenação",
domainConstraint: "Restrição",
rangeSubtype: "Subtipo do intervalo",
rangeMultirange: "Tipo multi-intervalo",
},
ddl: {
empty: "Nenhum DDL disponível para este tipo.",
incomplete: "O DDL gerado está incompleto e não foi copiado. Abra os detalhes do tipo para revisar os avisos.",
},
},
app: {
name: "DBX",
},
@@ -2522,6 +2559,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabela "{name}" removida',
editView: "Editar Visão",
viewSource: "Ver Código-fonte",
viewDetails: "Ver detalhes",
viewDdl: "Ver DDL",
viewDdlLoading: "Carregando DDL...",
ddlCopied: "DDL copiado",
+38
View File
@@ -3,6 +3,43 @@ import docs from "./docs/zh-CN";
import { consul } from "./consulZhCN";
export default withEnglishFallback({
customType: {
kinds: {
base: "基础类型",
composite: "复合类型",
domain: "域",
enum: "枚举",
range: "范围",
multirange: "多范围",
},
tabs: {
members: "成员",
properties: "属性",
ddl: "DDL",
},
members: {
empty: "该类型没有成员。",
name: "名称",
type: "类型",
nullable: "可空",
default: "默认值",
comment: "注释",
},
properties: {
empty: "没有可用属性。",
baseType: "基础类型",
notNull: "非空",
default: "默认值",
collation: "排序规则",
domainConstraint: "约束",
rangeSubtype: "范围子类型",
rangeMultirange: "多范围类型",
},
ddl: {
empty: "该类型没有可用 DDL。",
incomplete: "生成的 DDL 不完整,未复制。请打开类型详情查看警告。",
},
},
app: {
name: "DBX",
},
@@ -2513,6 +2550,7 @@ export default withEnglishFallback({
editView: "编辑视图",
viewSource: "查看源码",
changeOpenMode: "修改打开方式",
viewDetails: "查看详情",
viewDdl: "查看 DDL",
viewDdlLoading: "正在读取 DDL...",
ddlCopied: "DDL 已复制",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "基礎型別",
composite: "複合型別",
domain: "定義域",
enum: "列舉型別",
range: "範圍型別",
multirange: "多重範圍型別",
},
tabs: {
members: "成員",
properties: "屬性",
ddl: "DDL",
},
members: {
empty: "此型別沒有成員。",
name: "名稱",
type: "型別",
nullable: "可為 NULL",
default: "預設值",
comment: "註解",
},
properties: {
empty: "沒有可用的屬性。",
baseType: "基礎型別",
notNull: "不可為 NULL",
default: "預設值",
collation: "定序規則",
domainConstraint: "限制條件",
rangeSubtype: "範圍子型別",
rangeMultirange: "多重範圍型別",
},
ddl: {
empty: "此型別沒有可用的 DDL。",
incomplete: "產生的 DDL 不完整,未複製。請開啟型別詳細資料查看警告。",
},
},
app: {
name: "DBX",
},
@@ -2521,6 +2558,7 @@ export default withEnglishFallback({
dropTableSuccess: "資料表「{name}」已刪除",
editView: "編輯檢視",
viewSource: "檢視原始碼",
viewDetails: "檢視詳細資料",
viewDdl: "檢視 DDL",
viewDdlLoading: "正在讀取 DDL...",
ddlCopied: "DDL 已複製",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion } from "@/lib/database/databaseObjectCapabilities";
import { customTypeCapabilities, databaseObjectCapabilities, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
describe("databaseObjectCapabilities", () => {
it("exposes supported programmable objects for Dameng", () => {
@@ -39,4 +39,41 @@ describe("databaseObjectCapabilities", () => {
it("normalizes space separated materialized view types", () => {
expect(normalizeSidebarObjectKind("MATERIALIZED VIEW")).toBe("MATERIALIZED_VIEW");
});
it("exposes TYPE for verified PostgreSQL-family databases only", () => {
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(sidebarObjectKindsForDatabase(dbType), dbType).toContain("TYPE");
}
// Unverified PG-like databases must not advertise TYPE this cycle.
for (const dbType of ["highgo", "uxdb", "redshift", "kwdb"] as const) {
expect(sidebarObjectKindsForDatabase(dbType), dbType).not.toContain("TYPE");
}
});
it("only Xugu TYPE nodes can open object source", () => {
expect(supportsTypeObjectSource("xugu")).toBe(true);
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase", undefined] as const) {
expect(supportsTypeObjectSource(dbType), String(dbType)).toBe(false);
}
});
it("keeps sourceReadable in sync with supportsTypeObjectSource", () => {
expect(databaseObjectCapabilities("xugu").sourceReadable).toContain("TYPE");
expect(databaseObjectCapabilities("xugu").sourceReadable).toContain("TYPE_BODY");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).not.toContain("TYPE");
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).not.toContain("TYPE_BODY");
// Non-type programmable objects stay source-readable on PG-family.
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).toContain("FUNCTION");
}
});
it("enables custom type details only for verified PG-family databases", () => {
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(customTypeCapabilities(dbType), dbType).toEqual({ details: true, members: true, ddl: true });
}
for (const dbType of ["xugu", "highgo", "uxdb", "redshift", "mysql", undefined] as const) {
expect(customTypeCapabilities(dbType), String(dbType)).toEqual({ details: false, members: false, ddl: false });
}
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import type { CustomTypeDetails, TreeNode } from "@/types/database";
const parent: TreeNode = {
id: "c:db:app:types:status",
label: "status",
type: "type",
connectionId: "c",
database: "db",
schema: "app",
};
function details(overrides: Partial<CustomTypeDetails>): CustomTypeDetails {
return {
name: "status",
schema: "app",
kind: "enum",
members: [],
properties: { domainConstraints: [] },
...overrides,
};
}
describe("custom type sidebar tree", () => {
it("builds composite fields with their data types", () => {
const children = buildCustomTypeTreeChildren(parent, details({ kind: "composite", members: [{ name: "city", dataType: "text", ordinal: 1 }] }));
expect(children).toHaveLength(1);
expect(children[0]).toMatchObject({ id: "c:db:app:types:status:type-member:field:city", label: "city", type: "type-member", meta: { kind: "field", displayValue: "text", ordinal: 1 } });
});
it("builds enum values in backend order", () => {
const children = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
}),
);
expect(children.map((child) => child.label)).toEqual(["draft", "published"]);
expect(children.map((child) => child.id)).toEqual(["c:db:app:types:status:type-member:enum-value:draft", "c:db:app:types:status:type-member:enum-value:published"]);
});
it("keeps existing member IDs stable when a new member is inserted", () => {
const before = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
}),
);
const after = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "new" },
{ name: "", dataType: "", ordinal: 2, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 3, enumValue: "published" },
],
}),
);
expect(after.find((child) => child.label === "draft")?.id).toBe(before.find((child) => child.label === "draft")?.id);
expect(after.find((child) => child.label === "published")?.id).toBe(before.find((child) => child.label === "published")?.id);
});
it("encodes member identity characters in IDs", () => {
const children = buildCustomTypeTreeChildren(parent, details({ members: [{ name: "", dataType: "text", ordinal: 1, enumValue: "a:b/c" }] }));
expect(children[0].id).toContain("a%3Ab%2Fc");
});
it.each(["domain", "range", "multirange", "base"] as const)("does not add property children for %s types", (kind) => {
expect(
buildCustomTypeTreeChildren(
parent,
details({
kind,
properties: {
baseType: "text",
rangeSubtype: "numeric",
domainConstraints: [{ name: "value_check", definition: "CHECK (VALUE IS NOT NULL)" }],
},
}),
),
).toEqual([]);
});
});
@@ -40,6 +40,13 @@ describe("sidebar tree item layout", () => {
expect(canTreeNodeShowExpander({ type: "type-body", childCount: 0, explicitContainer: true })).toBe(false);
});
it("shows an expander only while a custom type may still load children", () => {
expect(canTreeNodeShowExpander({ type: "type", childCount: undefined })).toBe(true);
expect(canTreeNodeShowExpander({ type: "type", childCount: 2 })).toBe(true);
expect(canTreeNodeShowExpander({ type: "type", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "type-member", childCount: undefined })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
describe("treeNodeClick", () => {
it("opens synonym nodes as synonym source", () => {
@@ -7,6 +7,32 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
});
it("only Xugu type nodes open source on single click", () => {
expect(treeNodeRowAction("type", false, "single", "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(treeNodeRowAction("type", true, "single", dbType), String(dbType)).toBe("toggle");
expect(treeNodeRowAction("type", false, "single", dbType), String(dbType)).toBe("none");
}
// Unknown connection type keeps the conservative no-action behavior.
expect(treeNodeRowAction("type", false, "single", undefined)).toBe("none");
});
it("only Xugu type nodes open source on double click", () => {
expect(treeNodeRowDoubleClickAction("type", false, "double", false, "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(treeNodeRowDoubleClickAction("type", false, "double", true, dbType), String(dbType)).toBe("toggle");
expect(treeNodeRowDoubleClickAction("type", false, "double", false, dbType), String(dbType)).toBe("none");
}
expect(treeNodeRowDoubleClickAction("type", false, "double", false, undefined)).toBe("none");
});
it("keeps source actions for non-type source nodes on PG-family databases", () => {
for (const type of ["procedure", "function", "trigger", "sequence", "package", "package-body"] as const) {
expect(treeNodeRowAction(type, false, "single", "postgres"), type).toBe("open-source");
}
expect(treeNodeRowAction("type-body", false, "single", "xugu")).toBe("open-source");
});
it("opens Consul navigation entries on one click regardless of object activation preference", () => {
expect(isDirectNavigationTreeNode("consul-root")).toBe(true);
expect(isDirectNavigationTreeNode("consul-overview")).toBe(true);
@@ -23,9 +49,9 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("package", false)).toBe("open-source");
});
it("toggles an expandable type node before opening its source", () => {
expect(treeNodeRowAction("type", true)).toBe("toggle");
expect(treeNodeRowAction("type", false)).toBe("open-source");
it("toggles an expandable Xugu type node before opening its source", () => {
expect(treeNodeRowAction("type", true, "single", "xugu")).toBe("toggle");
expect(treeNodeRowAction("type", false, "single", "xugu")).toBe("open-source");
});
it("routes package members to their owning package body", () => {
@@ -39,8 +39,30 @@ describe("singleClickRowAction", () => {
expect(singleClickRowAction(row("PACKAGE_BODY", "pkg_body_test"))).toBe("open-source");
});
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("returns open-source for %s", (type) => {
expect(singleClickRowAction(row(type, "programmable_test"))).toBe("open-source");
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("returns open-source for %s on Xugu", (type) => {
expect(singleClickRowAction(row(type, "programmable_test"), "xugu")).toBe("open-source");
});
it("only Xugu TYPE rows open source", () => {
expect(singleClickRowAction(row("TYPE", "app_status"), "xugu")).toBe("open-source");
expect(doubleClickRowAction(row("TYPE", "app_status"), "xugu")).toBe("open-source");
expect(singleClickRowAction(row("TYPE_BODY", "app_status"), "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
// Verified PG-family TYPE rows open the read-only details panel.
expect(singleClickRowAction(row("TYPE", "app_status"), dbType), String(dbType)).toBe("type-info");
expect(doubleClickRowAction(row("TYPE", "app_status"), dbType), String(dbType)).toBe("type-info");
// TYPE_BODY has no backend getter on these databases.
expect(singleClickRowAction(row("TYPE_BODY", "app_status"), dbType), String(dbType)).toBe("none");
}
// Unknown connection type keeps the conservative no-action behavior.
expect(singleClickRowAction(row("TYPE", "app_status"), undefined)).toBe("none");
expect(doubleClickRowAction(row("TYPE", "app_status"), undefined)).toBe("none");
});
it("keeps source actions for non-type rows on PG-family databases", () => {
for (const type of ["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"] as const) {
expect(singleClickRowAction(row(type), "postgres"), type).toBe("open-source");
}
});
it("returns none for null/undefined", () => {
+37
View File
@@ -95,6 +95,27 @@ describe("PostgreSQL overloaded routines", () => {
});
});
describe("PostgreSQL custom type metadata", () => {
it("preserves type kind and member capability in simple and grouped trees", () => {
const objects: ObjectInfo[] = [
{ name: "address", object_type: "TYPE", schema: "public", custom_type_kind: "composite", has_members: true },
{ name: "email", object_type: "TYPE", schema: "public", custom_type_kind: "domain", has_members: false },
];
const simple = buildSimpleObjectTreeNodes({ ...context, schema: "public", objects });
expect(simple.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "public", objects });
expect(grouped.find((node) => node.type === "group-types")?.children?.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
});
});
describe("programmable database objects", () => {
it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => {
const objects: ObjectInfo[] = [
@@ -156,6 +177,22 @@ describe("programmable database objects", () => {
expect(synonymGroup).toEqual(expect.objectContaining({ objectCount: 1, label: "tree.synonyms" }));
expect(synonymGroup?.children).toEqual([expect.objectContaining({ type: "synonym", objectName: "SYN_SHOP_USERS", valid: true })]);
});
it("groups user-defined types with an object count", () => {
const objects: ObjectInfo[] = [
{ name: "status", object_type: "TYPE", schema: "app", comment: "order status" },
{ name: "email", object_type: "TYPE", schema: "app" },
];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "app", objects });
const typeGroup = groups.find((node) => node.type === "group-types");
expect(typeGroup).toEqual(expect.objectContaining({ objectCount: 2, label: "tree.types" }));
expect(typeGroup?.children?.map((node) => node.type)).toEqual(["type", "type"]);
expect(typeGroup?.children?.map((node) => node.objectName)).toEqual(["email", "status"]);
expect(typeGroup?.children?.[0]?.comment).toBeUndefined();
expect(typeGroup?.children?.[1]?.comment).toBe("order status");
});
});
describe("PostgreSQL table hierarchy", () => {
+1
View File
@@ -157,6 +157,7 @@ export const listObjectStatistics = forward("listObjectStatistics");
export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
export const getCustomTypeDetails = forward("getCustomTypeDetails");
export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns");
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
+5
View File
@@ -14,6 +14,7 @@ import type {
CompletionAssistantRequest,
CompletionAssistantResponse,
ObjectStatistics,
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
@@ -742,6 +743,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`);
}
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`);
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`);
}
+4
View File
@@ -27,6 +27,7 @@ import type {
CompletionAssistantRequest,
CompletionAssistantResponse,
ObjectStatistics,
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
@@ -1055,6 +1056,9 @@ export async function listSchemaInfos(connectionId: string, database: string): P
return invoke("list_schema_infos", { connectionId, database });
}
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
return invoke("get_custom_type_details", { connectionId, database, schema, name });
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return invoke("get_columns", {
connectionId,
@@ -13,7 +13,21 @@ const TABLE_VIEW_MV_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZ
const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"];
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
// PostgreSQL-family databases with a verified pg_type listing path. TYPE only
// covers user-created types (enum/domain/composite/range/multirange/base);
// relation auto-generated row types stay hidden.
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "TYPE"];
// KWDB is routed through the PostgreSQL pool but its pg_type catalog
// compatibility is not verified yet, so it stays on the pre-TYPE object set.
const POSTGRES_NO_TYPE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
// Kingbase and Vastbase agents support the same user-defined type listing via
// their own metadata query, but do not expose sequences. Kept separate from
// POSTGRES_OBJECTS and POSTGRES_LIKE_OBJECTS so unverified PG-like databases
// (highgo/uxdb/redshift) never advertise TYPE.
const KINGBASE_VASTBASE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TYPE"];
const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"];
const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
@@ -24,13 +38,13 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
// postgres
["postgres", POSTGRES_OBJECTS],
["gaussdb", POSTGRES_OBJECTS],
["kwdb", POSTGRES_OBJECTS],
["kwdb", POSTGRES_NO_TYPE_OBJECTS],
["opengauss", POSTGRES_OBJECTS],
// postgres like
["kingbase", POSTGRES_LIKE_OBJECTS],
["kingbase", KINGBASE_VASTBASE_OBJECTS],
["highgo", POSTGRES_LIKE_OBJECTS],
["uxdb", POSTGRES_LIKE_OBJECTS],
["vastbase", POSTGRES_LIKE_OBJECTS],
["vastbase", KINGBASE_VASTBASE_OBJECTS],
["redshift", POSTGRES_LIKE_OBJECTS],
// oracle
["oracle", ORACLE_OBJECTS],
@@ -69,11 +83,22 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["manticoresearch", ["TABLE", "FUNCTION"]],
["databend", ["TABLE", "VIEW", "PROCEDURE"]],
]);
/**
* Whether a kind is readable as object source for the given connection type.
* TYPE/TYPE_BODY only have a real source implementation on Xugu; PostgreSQL-
* family databases list types without a CREATE TYPE getter this cycle.
*/
function isSourceReadableObjectKind(kind: SidebarObjectKind, dbType?: DatabaseType): boolean {
if (kind === "TABLE") return false;
if (kind === "TYPE" || kind === "TYPE_BODY") return supportsTypeObjectSource(dbType);
return true;
}
export function databaseObjectCapabilities(dbType?: DatabaseType): DatabaseObjectCapabilities {
const sidebarObjects = sidebarObjectKindsForDatabase(dbType);
return {
sidebarObjects,
sourceReadable: sidebarObjects.filter((kind) => kind !== "TABLE"),
sourceReadable: sidebarObjects.filter((kind) => isSourceReadableObjectKind(kind, dbType)),
executable: sidebarObjects.filter((kind) => kind === "PROCEDURE"),
};
}
@@ -83,6 +108,37 @@ export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObj
return DATABASE_TYPE_OBJECTS.get(dbType) ?? [...ROUTINE_OBJECTS];
}
/**
* Whether a connection's TYPE tree nodes may be opened as object source.
*
* Xugu has a real TYPE/TYPE_BODY source implementation. PostgreSQL-family
* databases only list user-defined types this cycle; their CREATE TYPE DDL has
* no unified catalog getter, so opening source would error. Callers must gate
* the source action (single/double click, context menu, shortcuts) on this
* before dispatching getObjectSource.
*/
export function supportsTypeObjectSource(dbType?: DatabaseType): boolean {
return dbType === "xugu";
}
export type CustomTypeCapabilities = {
details: boolean;
members: boolean;
ddl: boolean;
};
const VERIFIED_CUSTOM_TYPE_DATABASES = new Set<DatabaseType>(["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"]);
/**
* Whether a connection may open read-only custom type details (phase 2).
* Kept separate from the listing capability so a future per-kind DDL toggle
* can be introduced without touching the object-list sets.
*/
export function customTypeCapabilities(dbType?: DatabaseType): CustomTypeCapabilities {
const supported = !!dbType && VERIFIED_CUSTOM_TYPE_DATABASES.has(dbType);
return { details: supported, members: supported, ddl: supported };
}
export function supportsPackageMemberExpansion(dbType?: DatabaseType): boolean {
return !!dbType && PACKAGE_MEMBER_EXPANSION_DATABASES.has(dbType);
}
@@ -0,0 +1,42 @@
import type { CustomTypeDetails, CustomTypeTreeMemberMeta, TreeNode } from "@/types/database";
function encodeMemberIdentity(value: string): string {
return encodeURIComponent(value);
}
function typeMemberNode(parent: TreeNode, identity: string, label: string, meta: CustomTypeTreeMemberMeta): TreeNode {
return {
id: `${parent.id}:type-member:${meta.kind}:${encodeMemberIdentity(identity)}`,
label,
type: "type-member",
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
catalog: parent.catalog,
objectName: parent.objectName || parent.label,
meta,
};
}
export function buildCustomTypeTreeChildren(parent: TreeNode, details: CustomTypeDetails): TreeNode[] {
if (details.kind === "composite") {
return details.members.map((member) =>
typeMemberNode(parent, member.name, member.name, {
kind: "field",
displayValue: member.dataType,
ordinal: member.ordinal,
}),
);
}
if (details.kind === "enum") {
return details.members.map((member) =>
typeMemberNode(parent, member.enumValue || member.name, member.enumValue || member.name, {
kind: "enum-value",
ordinal: member.ordinal,
}),
);
}
return [];
}
@@ -10,8 +10,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"synonym",
"package",
"package-body",
"type",
"type-body",
"type-member",
"object-browser",
"redis-db",
"mq-tenant",
@@ -34,7 +34,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder", "type"]);
const pinnableTypes: Set<TreeNodeType> = new Set([
"connection-group",
@@ -157,7 +157,11 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean {
export function canTreeNodeShowExpander({ type, childCount, explicitContainer = false }: { type: TreeNodeType; childCount?: number; explicitContainer?: boolean }): boolean {
if (!canTreeNodeExpand(type) && !((type === "package" || type === "type") && explicitContainer)) return false;
if (childCount === 0 && emptyContainerTypes.has(type)) return false;
// An empty type node only hides its expander when it is not an explicitly
// marked container. Xugu TYPE specifications use explicitContainer to lazy
// load members even when childCount is currently 0; PostgreSQL-family types
// without members rely on the caller's hasMembers gate instead.
if (childCount === 0 && emptyContainerTypes.has(type) && !explicitContainer) return false;
return true;
}
+20 -5
View File
@@ -1,4 +1,5 @@
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import type { DatabaseType, ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none";
@@ -88,12 +89,25 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean {
return documentBrowserNodeTypes.has(type);
}
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction {
/**
* Whether a tree node type may open an object source dialog for the given
* connection type. TYPE/TYPE_BODY only have a real source implementation on
* Xugu; other databases list types without a DDL getter this cycle.
*/
function canOpenTreeNodeSource(type: TreeNodeType, dbType?: DatabaseType): boolean {
if (type === "type" || type === "type-body") return supportsTypeObjectSource(dbType);
return true;
}
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single", dbType?: DatabaseType): TreeNodeRowAction {
if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none";
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
// PostgreSQL-family custom types: open read-only details (toggle when expandable).
if (type === "type" && customTypeCapabilities(dbType).details) return canExpand ? "toggle" : "none";
// Xugu and other databases: expandable package/type nodes toggle their members.
if ((type === "package" || type === "type") && canExpand) return "toggle";
if (sourceNodeTypes.has(type)) return "open-source";
if (sourceNodeTypes.has(type) && canOpenTreeNodeSource(type, dbType)) return "open-source";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canExpand) return "toggle";
return "none";
@@ -106,14 +120,15 @@ export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetai
return action !== "none" && clickDetail <= 1;
}
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false): TreeNodeRowDoubleClickAction {
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType): TreeNodeRowDoubleClickAction {
// Single-click activation already handles the first click in a dblclick
// sequence. Only double-click activation needs a second-stage table action.
if (type === "table") return activation === "double" ? "activate-data" : "none";
if (activation === "double") {
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
if (sourceNodeTypes.has(type)) return "open-source";
if (type === "type" && customTypeCapabilities(dbType).details) return canExpand ? "toggle" : "none";
if (sourceNodeTypes.has(type) && canOpenTreeNodeSource(type, dbType)) return "open-source";
if (savedSqlNodeTypes.has(type)) return "open-saved-sql";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canOpenObjectBrowser && objectBrowserNodeTypes.has(type) && canExpand) return "open-object-browser-and-expand";
@@ -1,17 +1,24 @@
import type { DatabaseType } from "@/types/database";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
export type ObjectBrowserRowAction = "table-info" | "open-table" | "open-source" | "none";
export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" | "open-source" | "none";
/**
* Determine the action for a single click on an object browser row.
* - TABLE → table-info (show table properties panel)
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source
* - TYPE → type-info (read-only type details) on verified PG-family databases
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE_BODY → open-source
* - otherwise → none
*
* TYPE/TYPE_BODY only open source for connections with a real type source
* implementation (Xugu); other databases list types without a DDL getter.
*/
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "table-info";
if (canOpenSource(row)) return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
}
@@ -21,10 +28,11 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined):
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source
* - otherwise → none
*/
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "open-table";
if (canOpenSource(row)) return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
}
@@ -38,14 +46,14 @@ export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined):
* the caller defers the single-click via shouldDeferSingleClick so the second
* click can cancel it.
*/
export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, detail: number, activation: "single" | "double"): { action: ObjectBrowserRowAction; isDouble: boolean } {
export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, detail: number, activation: "single" | "double", dbType?: DatabaseType): { action: ObjectBrowserRowAction; isDouble: boolean } {
if (activation === "double") {
if (detail === 2) return { action: doubleClickRowAction(row), isDouble: true };
return { action: singleClickRowAction(row), isDouble: false };
if (detail === 2) return { action: doubleClickRowAction(row, dbType), isDouble: true };
return { action: singleClickRowAction(row, dbType), isDouble: false };
}
// single-click activation
if (detail > 1) return { action: doubleClickRowAction(row), isDouble: true };
return { action: singleClickRowAction(row), isDouble: false };
if (detail > 1) return { action: doubleClickRowAction(row, dbType), isDouble: true };
return { action: singleClickRowAction(row, dbType), isDouble: false };
}
/**
@@ -70,6 +78,10 @@ export function isSourceOnlyObjectBrowserRow(row: ObjectBrowserRow): boolean {
return row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
function canOpenSource(row: ObjectBrowserRow): boolean {
function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Verified PG-family TYPE rows open the read-only details panel instead;
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false;
if ((row.type === "TYPE" || row.type === "TYPE_BODY") && !supportsTypeObjectSource(dbType)) return false;
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
+4
View File
@@ -585,6 +585,8 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch
type: simpleNodeType,
objectName: name,
signature: signature || undefined,
customTypeKind: obj.custom_type_kind ?? undefined,
hasMembers: obj.has_members ?? undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined,
@@ -760,6 +762,8 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc
type: childType,
objectName: obj.name,
signature: signature || undefined,
customTypeKind: obj.custom_type_kind ?? undefined,
hasMembers: obj.has_members ?? undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined,
@@ -583,7 +583,7 @@ describe("connectionStore metadata loading", () => {
});
it("keeps concurrent table-tree and local-index refreshes in separate cache entries", async () => {
const treeCacheKey = "pg-1:app:public:group-tables:objects-v6";
const treeCacheKey = "pg-1:app:public:group-tables:objects-v8";
const indexCacheKey = `${treeCacheKey}:table-search-index-v1`;
const cachedPayloads = new Map<string, unknown>([
[
@@ -2565,7 +2565,7 @@ describe("connectionStore metadata loading", () => {
const loadPromise = store.loadTables(connection.id, "prulife", "xtdpcky", { force: true });
await vi.waitFor(() => expect(saveSchemaCache).toHaveBeenCalledTimes(1));
expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v7-informix-owner-v2`);
expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v8-informix-owner-v2`);
expect(schemaNode.isLoading).toBe(true);
schemaNode.isExpanded = false;
@@ -2988,4 +2988,75 @@ describe("connectionStore metadata loading", () => {
expect(replacementDb.children?.length ?? 0).toBeGreaterThan(0);
expect(store.isTreeNodeChildrenLoaded(dbId)).toBe(true);
});
it("loads custom type members into the current tree node and ignores stale responses", async () => {
let resolveFirst!: (value: any) => void;
const first = new Promise((resolve) => {
resolveFirst = resolve;
});
const getCustomTypeDetails = vi
.fn()
.mockReturnValueOnce(first)
.mockResolvedValueOnce({
name: "status",
schema: "app",
kind: "enum",
members: [{ name: "", dataType: "", ordinal: 1, enumValue: "published" }],
properties: { domainConstraints: [] },
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
getCustomTypeDetails,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection();
const typeNode: TreeNode = {
id: `${connection.id}:app:types:status`,
label: "status",
objectName: "status",
type: "type",
connectionId: connection.id,
database: "app",
schema: "app",
children: undefined,
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
children: [typeNode],
},
];
const staleLoad = store.loadCustomTypeChildren(typeNode, { force: true });
await vi.waitFor(() => expect(getCustomTypeDetails).toHaveBeenCalledTimes(1));
await store.loadCustomTypeChildren(typeNode, { force: true });
resolveFirst({
name: "status",
schema: "app",
kind: "enum",
members: [{ name: "", dataType: "", ordinal: 1, enumValue: "draft" }],
properties: { domainConstraints: [] },
});
await staleLoad;
expect(typeNode.isExpanded).toBe(true);
expect(typeNode.customTypeKind).toBe("enum");
expect(typeNode.hasMembers).toBe(true);
expect(typeNode.children?.map((child) => child.label)).toEqual(["published"]);
expect(store.isTreeNodeChildrenLoaded(typeNode.id)).toBe(true);
});
});
+36 -6
View File
@@ -121,6 +121,7 @@ import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"
import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
@@ -1193,7 +1194,7 @@ export const useConnectionStore = defineStore("connection", () => {
// recurse into raw objects, its `meta` too), mirroring the markRaw() treatment
// queryStore already applies to result rows. Containers stay reactive so their
// children / isExpanded / isLoading mutations still drive the UI.
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger"]);
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger", "type-member"]);
function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] {
for (const node of nodes) {
@@ -1437,7 +1438,9 @@ export const useConnectionStore = defineStore("connection", () => {
function objectGroupCacheKey(node: TreeNode): string {
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v6");
// objects-v8: object-group listing SQL gained a pg_type branch for
// PostgreSQL-family user-defined types; older cached lists miss TYPE nodes.
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v8");
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion);
}
@@ -1965,7 +1968,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8");
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion);
}
@@ -4272,7 +4275,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v6"));
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v8"));
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined);
return;
@@ -4305,7 +4308,7 @@ export const useConnectionStore = defineStore("connection", () => {
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const searchFilter = activeTreeLoadSearchFilter(options);
const config = getConfig(connectionId);
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8");
const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion);
const querySchema = connectionObjectTreeQuerySchema(config, database, schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema);
@@ -4514,6 +4517,30 @@ export const useConnectionStore = defineStore("connection", () => {
);
}
async function loadCustomTypeChildren(node: TreeNode, options?: LoadTreeOptions) {
if (node.type !== "type" || !node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(node.connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const schema = node.schema || node.database;
const details = await api.getCustomTypeDetails(node.connectionId, node.database, schema, node.objectName || node.label);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildCustomTypeTreeChildren(targetNode, details);
targetNode.customTypeKind = details.kind;
targetNode.hasMembers = children.length > 0;
setChildren(targetNode, children);
targetNode.isExpanded = children.length > 0;
} catch (error) {
recordMetadataLoadError(node.connectionId, error, load);
throw error;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadMoreObjectGroupChildren(node: TreeNode) {
if (node.type !== "load-more" || !node.loadMore) return;
const loadMore = node.loadMore;
@@ -4567,7 +4594,7 @@ export const useConnectionStore = defineStore("connection", () => {
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v6")), nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren);
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
return;
@@ -5356,6 +5383,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadTables(node.connectionId, node.database, node.schema, options);
} else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) {
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog);
} else if (node.type === "type") {
await loadCustomTypeChildren(node, options);
} else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
@@ -7347,6 +7376,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadTables,
loadTableForLocate,
loadObjectGroupChildren,
loadCustomTypeChildren,
loadPackageMembers,
loadXuguTypeMembers,
loadMoreObjectGroupChildren,
+67 -1
View File
@@ -429,6 +429,8 @@ export interface ObjectInfo {
schema?: string | null;
valid?: boolean | null;
signature?: string | null;
custom_type_kind?: CustomTypeKind | null;
has_members?: boolean | null;
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
@@ -455,6 +457,61 @@ export interface ObjectSource {
editable?: boolean;
}
export type CustomTypeKind = "base" | "composite" | "domain" | "enum" | "range" | "multirange";
export interface CustomTypeMember {
name: string;
dataType: string;
ordinal: number;
nullable?: boolean | null;
default?: string | null;
comment?: string | null;
enumValue?: string | null;
}
export interface CustomTypeDomainConstraint {
name: string;
definition: string;
}
export interface CustomTypeProperties {
baseType?: string | null;
notNull?: boolean | null;
default?: string | null;
collation?: string | null;
domainConstraints: CustomTypeDomainConstraint[];
rangeSubtype?: string | null;
rangeMultirangeName?: string | null;
rangeCanonicalFunction?: string | null;
rangeSubtypeDiffFunction?: string | null;
rangeSubtypeOpclass?: string | null;
inputFunction?: string | null;
outputFunction?: string | null;
receiveFunction?: string | null;
sendFunction?: string | null;
analyzeFunction?: string | null;
internallength?: number | null;
passedByValue?: boolean | null;
alignment?: string | null;
storage?: string | null;
}
export interface CustomTypeDdl {
sql: string;
complete: boolean;
warnings?: string[];
}
export interface CustomTypeDetails {
name: string;
schema: string;
kind: CustomTypeKind;
comment?: string | null;
members: CustomTypeMember[];
properties: CustomTypeProperties;
ddl?: CustomTypeDdl | null;
}
export interface ColumnInfo {
name: string;
data_type: string;
@@ -807,6 +864,7 @@ export type TreeNodeType =
| "function"
| "type"
| "type-body"
| "type-member"
| "sequence"
| "synonym"
| "package"
@@ -902,6 +960,8 @@ export interface TreeNode {
tableName?: string;
objectName?: string;
signature?: string;
customTypeKind?: CustomTypeKind;
hasMembers?: boolean;
/** Owning programmable object for a nested metadata member. */
parentName?: string;
parentSchema?: string;
@@ -921,7 +981,7 @@ export interface TreeNode {
tableSearchParentId?: string;
savedSqlId?: string;
savedSqlFolderId?: string;
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta;
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta | CustomTypeTreeMemberMeta;
loadMore?: {
parentId: string;
offset: number;
@@ -929,6 +989,12 @@ export interface TreeNode {
};
}
export interface CustomTypeTreeMemberMeta {
kind: "field" | "enum-value";
displayValue?: string;
ordinal?: number;
}
export interface TableNameFilter {
includePatterns: string[];
excludePatterns: string[];
+2
View File
@@ -2532,6 +2532,8 @@ mod tests {
schema: Some("public".to_string()),
valid: None,
signature: Some(signature.to_string()),
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
+21
View File
@@ -1127,6 +1127,7 @@ pub enum AgentMethod {
CompletionAssistantSearchV1,
GetObjectSource,
GetColumns,
GetCustomTypeDetails,
ListIndexes,
ListForeignKeys,
ListTriggers,
@@ -1208,6 +1209,7 @@ impl AgentMethod {
Self::GetObjectSource => "get_object_source",
Self::GetTableDdl => "get_table_ddl",
Self::GetColumns => "get_columns",
Self::GetCustomTypeDetails => "get_type_details",
Self::ListIndexes => "list_indexes",
Self::ListForeignKeys => "list_foreign_keys",
Self::ListTriggers => "list_triggers",
@@ -2060,6 +2062,21 @@ impl AgentDriverClient {
.await
}
pub async fn get_custom_type_details<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
schema: &str,
name: &str,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
self.call_method_with_timeout(
AgentMethod::GetCustomTypeDetails,
agent_type_details_params(database, schema, name),
timeout_duration,
)
.await
}
pub async fn get_table_comment<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
@@ -2756,6 +2773,10 @@ pub fn agent_object_source_params<K: Serialize>(database: &str, schema: &str, na
serde_json::json!({ "database": database, "schema": schema, "name": name, "object_type": object_type })
}
pub fn agent_type_details_params(database: &str, schema: &str, name: &str) -> Value {
serde_json::json!({ "database": database, "schema": schema, "name": name })
}
pub fn agent_close_query_session_params(session_id: &str) -> Value {
serde_json::json!({ "sessionId": session_id })
}
+1 -1
View File
@@ -67,7 +67,7 @@ pub async fn list_tables_filtered(
}
pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
let mut objects = db::postgres::list_objects(pool, schema).await?;
let mut objects = db::postgres::list_objects(pool, schema, true, true, false).await?;
let names = objects.iter().map(|object| object.name.clone()).collect::<Vec<_>>();
let external_names = external_table_names(pool, schema, &names).await.unwrap_or_else(|error| {
log::debug!("[cloudberry][list_objects:external-table-fallback] error={error}");
@@ -42,6 +42,8 @@ pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result<Vec<Object
schema: Some(database.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: table.comment,
created_at: None,
updated_at: None,
@@ -105,6 +107,8 @@ fn plugin_object(
schema: Some(database.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: if comment_parts.is_empty() { None } else { Some(comment_parts.join(", ")) },
created_at: None,
updated_at: None,
+6
View File
@@ -2952,6 +2952,8 @@ fn row_to_object(row: &mysql_async::Row, database: &str) -> ObjectInfo {
schema: Some(database.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: get_opt_str(row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
@@ -3226,6 +3228,8 @@ fn table_infos_to_objects(
schema: Some(database.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: table.comment,
created_at: meta.and_then(|meta| meta.created_at.clone()),
updated_at: meta.and_then(|meta| meta.updated_at.clone()),
@@ -5491,6 +5495,8 @@ mod tests {
schema: Some("app".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
+2
View File
@@ -126,6 +126,8 @@ pub async fn list_objects(pool: &mysql_async::Pool, schema: &str) -> Result<Vec<
.or_else(|| (!schema.trim().is_empty()).then(|| schema.to_string())),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,8 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>,
schema: None,
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: t.comment.clone(),
created_at: None,
updated_at: None,
+2
View File
@@ -2319,6 +2319,8 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
schema: Some(schema.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: row.get::<&str, _>(4).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
created_at: row.get::<chrono::NaiveDateTime, _>(2).map(|value| value.to_string()),
updated_at: row.get::<chrono::NaiveDateTime, _>(3).map(|value| value.to_string()),
+252 -9
View File
@@ -2383,6 +2383,8 @@ async fn external_driver_presto_like_objects(
schema: Some(schema.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: table.comment,
created_at: None,
updated_at: None,
@@ -2600,6 +2602,10 @@ mod tests {
TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES,
};
use super::{list_databases_core, list_tables_core};
use super::{
object_types_include_custom_types, object_types_include_relations, object_types_include_routines,
object_types_only_custom_types, supports_custom_type_details, supports_pg_custom_type_objects,
};
use crate::connection::{AppState, PoolKind};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::storage::Storage;
@@ -2772,6 +2778,96 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn supports_pg_custom_type_objects_only_for_verified_family() {
for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss, DatabaseType::Gaussdb] {
assert!(supports_pg_custom_type_objects(&test_connection_config(db_type)), "{db_type:?}");
}
for db_type in [
DatabaseType::Kingbase,
DatabaseType::Vastbase,
DatabaseType::Highgo,
DatabaseType::Uxdb,
DatabaseType::Kwdb,
DatabaseType::Redshift,
] {
assert!(!supports_pg_custom_type_objects(&test_connection_config(db_type)), "{db_type:?}");
}
}
#[test]
fn supports_custom_type_details_covers_five_verified_families() {
for db_type in [
DatabaseType::Postgres,
DatabaseType::OpenGauss,
DatabaseType::Gaussdb,
DatabaseType::Kingbase,
DatabaseType::Vastbase,
] {
assert!(supports_custom_type_details(&test_connection_config(db_type)), "{db_type:?}");
}
for db_type in [
DatabaseType::Highgo,
DatabaseType::Uxdb,
DatabaseType::Kwdb,
DatabaseType::Redshift,
DatabaseType::Mysql,
DatabaseType::Xugu,
] {
assert!(!supports_custom_type_details(&test_connection_config(db_type)), "{db_type:?}");
}
}
#[test]
fn object_types_include_custom_types_only_when_unfiltered_or_type_requested() {
assert!(object_types_include_custom_types(None));
assert!(object_types_include_custom_types(Some(&["TYPE".to_string()])));
assert!(object_types_include_custom_types(Some(&["type_body".to_string()])));
assert!(object_types_include_custom_types(Some(&["table".to_string(), "type".to_string()])));
assert!(!object_types_include_custom_types(Some(&["TABLE".to_string()])));
assert!(!object_types_include_custom_types(Some(&["FUNCTION".to_string()])));
}
#[test]
fn object_types_select_independent_catalog_branches() {
assert!(object_types_include_relations(None));
assert!(object_types_include_routines(None));
assert!(object_types_include_custom_types(None));
assert!(object_types_include_relations(Some(&["TABLE".to_string()])));
assert!(object_types_include_relations(Some(&["VIEW".to_string()])));
assert!(object_types_include_relations(Some(&["SEQUENCE".to_string()])));
assert!(!object_types_include_routines(Some(&["TABLE".to_string()])));
assert!(!object_types_include_custom_types(Some(&["TABLE".to_string()])));
assert!(object_types_include_routines(Some(&["PROCEDURE".to_string()])));
assert!(object_types_include_routines(Some(&["FUNCTION".to_string()])));
assert!(!object_types_include_relations(Some(&["FUNCTION".to_string()])));
assert!(!object_types_include_custom_types(Some(&["FUNCTION".to_string()])));
assert!(object_types_include_custom_types(Some(&["TYPE".to_string()])));
assert!(!object_types_include_relations(Some(&["TYPE".to_string()])));
assert!(!object_types_include_routines(Some(&["TYPE".to_string()])));
// The sidebar type group sends the TYPE_BODY companion kind as well;
// it must select the type branch alone, never relations or routines.
let type_group = ["TYPE".to_string(), "TYPE_BODY".to_string()];
assert!(object_types_include_custom_types(Some(&type_group)));
assert!(!object_types_include_relations(Some(&type_group)));
assert!(!object_types_include_routines(Some(&type_group)));
}
#[test]
fn object_types_only_custom_types_detects_dedicated_type_requests() {
assert!(!object_types_only_custom_types(None));
assert!(object_types_only_custom_types(Some(&["TYPE".to_string()])));
assert!(object_types_only_custom_types(Some(&["TYPE".to_string(), "TYPE_BODY".to_string()])));
assert!(object_types_only_custom_types(Some(&["type_body".to_string()])));
assert!(!object_types_only_custom_types(Some(&["TYPE".to_string(), "TABLE".to_string()])));
assert!(!object_types_only_custom_types(Some(&["TABLE".to_string()])));
assert!(!object_types_only_custom_types(Some(&[])));
}
#[test]
fn agent_metadata_uses_unique_ephemeral_sessions_only_for_agents() {
let oracle = test_connection_config(DatabaseType::Oracle);
@@ -3238,6 +3334,8 @@ for line in sys.stdin:
schema: Some("app".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -4138,6 +4236,8 @@ for line in sys.stdin:
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -4152,6 +4252,8 @@ for line in sys.stdin:
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -4166,6 +4268,8 @@ for line in sys.stdin:
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -4770,10 +4874,19 @@ async fn list_objects_once(
return Ok(unpaged_object_list(objects));
}
Ok(objects) => {
if object_types_only_custom_types(object_types) {
// A dedicated type request: the agent is authoritative.
// The native fallback never lists types, so running it
// would turn a real empty schema or a catalog error into
// a misleading empty type group.
return Ok(unpaged_object_list(objects));
}
if let Some(config) = fallback_config.as_ref() {
match native_postgres_metadata_pool(state, connection_id, database, config).await {
Ok(Some(pool)) => {
return db::postgres::list_objects(&pool, schema).await.map(unpaged_object_list)
return db::postgres::list_objects(&pool, schema, true, true, false)
.await
.map(unpaged_object_list)
}
Ok(None) => return Ok(unpaged_object_list(objects)),
Err(error) => {
@@ -4790,18 +4903,25 @@ async fn list_objects_once(
return Ok(unpaged_object_list(objects));
}
Err(agent_error) => {
if object_types_only_custom_types(object_types) {
// Preserve the type catalog error instead of masking it
// with a relation/function fallback that cannot serve
// user-defined types.
return Err(agent_error);
}
if let Some(config) = fallback_config.as_ref() {
if let Some(pool) =
native_postgres_metadata_pool(state, connection_id, database, config).await?
{
return db::postgres::list_objects(&pool, schema).await.map(unpaged_object_list).map_err(
|fallback_error| {
return db::postgres::list_objects(&pool, schema, true, true, false)
.await
.map(unpaged_object_list)
.map_err(|fallback_error| {
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
},
);
});
}
}
return Err(agent_error);
@@ -4836,7 +4956,15 @@ async fn list_objects_once(
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(unpaged_object_list)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(unpaged_object_list),
PoolKind::Postgres(p) => {
let include_relations = object_types_include_relations(object_types);
let include_routines = object_types_include_routines(object_types);
let include_custom_types = db_config.as_ref().is_some_and(supports_pg_custom_type_objects)
&& object_types_include_custom_types(object_types);
db::postgres::list_objects(p, schema, include_relations, include_routines, include_custom_types)
.await
.map(unpaged_object_list)
}
_ => {
drop(connections);
Ok(unpaged_object_list(
@@ -4849,6 +4977,8 @@ async fn list_objects_once(
schema: if schema.is_empty() { None } else { Some(schema.to_string()) },
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: table.comment,
created_at: None,
updated_at: None,
@@ -4905,7 +5035,9 @@ async fn list_completion_objects_once(
if let Some(config) = fallback_config.as_ref() {
match native_postgres_metadata_pool(state, connection_id, database, config).await {
Ok(Some(pool)) => {
return db::postgres::list_objects(&pool, schema).await.map(filter_completion_objects)
return db::postgres::list_objects(&pool, schema, true, true, false)
.await
.map(filter_completion_objects)
}
Ok(None) => objects,
Err(error) => {
@@ -4928,7 +5060,7 @@ async fn list_completion_objects_once(
if let Some(pool) =
native_postgres_metadata_pool(state, connection_id, database, config).await?
{
return db::postgres::list_objects(&pool, schema)
return db::postgres::list_objects(&pool, schema, true, true, false)
.await
.map(filter_completion_objects)
.map_err(|fallback_error| {
@@ -4960,7 +5092,9 @@ async fn list_completion_objects_once(
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(filter_completion_objects)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(filter_completion_objects),
PoolKind::Postgres(p) => {
db::postgres::list_objects(p, schema, true, true, false).await.map(filter_completion_objects)
}
PoolKind::SqlServer(_) => {
drop(connections);
let outcome =
@@ -6237,6 +6371,52 @@ fn is_cloudberry_config(config: &ConnectionConfig) -> bool {
matches!(config.driver_profile.as_deref(), Some("cloudberry"))
}
/// Whether a native PostgreSQL connection should list user-defined types.
///
/// Only databases with a verified `pg_type` catalog contract are enabled.
/// Other PG-protocol connections (Redshift, QuestDB, Cloudberry, KWDB, ...)
/// keep the legacy object list even though they share `PoolKind::Postgres`.
fn supports_pg_custom_type_objects(config: &ConnectionConfig) -> bool {
matches!(config.db_type, DatabaseType::Postgres | DatabaseType::OpenGauss | DatabaseType::Gaussdb)
}
/// Whether a typed object-list request needs the pg_class relation branch.
///
/// `None` means the caller wants the full object list (object browser “all
/// objects” view), so every branch is selected. Group loads only request their
/// own kinds (e.g. `["TABLE"]`), which skips the other catalog scans entirely.
fn object_types_include_relations(object_types: Option<&[String]>) -> bool {
object_types.is_none_or(|types| {
types.iter().any(|t| {
matches!(
t.to_ascii_uppercase().as_str(),
"TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "SEQUENCE" | "FOREIGN_TABLE" | "PARTITIONED_TABLE"
)
})
})
}
fn object_types_include_routines(object_types: Option<&[String]>) -> bool {
object_types
.is_none_or(|types| types.iter().any(|t| matches!(t.to_ascii_uppercase().as_str(), "PROCEDURE" | "FUNCTION")))
}
fn object_types_include_custom_types(object_types: Option<&[String]>) -> bool {
object_types
.is_none_or(|types| types.iter().any(|t| t.eq_ignore_ascii_case("TYPE") || t.eq_ignore_ascii_case("TYPE_BODY")))
}
/// Whether the object-type filter exclusively asks for user-defined types.
///
/// Used to keep agent errors visible: the native PostgreSQL fallback never
/// lists custom types, so running it for a dedicated type request would mask a
/// real catalog failure as an empty type group.
fn object_types_only_custom_types(object_types: Option<&[String]>) -> bool {
object_types.is_some_and(|types| {
!types.is_empty() && types.iter().all(|t| t.eq_ignore_ascii_case("TYPE") || t.eq_ignore_ascii_case("TYPE_BODY"))
})
}
fn is_default_oracle_agent_config(config: &ConnectionConfig) -> bool {
// Only the default go-oracle agent handles filtered/paged metadata; legacy profiles keep Rust fallback paging.
matches!(config.db_type, DatabaseType::Oracle)
@@ -6880,6 +7060,67 @@ async fn read_mysql_object_source_row(
.ok_or_else(|| "Failed to read object source".to_string())
}
/// Whether a connection may serve custom type details (phase 2). Kept
/// separate from listing support so a future per-kind DDL capability can be
/// toggled independently.
fn supports_custom_type_details(config: &ConnectionConfig) -> bool {
matches!(
config.db_type,
DatabaseType::Postgres
| DatabaseType::OpenGauss
| DatabaseType::Gaussdb
| DatabaseType::Kingbase
| DatabaseType::Vastbase
)
}
pub async fn get_custom_type_details_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
name: &str,
) -> Result<db::CustomTypeDetails, String> {
retry_metadata_connection(state, connection_id, Some(database), || {
get_custom_type_details_once(state, connection_id, database, schema, name)
})
.await
}
async fn get_custom_type_details_once(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
name: &str,
) -> Result<db::CustomTypeDetails, String> {
let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
let db_config = connection_config(state, connection_id).await;
let Some(config) = db_config.as_ref() else {
return Err("connection not found".to_string());
};
if !supports_custom_type_details(config) {
return Err(format!("custom type details are not supported for {:?} connections", config.db_type));
}
{
let connections = state.connections.read().await;
if let Some(client) = extract_pool!(&connections, &pool_key, Agent) {
let timeout_duration = agent_metadata_timeout(db_config.as_ref());
drop(connections);
let mut client = client.lock().await;
return client
.get_custom_type_details::<db::CustomTypeDetails>(database, schema, name, timeout_duration)
.await;
}
}
let connections = state.connections.read().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Postgres(p) => db::postgres::get_custom_type_details(p, schema, name).await,
_ => Err("custom type details are not supported for this connection type".to_string()),
}
}
pub async fn get_object_source_core(
state: &AppState,
connection_id: &str,
@@ -7109,6 +7350,8 @@ async fn oracle_agent_list_objects(
schema,
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -239,6 +239,8 @@ mod tests {
schema: None,
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -251,6 +253,8 @@ mod tests {
schema: None,
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -263,6 +267,8 @@ mod tests {
schema: None,
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -315,6 +321,8 @@ mod tests {
schema: Some("HR".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -327,6 +335,8 @@ mod tests {
schema: Some("HR".to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: None,
created_at: None,
updated_at: None,
@@ -97,7 +97,7 @@ pub(in crate::schema) async fn list_objects(
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some),
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema, true, true, false).await.map(Some),
_ => Ok(None),
}
}
@@ -121,7 +121,7 @@ pub(in crate::schema) async fn list_completion_objects(
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some),
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema, true, true, false).await.map(Some),
_ => Ok(None),
}
}
+116
View File
@@ -71,6 +71,10 @@ pub struct ObjectInfo {
pub valid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_type_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_members: Option<bool>,
pub comment: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
@@ -510,6 +514,118 @@ pub struct OwnerInfo {
pub owner: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CustomTypeKind {
Base,
Composite,
Domain,
Enum,
Range,
Multirange,
}
impl CustomTypeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Base => "base",
Self::Composite => "composite",
Self::Domain => "domain",
Self::Enum => "enum",
Self::Range => "range",
Self::Multirange => "multirange",
}
}
}
/// A member of a user-defined type.
///
/// Composite types expose fields (`name`/`data_type`/`ordinal`/`nullable`/
/// `default`/`comment`), enums expose values (`ordinal`/`enum_value`), and
/// domains/ranges/base types expose neither (their members list stays empty;
/// the UI shows an explanatory empty state instead).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTypeMember {
pub name: String,
pub data_type: String,
pub ordinal: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nullable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_value: Option<String>,
}
/// A domain CHECK constraint, keeping its name so generated DDL never invents
/// duplicate constraint identifiers.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTypeDomainConstraint {
pub name: String,
pub definition: String,
}
/// Category-specific type attributes. Fields that do not apply to a category
/// are `None`, never empty strings, so the UI can distinguish “unknown” from
/// “not applicable”.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTypeProperties {
pub base_type: Option<String>,
pub not_null: Option<bool>,
pub default: Option<String>,
pub collation: Option<String>,
#[serde(default)]
pub domain_constraints: Vec<CustomTypeDomainConstraint>,
pub range_subtype: Option<String>,
pub range_multirange_name: Option<String>,
pub range_canonical_function: Option<String>,
pub range_subtype_diff_function: Option<String>,
pub range_subtype_opclass: Option<String>,
pub input_function: Option<String>,
pub output_function: Option<String>,
pub receive_function: Option<String>,
pub send_function: Option<String>,
pub analyze_function: Option<String>,
pub internallength: Option<i32>,
pub passed_by_value: Option<bool>,
pub alignment: Option<String>,
pub storage: Option<String>,
}
/// Generated `CREATE TYPE` text for a user-defined type.
///
/// `complete = true` means the text can be executed standalone in the current
/// schema. `complete = false` means the text is for viewing only (it may depend
/// on other types/functions or internal attributes); `warnings` must be shown
/// in the UI and the text must never be presented as executable source.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTypeDdl {
pub sql: String,
pub complete: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTypeDetails {
pub name: String,
pub schema: String,
pub kind: CustomTypeKind,
pub comment: Option<String>,
#[serde(default)]
pub members: Vec<CustomTypeMember>,
pub properties: CustomTypeProperties,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ddl: Option<CustomTypeDdl>,
}
#[cfg(test)]
mod tests {
use super::{ObjectInfo, ObjectSourceKind, QueryMessage, SpatialColumn, SpatialColumnBuilder};
+1
View File
@@ -361,6 +361,7 @@ async fn main() {
.route("/schema/completion-objects", get(routes::schema::list_completion_objects))
.route("/schema/completion-assistant", post(routes::schema::completion_assistant_search))
.route("/schema/object-source", get(routes::schema::get_object_source))
.route("/schema/custom-type-details", get(routes::schema::get_custom_type_details))
.route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/all-columns", get(routes::schema::get_all_columns))
.route("/schema/data-types", get(routes::schema::list_data_types))
+15
View File
@@ -250,6 +250,8 @@ pub async fn list_objects(
schema: Some(database.to_string()),
valid: None,
signature: None,
custom_type_kind: None,
has_members: None,
comment: table.comment,
created_at: None,
updated_at: None,
@@ -332,6 +334,19 @@ pub async fn get_object_source(
Ok(Json(result))
}
pub async fn get_custom_type_details(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<dbx_core::db::CustomTypeDetails>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let name = q.table.as_deref().unwrap_or("");
let result = dbx_core::schema::get_custom_type_details_core(&state.app, &q.connection_id, database, schema, name)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn list_columns(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
+13
View File
@@ -240,6 +240,8 @@ pub async fn list_objects(
updated_at: None,
parent_schema: table.parent_schema,
parent_name: table.parent_name,
custom_type_kind: None,
has_members: None,
trigger: None,
xugu_type_members_expandable: None,
})
@@ -310,6 +312,17 @@ pub async fn get_object_source(
.await
}
#[tauri::command]
pub async fn get_custom_type_details(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
name: String,
) -> Result<db::CustomTypeDetails, String> {
dbx_core::schema::get_custom_type_details_core(&state, &connection_id, &database, &schema, &name).await
}
#[tauri::command]
pub async fn get_columns(
state: State<'_, Arc<AppState>>,
+1
View File
@@ -1644,6 +1644,7 @@ pub fn run() {
commands::schema::list_completion_objects,
commands::schema::completion_assistant_search,
commands::schema::get_object_source,
commands::schema::get_custom_type_details,
commands::schema::list_schemas,
commands::schema::list_schema_infos,
commands::schema::list_data_types,