mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(postgres): list user-defined types with details and DDL for PG-family databases
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -106,9 +107,11 @@ type connectionAttemptConn struct {
|
||||
}
|
||||
|
||||
type valueRows struct {
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
index int
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
index int
|
||||
nextErr error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (fakeDriver) Open(string) (driver.Conn, error) {
|
||||
@@ -286,10 +289,13 @@ func (connection *metadataConn) QueryContext(_ context.Context, query string, _
|
||||
|
||||
func (rows *valueRows) Columns() []string { return rows.columns }
|
||||
|
||||
func (*valueRows) Close() error { return nil }
|
||||
func (rows *valueRows) Close() error { return rows.closeErr }
|
||||
|
||||
func (rows *valueRows) Next(values []driver.Value) error {
|
||||
if rows.index >= len(rows.rows) {
|
||||
if rows.nextErr != nil {
|
||||
return rows.nextErr
|
||||
}
|
||||
return io.EOF
|
||||
}
|
||||
copy(values, rows.rows[rows.index])
|
||||
@@ -1100,6 +1106,467 @@ func TestListTablesPreservesKingbaseObjectTypesAndComments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCustomTypesUsesPostgresCatalog(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if !strings.Contains(query, "FROM pg_catalog.pg_type t") || !strings.Contains(query, "t.typtype IN ('b','c','d','e','r','m')") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "t.typelem = 0") || !strings.Contains(query, "(t.typrelid = 0 OR c.relkind = 'c')") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "n.nspname <> 'information_schema'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_toast%'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_temp%'") {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{
|
||||
{"status", "order status", "e", true},
|
||||
{"email", nil, "d", false},
|
||||
{"address", nil, "c", true},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
server.mode.postgresCatalog = true
|
||||
|
||||
types, err := server.listCustomTypes("public")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(types) != 3 {
|
||||
t.Fatalf("unexpected types: %#v", types)
|
||||
}
|
||||
for _, item := range types {
|
||||
if item.ObjectType != "TYPE" || item.Schema != "public" {
|
||||
t.Fatalf("type metadata was lost: %#v", item)
|
||||
}
|
||||
}
|
||||
if types[0].Comment == nil || *types[0].Comment != "order status" {
|
||||
t.Fatalf("type comment was lost: %#v", types[0])
|
||||
}
|
||||
if types[1].Comment != nil {
|
||||
t.Fatalf("nil comment became non-nil: %#v", types[1])
|
||||
}
|
||||
if types[0].CustomTypeKind == nil || *types[0].CustomTypeKind != "enum" || types[0].HasMembers == nil || !*types[0].HasMembers {
|
||||
t.Fatalf("type kind/member metadata was lost: %#v", types[0])
|
||||
}
|
||||
if types[1].CustomTypeKind == nil || *types[1].CustomTypeKind != "domain" || types[1].HasMembers == nil || *types[1].HasMembers {
|
||||
t.Fatalf("leaf type metadata was lost: %#v", types[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCustomTypesUsesSystemCatalog(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if !strings.Contains(query, "FROM sys_catalog.sys_type t") || strings.Contains(query, "FROM pg_catalog") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{{"status", "order status", "e", true}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
server.mode.postgresCatalog = false
|
||||
|
||||
types, err := server.listCustomTypes("public")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(types) != 1 || types[0].Name != "status" {
|
||||
t.Fatalf("unexpected types: %#v", types)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCustomTypesSkipsMySQLCompatMode(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
return nil, errors.New("custom types query must not run in mysql compat mode: " + query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
server.mode.mysqlCompat = true
|
||||
|
||||
types, err := server.listCustomTypes("public")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(types) != 0 {
|
||||
t.Fatalf("expected no types in mysql compat mode: %#v", types)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
switch {
|
||||
case strings.Contains(query, "sys_type t"):
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{
|
||||
{"status", "order status", "e", true},
|
||||
{"email", nil, "d", false},
|
||||
},
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_proc p"):
|
||||
return &valueRows{
|
||||
columns: []string{"proname", "kind", "comment"},
|
||||
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
objects, err := server.listObjects("public", metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var typeNames []string
|
||||
for _, item := range objects {
|
||||
if item.ObjectType == "TYPE" {
|
||||
typeNames = append(typeNames, item.Name)
|
||||
}
|
||||
}
|
||||
if len(typeNames) != 2 || typeNames[0] != "email" || typeNames[1] != "status" {
|
||||
t.Fatalf("unexpected types in object list: %v (objects=%#v)", typeNames, objects)
|
||||
}
|
||||
if len(objects) != 4 {
|
||||
t.Fatalf("expected table + function + 2 types, got %#v", objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjectsOnlyCustomTypesWhenTypeRequested(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "FROM sys_catalog.sys_class c") || strings.Contains(query, "sys_proc p") {
|
||||
return nil, errors.New("type-only request must not scan relations or routines: " + query)
|
||||
}
|
||||
if !strings.Contains(query, "sys_type t") {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{{"status", "order status", "e", true}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
// The sidebar type group sends TYPE together with the TYPE_BODY companion;
|
||||
// both must resolve to a type-only request that never scans tables.
|
||||
for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} {
|
||||
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: objectTypes})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objects) != 1 || objects[0].Name != "status" || objects[0].ObjectType != "TYPE" || objects[0].Schema != "public" {
|
||||
t.Fatalf("expected only the TYPE object for %v: %#v", objectTypes, objects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeBodyConstraintIsNotTableLike(t *testing.T) {
|
||||
constraints := metadataListConstraints{ObjectTypes: []string{"TYPE", "TYPE_BODY"}}
|
||||
if !constraintsAllowTypes(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must allow types")
|
||||
}
|
||||
if constraintsAllowsTableLike(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must not be table-like; normalizeTableType must not map TYPE_BODY to TABLE")
|
||||
}
|
||||
if constraintsAllowRoutines(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must not be routine-like")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "sys_type t") || strings.Contains(query, "sys_proc p") {
|
||||
return nil, errors.New("table-only request must not scan types or routines: " + query)
|
||||
}
|
||||
if !strings.Contains(query, "sys_class c") {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range objects {
|
||||
if item.ObjectType == "TYPE" {
|
||||
t.Fatalf("table-only request must not return types: %#v", objects)
|
||||
}
|
||||
}
|
||||
if len(objects) == 0 {
|
||||
t.Fatalf("expected the table to remain listed: %#v", objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjectsTypeOnlyPropagatesCustomTypesError(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
return nil, errors.New("catalog unavailable: " + query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
_, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TYPE"}})
|
||||
if err == nil {
|
||||
t.Fatal("dedicated type request must propagate the catalog error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "list custom types") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "sys_type t") {
|
||||
return nil, errors.New("pg_type unavailable")
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(query, "sys_proc p"):
|
||||
return &valueRows{
|
||||
columns: []string{"proname", "kind", "comment"},
|
||||
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
// A failing type catalog must surface as an error even for the unfiltered
|
||||
// “all objects” listing, so users never see a silently incomplete list.
|
||||
_, err := server.listObjects("public", metadataListConstraints{})
|
||||
if err == nil {
|
||||
t.Fatal("unfiltered request must propagate the type catalog error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "list custom types") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseCustomTypeQueriesFollowCatalog(t *testing.T) {
|
||||
pgQueries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "status")
|
||||
for _, fragment := range []string{
|
||||
"pg_catalog.pg_type", "pg_catalog.pg_namespace", "pg_catalog.pg_description", "pg_catalog.pg_proc",
|
||||
"pg_catalog.pg_collation", "pg_get_expr", "pg_get_constraintdef",
|
||||
"n.nspname = 'app' AND t.typname = 'status'",
|
||||
} {
|
||||
if !strings.Contains(pgQueries.general, fragment) && !strings.Contains(pgQueries.compositeMembers, fragment) && !strings.Contains(pgQueries.domainConstraints, fragment) {
|
||||
t.Fatalf("pg catalog queries missing %q: %s", fragment, pgQueries.general)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(pgQueries.enumMembers, "pg_catalog.pg_enum") {
|
||||
t.Fatalf("enum query must use pg_catalog.pg_enum: %s", pgQueries.enumMembers)
|
||||
}
|
||||
if !strings.Contains(pgQueries.compositeMembers, "pg_catalog.pg_attribute") {
|
||||
t.Fatalf("composite query must use pg_catalog.pg_attribute: %s", pgQueries.compositeMembers)
|
||||
}
|
||||
if !strings.Contains(pgQueries.rangeAttributes, "pg_catalog.pg_range") {
|
||||
t.Fatalf("range query must use pg_catalog.pg_range: %s", pgQueries.rangeAttributes)
|
||||
}
|
||||
for _, fragment := range []string{"JOIN pg_catalog.pg_type at", "quote_ident(atn.nspname)", "LEFT JOIN pg_catalog.pg_type elem"} {
|
||||
if !strings.Contains(pgQueries.compositeMembers, fragment) {
|
||||
t.Fatalf("composite query must schema-qualify member types; missing %q: %s", fragment, pgQueries.compositeMembers)
|
||||
}
|
||||
}
|
||||
for _, fragment := range []string{"JOIN pg_catalog.pg_namespace n", "quote_ident(n.nspname)", "WHERE t.oid = %[1]d"} {
|
||||
if !strings.Contains(pgQueries.domainBaseType, fragment) {
|
||||
t.Fatalf("domain query must schema-qualify its base type; missing %q: %s", fragment, pgQueries.domainBaseType)
|
||||
}
|
||||
}
|
||||
formattedDomainBaseType := fmt.Sprintf(pgQueries.domainBaseType, 25, -1)
|
||||
if strings.Contains(formattedDomainBaseType, "%") || !strings.Contains(formattedDomainBaseType, "format_type(t.oid, -1::int4)") {
|
||||
t.Fatalf("domain base type query must format both OID and typmod: %s", formattedDomainBaseType)
|
||||
}
|
||||
for _, query := range []string{pgQueries.rangeAttributes, pgQueries.rangeAttributesForMultirange} {
|
||||
for _, fragment := range []string{"JOIN pg_catalog.pg_type st", "quote_ident(stn.nspname)", "quote_ident(ncan.nspname)", "quote_ident(ndiff.nspname)", "quote_ident(nopc.nspname)", "ncan.oid = pcan.pronamespace", "ndiff.oid = pdiff.pronamespace", "nopc.oid = opc.opcnamespace"} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
t.Fatalf("range query must qualify catalog names with schema; missing %q: %s", fragment, query)
|
||||
}
|
||||
}
|
||||
if strings.Contains(query, "%!") {
|
||||
t.Fatalf("range query contains an unresolved format directive: %s", query)
|
||||
}
|
||||
}
|
||||
|
||||
sysQueries := customTypeCatalogQueriesFor("sys_catalog", "sys", "app", "status")
|
||||
for _, fragment := range []string{"sys_catalog.sys_type", "sys_catalog.sys_namespace", "sys_catalog.sys_description", "sys_catalog.sys_proc", "sys_get_expr", "sys_get_constraintdef"} {
|
||||
if !strings.Contains(sysQueries.general, fragment) && !strings.Contains(sysQueries.compositeMembers, fragment) && !strings.Contains(sysQueries.domainConstraints, fragment) {
|
||||
t.Fatalf("sys catalog queries missing %q: %s", fragment, sysQueries.general)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sysQueries.general, "FROM pg_catalog") || strings.Contains(sysQueries.general, "pg_get_expr") {
|
||||
t.Fatalf("sys catalog general query leaked pg_catalog references: %s", sysQueries.general)
|
||||
}
|
||||
if strings.Contains(pgQueries.general, "pg_get_expr") || strings.Contains(sysQueries.general, "sys_get_expr") {
|
||||
t.Fatal("general type lookup must not depend on default-expression rendering")
|
||||
}
|
||||
if !strings.Contains(pgQueries.domainRenderedDefault, "pg_get_expr") || !strings.Contains(sysQueries.domainRenderedDefault, "sys_get_expr") {
|
||||
t.Fatal("domain default renderer must follow the selected catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseDomainDefaultRenderFailureIsDegradable(t *testing.T) {
|
||||
bin := sql.NullString{String: "{CONST ...}", Valid: true}
|
||||
value, warnings := resolveCustomTypeDomainDefault(bin, sql.NullString{}, func() (string, error) {
|
||||
return "", errors.New("function sys_get_expr does not exist")
|
||||
})
|
||||
if value != nil || len(warnings) != 1 || !strings.Contains(warnings[0], "DDL is incomplete") {
|
||||
t.Fatalf("unexpected fallback result: value=%v warnings=%v", value, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseDomainConstraintReadFailuresMarkDDLIncomplete(t *testing.T) {
|
||||
queries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "email")
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
switch {
|
||||
case strings.Contains(query, "WHERE t.oid = 1"):
|
||||
return &valueRows{columns: []string{"base_type"}, rows: [][]driver.Value{{"text"}}}, nil
|
||||
case strings.Contains(query, "WHERE c.contypid = 9"):
|
||||
return &valueRows{columns: []string{"conname", "definition"}, rows: [][]driver.Value{{"email_valid"}}}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
properties := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}}
|
||||
warnings := server.customTypeDomainAttributes(queries, &properties, 9, 1, -1, false, sql.NullString{}, sql.NullString{}, 0, sql.NullString{})
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "domain constraints could not be decoded") {
|
||||
t.Fatalf("constraint scan failures must be retained as warnings: %v", warnings)
|
||||
}
|
||||
ddl := buildCustomTypeDDL("app", "email", customTypeKindDomain, sql.NullString{}, &[]customTypeMember{}, &properties, warnings)
|
||||
if ddl.Complete {
|
||||
t.Fatalf("domain DDL must be incomplete after a constraint scan failure: %+v", ddl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseGetTypeDetailsRejectsMySQLCompat(t *testing.T) {
|
||||
server := newServer()
|
||||
server.mode.mysqlCompat = true
|
||||
_, err := server.getTypeDetails("public", "status")
|
||||
if err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") {
|
||||
t.Fatalf("expected MySQL compat rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseGetTypeDetailsPropagatesRowIterationError(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(string) (driver.Rows, error) {
|
||||
return &valueRows{nextErr: errors.New("row stream failed")}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
_, err := server.getTypeDetails("public", "status")
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read type") || !strings.Contains(err.Error(), "row stream failed") {
|
||||
t.Fatalf("expected row iteration error to be propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseCustomTypeKindFromCode(t *testing.T) {
|
||||
for code, expected := range map[string]customTypeKind{
|
||||
"b": customTypeKindBase, "c": customTypeKindComposite, "d": customTypeKindDomain,
|
||||
"e": customTypeKindEnum, "r": customTypeKindRange, "m": customTypeKindMultirange,
|
||||
} {
|
||||
kind, ok := customTypeKindFromCode(code)
|
||||
if !ok || kind != expected {
|
||||
t.Fatalf("customTypeKindFromCode(%q) = %v, %v", code, kind, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := customTypeKindFromCode("p"); ok {
|
||||
t.Fatal("pseudo type must not map to a kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseSystemSchemasAreRejectedForCustomTypeDetails(t *testing.T) {
|
||||
for _, schema := range []string{"pg_catalog", "information_schema", "pg_toast", "pg_toast_temp_5", "pg_temp_5"} {
|
||||
if !isSystemSchema(schema) {
|
||||
t.Fatalf("%q should be recognized as a system schema", schema)
|
||||
}
|
||||
}
|
||||
if isSystemSchema("public") || isSystemSchema("app") {
|
||||
t.Fatal("user schemas must remain eligible for custom type details")
|
||||
}
|
||||
server := newServer()
|
||||
if _, err := server.getTypeDetails("pg_catalog", "int4"); err == nil || !strings.Contains(err.Error(), "system schema") {
|
||||
t.Fatalf("system schema must be rejected before catalog access, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseCustomTypeDDL(t *testing.T) {
|
||||
nullInput := sql.NullString{}
|
||||
enumMembers := []customTypeMember{
|
||||
{Ordinal: 1, EnumValue: stringPtr("draft")},
|
||||
{Ordinal: 2, EnumValue: stringPtr("已归档")},
|
||||
}
|
||||
enumDDL := buildCustomTypeDDL("app", "status", customTypeKindEnum, nullInput, &enumMembers, &customTypeProperties{}, nil)
|
||||
if enumDDL.SQL != "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', '已归档');" || !enumDDL.Complete {
|
||||
t.Fatalf("unexpected enum DDL: %+v", enumDDL)
|
||||
}
|
||||
|
||||
compositeMembers := []customTypeMember{
|
||||
{Name: "city", DataType: "text", Ordinal: 1, Comment: stringPtr("city name")},
|
||||
}
|
||||
compositeDDL := buildCustomTypeDDL("app", "address", customTypeKindComposite, nullInput, &compositeMembers, &customTypeProperties{}, nil)
|
||||
if !strings.Contains(compositeDDL.SQL, "\"city\" text") || !strings.Contains(compositeDDL.SQL, "COMMENT ON COLUMN \"app\".\"address\".\"city\" IS 'city name';") {
|
||||
t.Fatalf("unexpected composite DDL: %+v", compositeDDL)
|
||||
}
|
||||
|
||||
notNull := true
|
||||
domainProps := customTypeProperties{BaseType: stringPtr("text"), NotNull: ¬Null, DomainConstraints: []customTypeDomainConstraint{{Name: "email_valid", Definition: "CHECK ((VALUE <> ''::text))"}}}
|
||||
domainDDL := buildCustomTypeDDL("app", "email", customTypeKindDomain, nullInput, &[]customTypeMember{}, &domainProps, nil)
|
||||
if !strings.Contains(domainDDL.SQL, "CREATE DOMAIN \"app\".\"email\" AS text") || !strings.Contains(domainDDL.SQL, "NOT NULL") || !strings.Contains(domainDDL.SQL, "CHECK ((VALUE <> ''::text))") {
|
||||
t.Fatalf("unexpected domain DDL: %+v", domainDDL)
|
||||
}
|
||||
|
||||
rangeProps := customTypeProperties{RangeSubtype: stringPtr("numeric"), RangeCanonicalFunction: stringPtr("\"extensions\".\"numeric_range_canonical\"")}
|
||||
rangeDDL := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &rangeProps, nil)
|
||||
if !rangeDDL.Complete || !strings.Contains(rangeDDL.SQL, "subtype = numeric") || !strings.Contains(rangeDDL.SQL, "canonical = \"extensions\".\"numeric_range_canonical\"") {
|
||||
t.Fatalf("unexpected range DDL: %+v", rangeDDL)
|
||||
}
|
||||
missingSubtype := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &customTypeProperties{RangeMultirangeName: stringPtr("price_multirange")}, nil)
|
||||
if missingSubtype.Complete || missingSubtype.SQL != "CREATE TYPE \"app\".\"price_range\" AS RANGE (subtype = unknown);" {
|
||||
t.Fatalf("range DDL without subtype must be incomplete: %+v", missingSubtype)
|
||||
}
|
||||
|
||||
multirangeDDL := buildCustomTypeDDL("app", "_price_range", customTypeKindMultirange, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
||||
if multirangeDDL.Complete || len(multirangeDDL.Warnings) == 0 {
|
||||
t.Fatalf("multirange DDL must be incomplete with warnings: %+v", multirangeDDL)
|
||||
}
|
||||
|
||||
baseDDL := buildCustomTypeDDL("app", "point2d", customTypeKindBase, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
||||
if baseDDL.Complete || len(baseDDL.Warnings) == 0 {
|
||||
t.Fatalf("base DDL must be incomplete with warnings: %+v", baseDDL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTypeDetailsJSONNeverNullsSlices(t *testing.T) {
|
||||
props := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}}
|
||||
details := customTypeDetails{Name: "status", Schema: "app", Kind: customTypeKindEnum, Members: []customTypeMember{}, Properties: props}
|
||||
raw, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if strings.Contains(text, "null") {
|
||||
t.Fatalf("empty slices must encode as [] not null: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"members":[]`) || !strings.Contains(text, `"domainConstraints":[]`) {
|
||||
t.Fatalf("empty slices must be present as []: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTriggersUsesCompatibilityCatalogAndDecodesTiming(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if !strings.Contains(query, "FROM pg_catalog.pg_trigger") || !strings.Contains(query, "NOT tg.tgisinternal") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ¬Null, DomainConstraints: []customTypeDomainConstraint{{Name: "email_valid", Definition: "CHECK ((VALUE <> ''::text))"}}}
|
||||
domainDDL := buildCustomTypeDDL("app", "email", customTypeKindDomain, nullInput, &[]customTypeMember{}, &domainProps, nil)
|
||||
if !strings.Contains(domainDDL.SQL, "CREATE DOMAIN \"app\".\"email\" AS text") || !strings.Contains(domainDDL.SQL, "NOT NULL") {
|
||||
t.Fatalf("unexpected domain DDL: %+v", domainDDL)
|
||||
}
|
||||
rangeProps := customTypeProperties{RangeSubtype: stringPtr("numeric"), RangeCanonicalFunction: stringPtr("\"extensions\".\"numeric_range_canonical\"")}
|
||||
rangeDDL := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &rangeProps, nil)
|
||||
if !rangeDDL.Complete || !strings.Contains(rangeDDL.SQL, "canonical = \"extensions\".\"numeric_range_canonical\"") {
|
||||
t.Fatalf("unexpected range DDL: %+v", rangeDDL)
|
||||
}
|
||||
missingSubtype := buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &customTypeProperties{RangeMultirangeName: stringPtr("price_multirange")}, nil)
|
||||
if missingSubtype.Complete || missingSubtype.SQL != "CREATE TYPE \"app\".\"price_range\" AS RANGE (subtype = unknown);" {
|
||||
t.Fatalf("range DDL without subtype must be incomplete: %+v", missingSubtype)
|
||||
}
|
||||
multirangeDDL := buildCustomTypeDDL("app", "_price_range", customTypeKindMultirange, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
||||
if multirangeDDL.Complete || len(multirangeDDL.Warnings) == 0 {
|
||||
t.Fatalf("multirange DDL must be incomplete with warnings: %+v", multirangeDDL)
|
||||
}
|
||||
baseDDL := buildCustomTypeDDL("app", "point2d", customTypeKindBase, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
||||
if baseDDL.Complete || len(baseDDL.Warnings) == 0 {
|
||||
t.Fatalf("base DDL must be incomplete with warnings: %+v", baseDDL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
|
||||
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
|
||||
switch {
|
||||
case strings.Contains(query, "sys_type t"):
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{
|
||||
{"status", "order status", "e", true},
|
||||
{"email", nil, "d", false},
|
||||
},
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_proc p"):
|
||||
return &valueRows{
|
||||
columns: []string{"proname", "kind", "comment"},
|
||||
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openVastbaseCustomTypesDB(t, state)
|
||||
|
||||
objects, err := server.listObjects("public", metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var typeNames []string
|
||||
for _, item := range objects {
|
||||
if item.ObjectType == "TYPE" {
|
||||
typeNames = append(typeNames, item.Name)
|
||||
}
|
||||
}
|
||||
if len(typeNames) != 2 || typeNames[0] != "email" || typeNames[1] != "status" {
|
||||
t.Fatalf("unexpected types in object list: %v (objects=%#v)", typeNames, objects)
|
||||
}
|
||||
if len(objects) != 4 {
|
||||
t.Fatalf("expected table + function + 2 types, got %#v", objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseListObjectsOnlyCustomTypesWhenTypeRequested(t *testing.T) {
|
||||
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "FROM sys_catalog.sys_class c") || strings.Contains(query, "sys_proc p") {
|
||||
return nil, fmt.Errorf("type-only request must not scan relations or routines: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "sys_type t") {
|
||||
return nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"typname", "description", "typtype", "has_members"},
|
||||
rows: [][]driver.Value{{"status", "order status", "e", true}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openVastbaseCustomTypesDB(t, state)
|
||||
|
||||
// The sidebar type group sends TYPE together with the TYPE_BODY companion;
|
||||
// both must resolve to a type-only request that never scans tables.
|
||||
for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} {
|
||||
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: objectTypes})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objects) != 1 || objects[0].Name != "status" || objects[0].ObjectType != "TYPE" || objects[0].Schema != "public" {
|
||||
t.Fatalf("expected only the TYPE object for %v: %#v", objectTypes, objects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseTypeBodyConstraintIsNotTableLike(t *testing.T) {
|
||||
constraints := metadataListConstraints{ObjectTypes: []string{"TYPE", "TYPE_BODY"}}
|
||||
if !constraintsAllowTypes(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must allow types")
|
||||
}
|
||||
if constraintsAllowsTableLike(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must not be table-like; normalizeTableType must not map TYPE_BODY to TABLE")
|
||||
}
|
||||
if constraintsAllowRoutines(constraints) {
|
||||
t.Fatal("TYPE/TYPE_BODY request must not be routine-like")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) {
|
||||
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "sys_type t") || strings.Contains(query, "sys_proc p") {
|
||||
return nil, fmt.Errorf("table-only request must not scan types or routines: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "FROM sys_catalog.sys_class c") {
|
||||
return nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openVastbaseCustomTypesDB(t, state)
|
||||
|
||||
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range objects {
|
||||
if item.ObjectType == "TYPE" {
|
||||
t.Fatalf("table-only request must not return types: %#v", objects)
|
||||
}
|
||||
}
|
||||
if len(objects) == 0 {
|
||||
t.Fatalf("expected the table to remain listed: %#v", objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseListObjectsTypeOnlyPropagatesCustomTypesError(t *testing.T) {
|
||||
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
|
||||
return nil, fmt.Errorf("catalog unavailable: %s", query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openVastbaseCustomTypesDB(t, state)
|
||||
|
||||
_, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TYPE"}})
|
||||
if err == nil {
|
||||
t.Fatal("dedicated type request must propagate the catalog error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "list custom types") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVastbaseListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) {
|
||||
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
|
||||
if strings.Contains(query, "sys_type t") {
|
||||
return nil, fmt.Errorf("pg_type unavailable")
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(query, "sys_proc p"):
|
||||
return &valueRows{
|
||||
columns: []string{"proname", "kind", "comment"},
|
||||
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
||||
}, nil
|
||||
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
|
||||
return &valueRows{
|
||||
columns: []string{"relname", "relkind", "comment"},
|
||||
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openVastbaseCustomTypesDB(t, state)
|
||||
|
||||
// A failing type catalog must surface as an error even for the unfiltered
|
||||
// “all objects” listing, so users never see a silently incomplete list.
|
||||
_, err := server.listObjects("public", metadataListConstraints{})
|
||||
if err == nil {
|
||||
t.Fatal("unfiltered request must propagate the type catalog error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "list custom types") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var vastbaseIndexMetadataDriverSequence atomic.Uint64
|
||||
|
||||
type vastbaseIndexMetadataTestState struct {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+246
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ビューを削除",
|
||||
|
||||
@@ -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을 복사했습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 已复制",
|
||||
|
||||
@@ -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 已複製",
|
||||
|
||||
+38
-1
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 })}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1537
-51
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user