Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions backup/queries_relations.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,29 @@ func (v View) ObjectType() string {
return toc.OBJ_VIEW
}

// relOptionsWithClause returns a SQL expression that renders the given
// pg_class.reloptions column as a " WITH (...)" clause for a CREATE statement.
// A single reloption is stored as a "name=value" element; a value that is not a
// simple token (lowercase letters, digits, '_' or '.') is single-quoted so that
// comma-separated, uppercase or spaced values survive the backup/restore round
// trip. The prime example is a PAX materialized view's minmax_columns /
// cluster_columns, whose comma-separated column list would otherwise be read as
// the separator between options and fail restore with `unrecognized parameter`.
// Plain-token values (e.g. compresstype=zstd) are emitted unchanged, and the
// expression yields '' when reloptions is NULL. WITH ORDINALITY preserves the
// original option order.
func relOptionsWithClause(reloptionsCol string) string {
return fmt.Sprintf(`CASE WHEN %[1]s IS NULL THEN ''
ELSE ' WITH (' || array_to_string(ARRAY(
SELECT CASE WHEN substr(opt, position('=' in opt) + 1) ~ '^[a-z0-9_.]+$'
THEN opt
ELSE left(opt, position('=' in opt) - 1) || '=' || quote_literal(substr(opt, position('=' in opt) + 1))
END
FROM unnest(%[1]s) WITH ORDINALITY AS u(opt, n)
ORDER BY n), ', ') || ')'
END`, reloptionsCol)
}

// This function retrieves both regular views and materialized views.
func GetAllViews(connectionPool *dbconn.DBConn) []View {
columnDefs := GetColumnDefinitions(connectionPool)
Expand Down Expand Up @@ -413,29 +436,31 @@ func GetAllViews(connectionPool *dbconn.DBConn) []View {

// Materialized views were introduced in GPDB 7 and backported to GPDB 6.2.
// Reloptions and tablespace added to pg_class in GPDB 6
optionsExpr := relOptionsWithClause("c.reloptions")

version6Query := fmt.Sprintf(`
SELECT
c.oid AS oid,
quote_ident(n.nspname) AS schema,
quote_ident(c.relname) AS name,
pg_get_viewdef(c.oid) AS definition,
coalesce(' WITH (' || array_to_string(c.reloptions, ', ') || ')', '') AS options,
%s AS options,
coalesce(quote_ident(t.spcname), '') AS tablespace,
c.relkind='m' AS ismaterialized
FROM pg_class c
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
WHERE c.relkind IN ('m', 'v')
AND %s
AND %s`, relationAndSchemaFilterClause(), ExtensionFilterClause("c"))
AND %s`, optionsExpr, relationAndSchemaFilterClause(), ExtensionFilterClause("c"))

atLeast7Query := fmt.Sprintf(`
SELECT
c.oid AS oid,
quote_ident(n.nspname) AS schema,
quote_ident(c.relname) AS name,
pg_get_viewdef(c.oid) AS definition,
coalesce(' WITH (' || array_to_string(c.reloptions, ', ') || ')', '') AS options,
%s AS options,
coalesce(quote_ident(t.spcname), '') AS tablespace,
c.relkind='m' AS ismaterialized,
coalesce(quote_ident(am.amname), '') AS accessmethodname
Expand All @@ -445,7 +470,7 @@ func GetAllViews(connectionPool *dbconn.DBConn) []View {
LEFT JOIN pg_am am ON am.oid = c.relam
WHERE c.relkind IN ('m', 'v')
AND %s
AND %s`, relationAndSchemaFilterClause(), ExtensionFilterClause("c"))
AND %s`, optionsExpr, relationAndSchemaFilterClause(), ExtensionFilterClause("c"))

query := ""
if connectionPool.Version.IsGPDB() && connectionPool.Version.Before("6") {
Expand Down
36 changes: 36 additions & 0 deletions integration/predata_relations_queries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package integration

import (
"database/sql"
"fmt"
"math"
"sort"
"strconv"
Expand Down Expand Up @@ -553,5 +554,40 @@ PARTITION BY LIST (gender)
Expect(results).To(HaveLen(1))
structmatcher.ExpectStructsToMatchExcluding(&materialView, &results[0], "Oid", "ColumnDefs", "DistPolicy.Oid")
})
It("single-quotes comma-containing storage option values for PAX materialized views", func() {
// PAX minmax_columns/cluster_columns store a comma-separated column
// list as a single string value. Emitted unquoted, the embedded comma
// is read as an option separator and the restored matview DDL fails
// with `unrecognized parameter "<second column>"`. The value must be
// single-quoted so the WITH clause survives the round trip.
if !connectionPool.Version.IsCBDB() {
Skip("PAX access method is only available on Cloudberry")
}
testhelper.AssertQueryRuns(connectionPool,
"CREATE TABLE public.pax_mv_base(a int, b int, c int) DISTRIBUTED BY (a)")
defer testhelper.AssertQueryRuns(connectionPool, "DROP TABLE public.pax_mv_base CASCADE")
testhelper.AssertQueryRuns(connectionPool,
"CREATE MATERIALIZED VIEW public.pax_mv USING pax WITH (minmax_columns='a,b', cluster_columns='a,b', cluster_type='lexical') AS SELECT * FROM public.pax_mv_base DISTRIBUTED BY (a)")
defer testhelper.AssertQueryRuns(connectionPool, "DROP MATERIALIZED VIEW public.pax_mv")

results := backup.GetAllViews(connectionPool)
var mv backup.View
for _, v := range results {
if v.Name == "pax_mv" {
mv = v
}
}

// The comma-containing list values must be single-quoted ...
Expect(mv.Options).To(ContainSubstring("minmax_columns='a,b'"))
Expect(mv.Options).To(ContainSubstring("cluster_columns='a,b'"))

// ... and the emitted WITH clause must recreate the matview verbatim.
// This asserts the wiring end to end: a failure to quote would make
// this CREATE MATERIALIZED VIEW fail.
testhelper.AssertQueryRuns(connectionPool,
fmt.Sprintf("CREATE MATERIALIZED VIEW public.pax_mv_restored USING pax%s AS SELECT * FROM public.pax_mv_base DISTRIBUTED BY (a)", mv.Options))
testhelper.AssertQueryRuns(connectionPool, "DROP MATERIALIZED VIEW public.pax_mv_restored")
})
})
})