package pistachio
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/winebarrel/pistachio/dsql"
"github.com/winebarrel/pistachio/parser"
)
type ApplyOptions struct {
DropPolicy
Files []string `arg:"" help:"Path to the desired schema SQL file(s)."`
PreSQL string `xor:"pre-sql" env:"PISTA_PRE_SQL" help:"SQL to execute before applying changes."`
PreSQLFile string `type:"path" xor:"pre-sql" env:"PISTA_PRE_SQL_FILE" help:"Path to a SQL file to execute before applying changes."`
ConcurrentlyPreSQL string `xor:"concurrently-pre-sql" env:"PISTA_CONCURRENTLY_PRE_SQL" help:"SQL to execute before CONCURRENTLY index DDL (e.g. SET lock_timeout). Runs outside any transaction, only when the diff contains CONCURRENTLY index DDL."`
ConcurrentlyPreSQLFile string `type:"path" xor:"concurrently-pre-sql" env:"PISTA_CONCURRENTLY_PRE_SQL_FILE" help:"Path to a SQL file to execute before CONCURRENTLY index DDL."`
DisableIndexConcurrently bool `xor:"index-concurrently" env:"PISTA_DISABLE_INDEX_CONCURRENTLY" help:"Ignore CONCURRENTLY opt-ins (directive and inline) and emit plain CREATE/DROP INDEX."`
ForceIndexConcurrently bool `xor:"index-concurrently,tx-mode" env:"PISTA_FORCE_INDEX_CONCURRENTLY" help:"Force CONCURRENTLY on every CREATE/DROP INDEX, including pure drops."`
BulkAlter bool `env:"PISTA_BULK_ALTER" help:"Combine consecutive ALTER TABLE actions on the same table into a single statement. FK changes, RENAME, VALIDATE CONSTRAINT, RLS toggles, and skipped DROPs stay separate."`
AssumeValidated bool `env:"PISTA_ASSUME_VALIDATED" help:"Treat every table constraint, domain constraint, and foreign key as validated: ignore NOT VALID and never emit VALIDATE CONSTRAINT."`
EvaluateLiterals bool `env:"PISTA_EVALUATE_LITERALS" help:"Compare a string literal with a cast by value, not by text, in a DEFAULT, a generated column and a CHECK constraint. The literals whose text differs are sent to the server in one query."`
ExecOptions
}
// ExecOptions decides how the statements are run rather than what they are, so
// apply and apply-from, which takes its statements from a plan file, share it.
type ExecOptions struct {
WithTx bool `xor:"tx-mode,tx-choice,tx-retry" env:"PISTA_WITH_TX" help:"Execute pre-SQL and schema changes in a transaction."`
TryTx bool `xor:"tx-choice,tx-retry" env:"PISTA_TRY_TX" help:"Execute pre-SQL and schema changes in a transaction when possible. A diff containing CONCURRENTLY index DDL runs without a transaction instead of failing."`
Timing bool `env:"PISTA_TIMING" help:"Write each statement's elapsed time after it as a comment. Measured on the client, so it covers the round trip and any lock wait."`
// Exclusive and ExclusiveWait guard the database rather than one apply, so
// apply-from takes them too: a plan file executed beside another apply
// would be checked against a state that apply is still changing.
Exclusive bool `xor:"exclusive" env:"PISTA_EXCLUSIVE" help:"Make apply runs on the same database mutually exclusive: fail immediately when another exclusive apply is running."`
// ExclusiveWait enables the same mutual exclusion as Exclusive and waits
// for the other apply instead of failing. A pointer because 0 is a valid
// value (wait without limit) and must be distinguishable from "not set".
// The type rejects a negative value at parse time.
ExclusiveWait *UnsignedDuration `xor:"exclusive" env:"PISTA_EXCLUSIVE_WAIT" placeholder:"DURATION" help:"Like --exclusive, but wait up to the given duration (0 waits without limit) for the other apply to finish."`
// WaitWriter receives the line that says apply is waiting for another
// exclusive apply. The CLI buffers the output writer until the apply is
// done, which would hold that line back until the wait it announces is
// over, so it passes the terminal here. nil writes the line to the
// output writer.
WaitWriter io.Writer `kong:"-"`
// DSQLNoWaitJob leaves a job running when apply moves on. DSQL builds
// every index and validates every constraint in a background job, and
// apply waits for each by default.
DSQLNoWaitJob bool `env:"PISTA_DSQL_NO_WAIT_JOB" help:"With --engine dsql, do not wait for an index build or a constraint validation to finish before running the next statement."`
// LockTimeout and StatementTimeout are pointers so that a flag left out
// sends no SET at all, leaving whatever the role or the database sets.
LockTimeout *UnsignedDuration `env:"PISTA_LOCK_TIMEOUT" placeholder:"DURATION" help:"Run SET lock_timeout with the given duration (0 turns it off) before the pre-SQL."`
// LockRetries runs again only the statement that timed out, so it
// cannot be used with a transaction, which the timeout aborts as a whole.
LockRetries uint `xor:"tx-retry" env:"PISTA_LOCK_RETRIES" help:"Retry a statement that fails on lock_timeout up to this many times. Cannot be used with --with-tx or --try-tx."`
LockRetryDelay UnsignedDuration `env:"PISTA_LOCK_RETRY_DELAY" default:"1s" placeholder:"DURATION" help:"How long to wait before each retry of --lock-retries."`
StatementTimeout *UnsignedDuration `env:"PISTA_STATEMENT_TIMEOUT" placeholder:"DURATION" help:"Run SET statement_timeout with the given duration (0 turns it off) before the pre-SQL."`
}
// applyInput is the statement set an apply runs, whichever it came from: the
// diff this run computed, or the plan file an earlier run wrote. They are held
// apart rather than joined into one script because apply treats each kind
// differently: pre-SQL runs before the search_path is set, the CONCURRENTLY
// pre-SQL only where there is CONCURRENTLY index DDL, and an execute statement
// writes its directive to the output.
type applyInput struct {
PreSQL string
ConcurrentlyPreSQL string
HasConcurrentlyIndex bool
Stmts []string
ExecuteStmts []*parser.ExecuteStmt
}
// ApplyResult holds the result of an Apply operation.
type ApplyResult struct {
Count ObjectCount
DisallowedDrops string
Ignored string
// InvalidIndexes warns about each managed index that is invalid, one
// comment per line.
InvalidIndexes string
// Applied reports whether any schema change was actually applied: schema
// DDL or an executed -- pista:execute statement. Pre-SQL,
// concurrently-pre-SQL, transaction control, search_path setup, and
// -- pista:execute directives skipped by their check SQL do not count.
Applied bool
// Duration is the elapsed time of the apply phase: every statement sent to
// the database (transaction BEGIN/COMMIT, pre-SQL, schema DDL, search_path
// setup, -- pista:execute check SQL, and execute statements) plus the time
// writing them to the output writer. It excludes connection setup and diff
// computation, and is zero unless Applied is true. With a fast writer it is
// dominated by database execution time.
Duration time.Duration
}
// timingComment renders an elapsed time as psql's \timing does, in
// milliseconds with three decimals. A Go duration rounded to milliseconds
// reports the sub-millisecond time of most DDL as "0s".
func timingComment(elapsed time.Duration) string {
return fmt.Sprintf("-- Time: %.3f ms", float64(elapsed.Nanoseconds())/float64(time.Millisecond))
}
func (client *Client) Apply(ctx context.Context, options *ApplyOptions, w io.Writer) (*ApplyResult, error) {
if err := client.validateSchemas(); err != nil {
return nil, err
}
desired, err := client.loadDesiredInput(
options.Files,
options.PreSQL, options.PreSQLFile,
options.ConcurrentlyPreSQL, options.ConcurrentlyPreSQLFile,
)
if err != nil {
return nil, err
}
conn, err := client.connect(ctx, false)
if err != nil {
return nil, err
}
defer conn.Close(ctx) //nolint:errcheck
// Acquire the exclusion before the catalog is read, so the diff below
// cannot be computed against a state another exclusive apply is still
// changing. Released when the connection closes.
if err := acquireExclusiveIfAsked(ctx, conn, &options.ExecOptions, w); err != nil {
return nil, err
}
result, err := client.diffAll(ctx, conn, &diffAllOptions{
FilterOptions: client.FilterOptions,
DropPolicy: options.DropPolicy,
Desired: desired,
DisableIndexConcurrently: options.DisableIndexConcurrently,
ForceIndexConcurrently: options.ForceIndexConcurrently,
BulkAlter: options.BulkAlter,
AssumeValidated: options.AssumeValidated,
EvaluateLiterals: options.EvaluateLiterals,
})
if err != nil {
return nil, err
}
applyResult := &ApplyResult{
Count: result.Count,
DisallowedDrops: strings.Join(result.DisallowedDrops, "\n"),
Ignored: strings.Join(result.Ignored, "\n"),
InvalidIndexes: strings.Join(result.InvalidIndexes, "\n"),
}
if err := client.applyStmts(ctx, conn, &applyInput{
PreSQL: result.PreSQL,
ConcurrentlyPreSQL: result.ConcurrentlyPreSQL,
HasConcurrentlyIndex: result.HasConcurrentlyIndex,
Stmts: result.Stmts,
ExecuteStmts: result.ExecuteStmts,
}, &options.ExecOptions, w, applyResult); err != nil {
return nil, err
}
return applyResult, nil
}
// acquireExclusiveIfAsked takes the exclusion when one was asked for. Both
// entry points call it before they read the catalog, so neither is checked
// against, or computed against, a state another exclusive apply is still
// changing.
func acquireExclusiveIfAsked(ctx context.Context, conn *pgx.Conn, options *ExecOptions, w io.Writer) error {
if !options.Exclusive && options.ExclusiveWait == nil {
return nil
}
waitWriter := options.WaitWriter
if waitWriter == nil {
waitWriter = w
}
return acquireExclusive(ctx, conn, options.ExclusiveWait, waitWriter)
}
// applyStmts runs the statements and writes them to w, filling in the Applied
// and Duration of result. Apply hands it the diff it just computed and
// apply-from the plan file it read, so the two run a change the same way.
func (client *Client) applyStmts(
ctx context.Context,
conn *pgx.Conn,
input *applyInput,
options *ExecOptions,
w io.Writer,
result *ApplyResult,
) error {
// --with-tx is an explicit all-or-nothing request, so CONCURRENTLY index
// DDL (which PostgreSQL cannot run inside a transaction) stays an error.
// --try-tx asks for a transaction only when one is possible, so the same
// diff runs without one.
if options.WithTx && input.HasConcurrentlyIndex {
return errors.New("--with-tx cannot be used with CONCURRENTLY index operations")
}
withTx := options.WithTx || (options.TryTx && !input.HasConcurrentlyIndex)
// A unique constraint takes over its index only once the index is valid,
// so it cannot follow a build nobody waits for.
if client.Engine.isDSQL() && options.DSQLNoWaitJob {
if name, ok := dsql.UnwaitedUniqueIndex(input.Stmts); ok {
return fmt.Errorf("--dsql-no-wait-job cannot be used when a unique constraint takes over index %s, which this plan builds", name)
}
}
if len(input.Stmts) == 0 && len(input.ExecuteStmts) == 0 {
return nil
}
start := time.Now()
applied := false
exec := conn.Exec
queryRow := conn.QueryRow
commit := func(context.Context) error { return nil }
// DSQL builds an index and validates a constraint in a background job.
// Each job is waited for before the next statement runs. --with-tx is
// refused with dsql, so the transaction below never replaces this.
if client.Engine.isDSQL() && !options.DSQLNoWaitJob {
exec = func(ctx context.Context, sql string, _ ...any) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, dsql.Exec(ctx, conn, sql)
}
}
// writeTiming reports how long the statement just written to w took. It is
// a no-op without --timing.
writeTiming := func(elapsed time.Duration) {
if options.Timing {
fmt.Fprintln(w, timingComment(elapsed)) //nolint:errcheck
}
}
// execTimed runs a statement already written to w and writes its elapsed
// time after it. A statement that fails is left without a time, so the
// output names where the apply stopped.
execTimed := func(ctx context.Context, stmt string) error {
stmtStart := time.Now()
if _, err := exec(ctx, stmt); err != nil {
return err
}
writeTiming(time.Since(stmtStart))
return nil
}
// execRetried runs a schema change, and runs it again when it fails on
// lock_timeout, up to --lock-retries times. --lock-retries cannot be used
// with a transaction, so a statement here that times out has changed
// nothing. A -- pista:execute statement is not retried: it is SQL the
// user wrote, and pistachio cannot tell whether running it again is safe.
execRetried := func(ctx context.Context, stmt string) error {
for retry := uint(1); ; retry++ {
err := execTimed(ctx, stmt)
if err == nil || retry > options.LockRetries || !isLockTimeout(err) || !retryable(stmt) {
return err
}
if err := waitLockRetry(ctx, w, options, retry); err != nil {
return err
}
fmt.Fprintln(w, stmt) //nolint:errcheck
}
}
if withTx {
txStart := time.Now()
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
fmt.Fprintln(w, "-- Transaction started") //nolint:errcheck
writeTiming(time.Since(txStart))
committed := false
defer func() {
tx.Rollback(ctx) //nolint:errcheck
if !committed {
fmt.Fprintln(w, "-- Transaction rolled back") //nolint:errcheck
}
}()
exec = tx.Exec
queryRow = tx.QueryRow
commit = func(ctx context.Context) error {
commitStart := time.Now()
if err := tx.Commit(ctx); err != nil {
return err
}
committed = true
fmt.Fprintln(w, "-- Transaction committed") //nolint:errcheck
writeTiming(time.Since(commitStart))
return nil
}
} else if options.TryTx {
// Record why the requested transaction was not opened, in the same
// sentence form as the comments above.
fmt.Fprintln(w, "-- Transaction skipped: plan contains CONCURRENTLY index DDL") //nolint:errcheck
}
// --lock-timeout and --statement-timeout come before the pre-SQL, so a
// SET there overrides them.
for _, stmt := range options.timeoutStmts() {
fmt.Fprintln(w, stmt) //nolint:errcheck
if err := execTimed(ctx, stmt); err != nil {
return fmt.Errorf("failed to execute SQL: %s: %w", stmt, err)
}
}
// Pre-SQL and concurrently-pre-SQL are setup steps (e.g. SET lock_timeout),
// not schema changes, so they do not mark the apply as applied. Whether
// "-- No changes" is reported depends only on actual schema DDL and
// executed -- pista:execute statements.
if input.PreSQL != "" {
fmt.Fprintln(w, input.PreSQL) //nolint:errcheck
if err := execTimed(ctx, input.PreSQL); err != nil {
return fmt.Errorf("failed to execute pre-SQL: %w", err)
}
}
// concurrently-pre-SQL is gated on HasConcurrentlyIndex so it only runs
// when there is CONCURRENTLY index DDL to apply. WithTx + HasConcurrentlyIndex
// is rejected above and TryTx opens no transaction in that case, so this
// always runs outside a transaction.
if input.ConcurrentlyPreSQL != "" && input.HasConcurrentlyIndex {
fmt.Fprintln(w, input.ConcurrentlyPreSQL) //nolint:errcheck
if err := execTimed(ctx, input.ConcurrentlyPreSQL); err != nil {
return fmt.Errorf("failed to execute concurrently-pre-SQL: %w", err)
}
}
// Set search_path to the target schemas before running the managed DDL and
// the -- pista:execute statements, so an unqualified user-type reference in
// a column or attribute definition (e.g. "home addr") resolves. It is not
// written to the output writer, matching the emitted-SQL contract.
if _, err := exec(ctx, client.searchPathSQL()); err != nil {
return fmt.Errorf("failed to set search_path: %w", err)
}
// runExecuteStmts runs the -- pista:execute statements whose First flag
// matches. search_path is already set above. Check SQL is evaluated at the
// point the statement runs, so an execute-first check sees the pre-change
// schema while a plain execute check sees the post-change schema.
runExecuteStmts := func(first bool) error {
for _, es := range input.ExecuteStmts {
if es.First != first {
continue
}
shouldExecute := true
if es.CheckSQL != "" {
if err := queryRow(ctx, es.CheckSQL).Scan(&shouldExecute); err != nil {
return fmt.Errorf("failed to evaluate check SQL: %s: %w", es.CheckSQL, err)
}
}
if shouldExecute {
fmt.Fprintln(w, parser.FormatExecuteStmt(es)) //nolint:errcheck
if err := execTimed(ctx, es.SQL); err != nil {
return fmt.Errorf("failed to execute SQL: %s: %w", es.SQL, err)
}
applied = true
}
}
return nil
}
// Execute -- pista:execute-first statements before schema changes.
if err := runExecuteStmts(true); err != nil {
return err
}
for _, stmt := range input.Stmts {
fmt.Fprintln(w, stmt) //nolint:errcheck
if err := execRetried(ctx, stmt); err != nil {
return fmt.Errorf("failed to execute SQL: %s: %w", stmt, err)
}
applied = true
}
// Execute -- pista:execute statements after schema changes.
if err := runExecuteStmts(false); err != nil {
return err
}
if err := commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
result.Applied = applied
if applied {
result.Duration = time.Since(start)
}
return nil
}
package pistachio
import (
"context"
"fmt"
"io"
"strings"
)
// driftWarning is what --force writes in place of the error, in the comment
// form the rest of the output takes.
const driftWarning = "-- Warning: the database has drifted since the plan was written"
type ApplyFromOptions struct {
ExecOptions
PlanFile string `arg:"" type:"path" help:"Path to the plan file written by plan --out."`
// No env var and no config key: --force turns off the one check the plan
// file is for, and a value set once in CI or a shared config file would
// turn it off for every run there.
Force bool `noconfig:"" help:"Apply the plan file even where the database has drifted since it was written. The drift is reported as a warning instead of an error."`
}
// ApplyFrom executes a plan file. The statements are the plan's, decided when
// it was written, so nothing is diffed here: the database is read only to
// check that it is still the one the plan was computed against.
//
// The scope comes from the file rather than from the command line. The state
// hash means nothing unless it is recomputed over the same read, and a flag
// that quietly narrowed the read here would report drift that is not there.
func (client *Client) ApplyFrom(ctx context.Context, options *ApplyFromOptions, w io.Writer) (*ApplyResult, error) {
plan, err := readPlanFile(options.PlanFile)
if err != nil {
return nil, err
}
// The connection is this run's, everything else the plan file's.
scoped := NewClient(&Options{ConnOptions: client.ConnOptions, ScopeOptions: plan.Scope})
if err := scoped.validateSchemas(); err != nil {
return nil, err
}
conn, err := scoped.connect(ctx, false)
if err != nil {
return nil, err
}
defer conn.Close(ctx) //nolint:errcheck
// Before the state is read, as apply does, so the plan is not checked
// against a state another exclusive apply is still changing.
if err := acquireExclusiveIfAsked(ctx, conn, &options.ExecOptions, w); err != nil {
return nil, err
}
// The major version decides what the catalog reads and what DDL the server
// takes, so a plan file does not travel between two of them. --force is
// about drift and does not reach this.
serverVersion, err := serverMajorVersion(ctx, conn)
if err != nil {
return nil, err
}
if serverVersion != plan.ServerVersion {
return nil, fmt.Errorf(
"plan file %s was written against PostgreSQL %d, and this server is %d: run plan again",
options.PlanFile, plan.ServerVersion, serverVersion,
)
}
current, err := scoped.currentState(ctx, conn)
if err != nil {
return nil, err
}
// The plan did not compare what the desired schema ignores, and this run
// has no desired schema to find them from, so the plan file names them.
current.remove(plan.IgnoredObjects)
stateHash, err := current.stateHash()
if err != nil {
return nil, err
}
if stateHash != plan.StateHash {
if !options.Force {
return nil, fmt.Errorf(
"the database has drifted since plan file %s was written: run plan again, or pass --force to apply it as it is",
options.PlanFile,
)
}
fmt.Fprintln(w, driftWarning) //nolint:errcheck
}
result := &ApplyResult{
Count: plan.Count,
DisallowedDrops: strings.Join(plan.DisallowedDrops, "\n"),
Ignored: strings.Join(ignoredObjectComments(plan.IgnoredObjects), "\n"),
InvalidIndexes: strings.Join(invalidIndexWarnings(current.Tables, current.Views), "\n"),
}
if err := scoped.applyStmts(ctx, conn, &applyInput{
PreSQL: plan.PreSQL,
ConcurrentlyPreSQL: plan.ConcurrentlyPreSQL,
HasConcurrentlyIndex: plan.HasConcurrentlyIndex,
Stmts: plan.Stmts,
ExecuteStmts: plan.ExecuteStmts,
}, &options.ExecOptions, w, result); err != nil {
return nil, err
}
return result, nil
}
package pistachio
import "strings"
// mergeAlterTable collapses consecutive `ALTER TABLE <fqtn> <action>;` statements
// that target the same table into a single statement with comma-separated actions.
// shouldMerge decides per table (by fqtn) whether its statements are merged;
// tables it rejects pass through unchanged.
//
// Only a small whitelist of action prefixes is mergeable; everything else
// (renames, VALIDATE CONSTRAINT, RLS toggles, "-- skipped:" comments, non-ALTER
// TABLE statements, and ALTER TABLE ONLY/IF EXISTS variants) flushes the
// current group and is emitted unchanged. Groups also break when the target
// fqtn changes between consecutive statements.
//
// Caller must only invoke this on statements that are sequenced per-table
// (i.e. diff.TableDiffResult.Stmts), since merging relies on the natural
// per-table contiguity that DiffTables produces.
func mergeAlterTable(stmts []string, shouldMerge func(fqtn string) bool) []string {
result := make([]string, 0, len(stmts))
var groupFQTN string
var groupActions []string
var groupOriginals []string
flush := func() {
switch len(groupActions) {
case 0:
// nothing to flush
case 1:
// Single action: keep the original byte-for-byte to avoid any
// reformatting drift from rebuilding the statement.
result = append(result, groupOriginals[0])
default:
result = append(result, "ALTER TABLE "+groupFQTN+"\n "+strings.Join(groupActions, ",\n ")+";")
}
groupFQTN = ""
groupActions = nil
groupOriginals = nil
}
for _, stmt := range stmts {
fqtn, action, ok := parseMergeableAlterTable(stmt)
if !ok || !shouldMerge(fqtn) {
flush()
result = append(result, stmt)
continue
}
if groupFQTN != "" && groupFQTN != fqtn {
flush()
}
groupFQTN = fqtn
groupActions = append(groupActions, action)
groupOriginals = append(groupOriginals, stmt)
}
flush()
return result
}
// parseMergeableAlterTable returns (fqtn, action, true) when stmt has the exact
// shape `ALTER TABLE <fqtn> <action>;` AND action's leading keyword is on the
// mergeable whitelist. Anything else (ALTER TABLE ONLY, ALTER TABLE IF EXISTS,
// non-mergeable actions like RENAME/VALIDATE/ENABLE-RLS, or non-ALTER-TABLE
// statements) returns ok=false.
func parseMergeableAlterTable(stmt string) (fqtn, action string, ok bool) {
const prefix = "ALTER TABLE "
if !strings.HasPrefix(stmt, prefix) {
return "", "", false
}
rest := stmt[len(prefix):]
// Reject ALTER TABLE ONLY ... and ALTER TABLE IF EXISTS ...; both reserve
// non-action semantics that we don't want to silently combine.
if strings.HasPrefix(rest, "ONLY ") || strings.HasPrefix(rest, "IF EXISTS ") {
return "", "", false
}
fqtn = extractFirstIdentifier(rest)
if fqtn == "" {
return "", "", false
}
rest = rest[len(fqtn):]
if !strings.HasPrefix(rest, " ") {
return "", "", false
}
rest = rest[1:]
if !strings.HasSuffix(rest, ";") {
return "", "", false
}
action = rest[:len(rest)-1]
if !isMergeableAction(action) {
return "", "", false
}
return fqtn, action, true
}
// extractFirstIdentifier extracts a possibly schema-qualified identifier from
// the beginning of a string, preserving quoting. parseMergeableAlterTable
// needs the identifier as the statement spells it, since it goes on to slice
// the action off after it.
func extractFirstIdentifier(s string) string {
s = strings.TrimSpace(s)
var result strings.Builder
inQuote := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' {
result.WriteByte(ch)
if inQuote && i+1 < len(s) && s[i+1] == '"' {
// Escaped quote inside a quoted identifier
result.WriteByte(s[i+1])
i++
continue
}
inQuote = !inQuote
continue
}
if inQuote {
result.WriteByte(ch)
continue
}
if ch == '.' || ch == '_' || ch == '$' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
result.WriteByte(ch)
continue
}
break
}
return result.String()
}
// isMergeableAction returns true for ALTER TABLE actions that are safe to
// combine with peers under the same target table:
// - ADD COLUMN / DROP COLUMN
// - ALTER COLUMN ... (all subforms: SET DATA TYPE, SET/DROP DEFAULT,
// SET/DROP NOT NULL, IDENTITY transitions)
// - ADD CONSTRAINT (CHECK / UNIQUE; FK lives in TableDiffResult.FKAddStmts
// and never reaches mergeAlterTable's input)
// - DROP CONSTRAINT (same FK note)
//
// Renames (column/constraint), VALIDATE CONSTRAINT, RLS toggles, and any other
// action keyword are intentionally excluded so they pass through untouched.
func isMergeableAction(action string) bool {
switch {
case strings.HasPrefix(action, "ADD COLUMN "),
strings.HasPrefix(action, "DROP COLUMN "),
strings.HasPrefix(action, "ALTER COLUMN "),
strings.HasPrefix(action, "ADD CONSTRAINT "),
strings.HasPrefix(action, "DROP CONSTRAINT "):
return true
}
return false
}
package catalog
import (
"errors"
"github.com/jackc/pgx/v5"
)
type Catalog struct {
conn *pgx.Conn
schemas []string
}
func NewCatalog(conn *pgx.Conn, schemas []string) (*Catalog, error) {
if len(schemas) == 0 {
return nil, errors.New("catalog: must specify schemas")
}
catalog := &Catalog{
conn: conn,
schemas: schemas,
}
return catalog, nil
}
package catalog
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
// ListColumnsByTables returns the columns of the given tables, keyed by table
// OID and in attnum order. One query serves every table; reading them one at a
// time cost a round trip per table.
func (c *Catalog) ListColumnsByTables(ctx context.Context, tables []*model.Table) (map[uint32][]*model.Column, error) {
q := `
-- https://www.postgresql.org/docs/current/catalog-pg-attribute.html
SELECT
a.attrelid,
a.attname,
-- False on a column an INHERITS child only inherits. Such a column
-- is not written by the child's CREATE TABLE, so the desired side
-- never holds it; reading it here planned a DROP COLUMN for it. A
-- column the child redeclares is local and inherited at once, and
-- stays.
a.attislocal,
-- A serial type only while the sequence has the default name and
-- the default options for its type. Any other column that takes
-- its default from a sequence it owns keeps its own type and
-- default, and catalog/sequences.go reads the sequence, so that
-- dump writes its name and options.
CASE
WHEN s.is_serial
AND seqc.relname = tc.relname || '_' || a.attname || '_seq'
AND (sq.seqstart, sq.seqincrement, sq.seqmin, sq.seqcache, sq.seqcycle) = (1, 1, 1, 1, false)
AND sq.seqmax = CASE sq.seqtypid
WHEN 'int2'::regtype THEN 32767
WHEN 'int4'::regtype THEN 2147483647
ELSE 9223372036854775807
END
THEN CASE pg_catalog.format_type(a.atttypid, a.atttypmod)
WHEN 'integer' THEN 'serial'
WHEN 'bigint' THEN 'bigserial'
WHEN 'smallint' THEN 'smallserial'
ELSE pg_catalog.format_type(a.atttypid, a.atttypmod)
END
ELSE pg_catalog.format_type(a.atttypid, a.atttypmod)
END AS type_name,
-- The sequence a column owns and takes its default from, whatever
-- its name. The diff uses it to change the sequence type with a
-- serial column's type: bigserial is a bigint column and a bigint
-- sequence, and ALTER TABLE changes only the column. NULL for
-- every other column.
CASE
WHEN s.is_serial
THEN pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname)
END AS serial_sequence,
a.attnotnull,
-- PG18 stores per-column NOT NULL as pg_constraint rows with
-- contype='n' and a single conkey entry. Pre-PG18 has no such row,
-- so the LEFT JOIN yields NULL there.
nn.conname AS not_null_name,
-- Kept on a serial column too, for a desired schema that writes
-- the nextval() default out rather than the serial type.
pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) AS default,
a.attidentity,
a.attgenerated,
quote_ident(con.nspname) || '.' || quote_ident(co.collname) AS collation,
-- attstorage always holds a strategy, so the type's own is read
-- alongside it: a column left at the default is only visible as
-- the two being equal.
COALESCE(CASE a.attstorage
WHEN 'p' THEN 'plain'
WHEN 'e' THEN 'external'
WHEN 'x' THEN 'extended'
WHEN 'm' THEN 'main'
END, '') AS storage_type,
COALESCE(CASE t.typstorage
WHEN 'p' THEN 'plain'
WHEN 'e' THEN 'external'
WHEN 'x' THEN 'extended'
WHEN 'm' THEN 'main'
END, '') AS type_storage,
CASE a.attcompression
WHEN 'p' THEN 'pglz'
WHEN 'l' THEN 'lz4'
ELSE ''
END AS compression,
d.description,
iseq.seqstart,
iseq.seqmin,
iseq.seqmax,
iseq.seqincrement,
iseq.seqcache,
iseq.seqcycle
FROM
pg_catalog.pg_attribute a
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = a.attrelid
AND ad.adnum = a.attnum
-- A column is serial only when it owns a sequence *and* its default
-- actually draws from that sequence. A plain
-- "ALTER SEQUENCE ... OWNED BY" creates the same dependency without
-- touching the default, and treating that as serial would drop the
-- real default from the dumped definition.
CROSS JOIN LATERAL (
SELECT COALESCE(
a.attidentity = ''
-- Round-trip the sequence name through regclass so it is
-- rendered the same way pg_get_expr renders it:
-- pg_get_serial_sequence always schema-qualifies, while
-- pg_get_expr omits the schema when it is on search_path.
AND pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) = 'nextval('
|| quote_literal(pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname)::regclass::text)
|| '::regclass)',
false
) AS is_serial
) s
JOIN pg_catalog.pg_class tc ON tc.oid = a.attrelid
LEFT JOIN pg_catalog.pg_class seqc ON s.is_serial
AND seqc.oid = pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname)::regclass
LEFT JOIN pg_catalog.pg_sequence sq ON sq.seqrelid = seqc.oid
LEFT JOIN pg_catalog.pg_collation co ON co.OID = a.attcollation
AND co.oid != t.typcollation
LEFT JOIN pg_catalog.pg_namespace con ON con.oid = co.collnamespace
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description d ON d.objoid = a.attrelid
AND d.classoid = 'pg_catalog.pg_class'::regclass
AND d.objsubid = a.attnum
LEFT JOIN pg_catalog.pg_constraint nn ON nn.conrelid = a.attrelid
AND nn.contype = 'n'
AND nn.conkey = ARRAY[a.attnum]
-- The sequence behind an identity column. deptype 'i' marks it,
-- and only an identity column has one, so the join is empty for
-- every other column including a serial one (deptype 'a').
LEFT JOIN LATERAL (
SELECT
sq.*
FROM
pg_catalog.pg_depend dep
JOIN pg_catalog.pg_sequence sq ON sq.seqrelid = dep.objid
WHERE
dep.classid = 'pg_class'::regclass
AND dep.refclassid = 'pg_class'::regclass
AND dep.deptype = 'i'
AND dep.refobjid = a.attrelid
AND dep.refobjsubid = a.attnum
) iseq ON true
WHERE
a.attrelid = ANY(@table_oids::oid[])
AND a.attnum >= 1
AND NOT a.attisdropped
ORDER BY
a.attrelid,
a.attnum
`
oids := tableOIDs(tables)
if len(oids) == 0 {
return map[uint32][]*model.Column{}, nil
}
inheritsChildren := inheritsChildOIDs(tables)
tableByOID := tablesByOID(tables)
args := pgx.NamedArgs{
"table_oids": oids,
}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get column info: %w", err)
}
defer rows.Close()
colsByTable := map[uint32][]*model.Column{}
for rows.Next() {
var col model.Column
var tableOID uint32
var isLocal bool
// Null for every column that is not an identity column.
var seqStart, seqMin, seqMax, seqIncrement, seqCache *int64
var seqCycle *bool
err := rows.Scan(
&tableOID,
&col.Name,
&isLocal,
&col.TypeName,
&col.SerialSequence,
&col.NotNull,
&col.NotNullName,
&col.Default,
&col.Identity,
&col.Generated,
&col.Collation,
&col.StorageType,
&col.TypeStorage,
&col.Compression,
&col.Comment,
&seqStart,
&seqMin,
&seqMax,
&seqIncrement,
&seqCache,
&seqCycle,
)
if err != nil {
return nil, fmt.Errorf("catalog: failed to scan column info: %w", err)
}
// Scoped to an INHERITS child. A partition child keeps its inherited
// columns: it declares none of its own, and the diff reaches them
// through a branch that skips the column comparison but still compares
// their comments.
if !isLocal && inheritsChildren[tableOID] {
continue
}
if seqStart != nil && seqMin != nil && seqMax != nil && seqIncrement != nil && seqCache != nil && seqCycle != nil {
col.IdentitySeq = &model.IdentitySequence{
Start: *seqStart,
Min: *seqMin,
Max: *seqMax,
Increment: *seqIncrement,
Cache: *seqCache,
Cycle: *seqCycle,
}
}
// PostgreSQL names an unnamed NOT NULL <table>_<column>_not_null,
// shortened to fit (ChooseConstraintName in
// src/backend/catalog/pg_constraint.c). Treat that name as no name.
// A table or column rename keeps the old name, so after a rename the
// name stays and dump writes it.
if col.NotNullName != nil && *col.NotNullName == parser.MakeObjectName(tableByOID[tableOID].Name, col.Name, "not_null") {
col.NotNullName = nil
}
colsByTable[tableOID] = append(colsByTable[tableOID], &col)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan column info rows: %w", err)
}
return colsByTable, nil
}
package catalog
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) CompositeTypes(ctx context.Context) (*orderedmap.Map[string, *model.CompositeType], error) {
compositeTypes, err := c.ListCompositeTypes(ctx)
if err != nil {
return nil, err
}
byKey := orderedmap.New[string, *model.CompositeType]()
for _, ct := range compositeTypes {
byKey.Set(ct.FQCN(), ct)
}
return byKey, nil
}
func (c *Catalog) ListCompositeTypes(ctx context.Context) ([]*model.CompositeType, error) {
q := `
WITH
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
t.oid,
n.nspname,
t.typname,
t.typrelid,
d.description
FROM
pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
JOIN pg_catalog.pg_class cl ON cl.oid = t.typrelid AND cl.relkind = 'c'
LEFT JOIN pg_catalog.pg_description d ON d.objoid = t.oid
AND d.classoid = 'pg_type'::regclass
AND d.objsubid = 0
LEFT JOIN dependency_extension de ON de.objid = t.oid
WHERE
t.typtype = 'c'
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
ORDER BY
n.nspname,
t.typname
`
type ctRow struct {
ct *model.CompositeType
typrelid uint32
}
var ctRows []ctRow
var row model.CompositeType
var typrelid uint32
err := c.eachRow(ctx, "composite type info", q, []any{&row.OID, &row.Schema, &row.Name, &typrelid, &row.Comment}, func() {
ct := row
ctRows = append(ctRows, ctRow{ct: &ct, typrelid: typrelid})
})
if err != nil {
return nil, err
}
relids := make([]uint32, len(ctRows))
for i, r := range ctRows {
relids[i] = r.typrelid
}
attrsByRelid, err := c.listCompositeAttributes(ctx, relids)
if err != nil {
return nil, err
}
compositeTypes := make([]*model.CompositeType, 0, len(ctRows))
for _, r := range ctRows {
r.ct.Attributes = attrsByRelid[r.typrelid]
compositeTypes = append(compositeTypes, r.ct)
}
return compositeTypes, nil
}
// listCompositeAttributes returns the attributes of the given composite types'
// relations, keyed by relation OID and in attnum order. One query serves every
// composite type.
func (c *Catalog) listCompositeAttributes(ctx context.Context, relids []uint32) (map[uint32][]*model.CompositeAttribute, error) {
q := `
SELECT
a.attrelid,
a.attname,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
-- An attribute typed as a collated domain inherits that collation
-- rather than declaring one, so compare against the attribute type's
-- own collation the way columns.go does.
(SELECT quote_ident(cn.nspname) || '.' || quote_ident(coll.collname) FROM pg_catalog.pg_collation coll JOIN pg_catalog.pg_namespace cn ON cn.oid = coll.collnamespace WHERE coll.oid = a.attcollation AND a.attcollation <> at.typcollation) AS collation,
d.description
FROM
pg_catalog.pg_attribute a
JOIN pg_catalog.pg_type at ON at.oid = a.atttypid
LEFT JOIN pg_catalog.pg_description d ON d.objoid = a.attrelid
AND d.classoid = 'pg_class'::regclass
AND d.objsubid = a.attnum
WHERE
a.attrelid = ANY(@relids::oid[])
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY
a.attrelid,
a.attnum
`
attrs := map[uint32][]*model.CompositeAttribute{}
if len(relids) == 0 {
return attrs, nil
}
args := pgx.NamedArgs{
"relids": relids,
}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get composite attributes: %w", err)
}
defer rows.Close()
for rows.Next() {
var a model.CompositeAttribute
var relid uint32
err := rows.Scan(&relid, &a.Name, &a.TypeName, &a.Collation, &a.Comment)
if err != nil {
return nil, fmt.Errorf("catalog: failed to scan composite attribute: %w", err)
}
attrs[relid] = append(attrs[relid], &a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan composite attribute rows: %w", err)
}
return attrs, nil
}
package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/model"
)
// ListConstraintsByTables returns the table constraints and the foreign keys of
// the given tables, each keyed by table OID and in contype then name order. One
// query serves every table.
func (c *Catalog) ListConstraintsByTables(ctx context.Context, tables []*model.Table) (map[uint32][]*model.Constraint, map[uint32][]*model.ForeignKey, error) {
q := `
WITH
-- One row per pg_constraint row: the conkey columns in conkey
-- order. Grouped by con.oid so each constraint gets only its own
-- columns; previously this CTE grouped by attrelid, which made
-- every constraint on the same table inherit the union of all
-- constraint columns (and on PG18 the contype='n' rows added
-- duplicates).
column_t AS (
SELECT
con.oid AS con_oid,
array_agg(
a.attname
ORDER BY
array_position(con.conkey, a.attnum)
) AS attnames
FROM
pg_catalog.pg_constraint con
JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid
AND a.attnum = ANY(con.conkey)
WHERE
con.conrelid = ANY(@table_oids::oid[])
GROUP BY
con.oid
)
SELECT
con.conrelid,
con.oid,
con.conname,
con.contype,
pg_catalog.pg_get_constraintdef(con.oid, true) AS definition,
col.attnames AS columns,
con.condeferrable,
con.condeferred,
con.convalidated,
-- False on a constraint an INHERITS child only inherits. The
-- child's CREATE TABLE does not write one, so reading it planned a
-- DROP CONSTRAINT that PostgreSQL rejects anyway.
con.conislocal,
-- The storage parameters of the index the constraint owns.
-- pg_get_constraintdef does not print them for a primary key or a
-- unique constraint, so they are appended to the definition below.
-- It does print them for an exclusion constraint, and a foreign
-- key's conindid names the referenced table's index, whose
-- parameters are not part of the key, so the CASE keeps both out.
CASE WHEN con.contype = ANY ('{p,u}'::"char"[]) THEN ci.reloptions END AS index_options,
rn.nspname AS ref_schema,
rc.relname AS ref_table,
descr.description AS comment
FROM
-- https://www.postgresql.org/docs/current/catalog-pg-constraint.html
pg_catalog.pg_constraint con
LEFT JOIN pg_catalog.pg_class rc ON rc.oid = con.confrelid
LEFT JOIN pg_catalog.pg_namespace rn ON rn.oid = rc.relnamespace
LEFT JOIN pg_catalog.pg_class ci ON ci.oid = con.conindid
LEFT JOIN column_t col ON col.con_oid = con.oid
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = con.oid
AND descr.classoid = 'pg_constraint'::regclass
AND descr.objsubid = 0
WHERE
con.conrelid = ANY(@table_oids::oid[])
-- The table-level constraint types, listed the way the ORDER BY
-- below lists them. Two other types share the catalog and belong
-- elsewhere. PG18's per-column NOT NULL rows (contype='n') are
-- read into Column.NotNull / Column.NotNullName by
-- ListColumnsByTables. CREATE CONSTRAINT TRIGGER records the
-- trigger here too (contype='t'), where pg_get_constraintdef
-- renders it as the bare word TRIGGER, followed by the deferral
-- clause when it has one; reading that as a table constraint made
-- dump write a CREATE TABLE that does not parse, and plan propose
-- a DROP CONSTRAINT for the trigger. Naming the types pistachio
-- manages also keeps a type a later major version adds out until
-- the model has somewhere to put it.
AND con.contype = ANY ('{p,u,c,x,f}'::"char"[])
ORDER BY
con.conrelid,
array_position('{p,u,c,x,f}'::"char"[], con.contype),
con.conname
`
constraints := map[uint32][]*model.Constraint{}
foreignKeys := map[uint32][]*model.ForeignKey{}
oids := tableOIDs(tables)
if len(oids) == 0 {
return constraints, foreignKeys, nil
}
tableByOID := tablesByOID(tables)
inheritsChildren := inheritsChildOIDs(tables)
args := pgx.NamedArgs{
"table_oids": oids,
}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, nil, fmt.Errorf("catalog: failed to get constraint info: %w", err)
}
defer rows.Close()
for rows.Next() {
var con model.Constraint
var tableOID uint32
var isLocal bool
var indexOptions []string
var refSchema, refTable *string
err := rows.Scan(
&tableOID,
&con.OID,
&con.Name,
&con.Type,
&con.Definition,
&con.Columns,
&con.Deferrable,
&con.Deferred,
&con.Validated,
&isLocal,
&indexOptions,
&refSchema,
&refTable,
&con.Comment,
)
if err != nil {
return nil, nil, fmt.Errorf("catalog: failed to scan constraint info: %w", err)
}
// Scoped to an INHERITS child. A partition child keeps the clones the
// parent pushes down, since its branch of the diff never compares them.
if !isLocal && inheritsChildren[tableOID] {
continue
}
con.Inherited = !isLocal
// pg_get_constraintdef includes "NOT VALID" in the definition string
// for unvalidated constraints. Strip it so Definition only contains
// the constraint body; validation state is tracked via Validated.
con.Definition = strings.TrimSuffix(con.Definition, " NOT VALID")
// Append the owned index's storage parameters, which
// pg_get_constraintdef leaves out. Values are quoted the way
// pg_get_indexdef quotes an index's, so a dump reloads. The grammar
// takes WITH before the deferral clause and pg_get_constraintdef
// prints DEFERRABLE last, so the clause goes in between.
if len(indexOptions) > 0 {
opts := make([]string, 0, len(indexOptions))
for _, o := range indexOptions {
name, value, _ := strings.Cut(o, "=")
opts = append(opts, name+"="+model.QuoteLiteral(value))
}
deferral := ""
if con.Deferrable {
for _, s := range []string{" DEFERRABLE INITIALLY DEFERRED", " DEFERRABLE"} {
if strings.HasSuffix(con.Definition, s) {
deferral = s
break
}
}
}
con.Definition = strings.TrimSuffix(con.Definition, deferral) +
" WITH (" + strings.Join(opts, ", ") + ")" + deferral
}
if con.Type.IsForeignKeyConstraint() {
table := tableByOID[tableOID]
fk := model.ForeignKey{
Constraint: con,
Schema: table.Schema,
Table: table.Name,
RefSchema: refSchema,
RefTable: refTable,
}
foreignKeys[tableOID] = append(foreignKeys[tableOID], &fk)
} else {
constraints[tableOID] = append(constraints[tableOID], &con)
}
}
if err := rows.Err(); err != nil {
return nil, nil, fmt.Errorf("catalog: failed to scan constraint info rows: %w", err)
}
return constraints, foreignKeys, nil
}
package catalog
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/model"
)
// Dependent is an object that reads a view or a materialized view. PostgreSQL
// refuses to drop a relation while one of these exists rather than cascading,
// so a plan that drops the relation has to account for it.
type Dependent struct {
// Kind is what PostgreSQL calls the object: "view", "materialized view",
// "function", and so on.
Kind string
// Name is the object's identity, schema-qualified. A view or materialized
// view is written the way the rest of the model writes a name; anything
// else the way PostgreSQL identifies it, so a function carries its
// argument types and a rule the relation it sits on.
Name string
// Relation is Name when the dependent is a view or a materialized view,
// and "" otherwise. A plan that drops that relation too is not blocked by
// it, since the drops run deepest first. A rule on a table records the
// dependency a view does, so this stays empty for one: the field is what
// the caller matches against the views it drops.
Relation string
// Constraint is <table>.<constraint> for a foreign key and "" otherwise.
Constraint string
}
func (d Dependent) String() string {
return d.Kind + " " + d.Name
}
// ViewDependents reads what depends on each view and materialized view in the
// managed schemas, keyed by the same schema-qualified name the Views map uses.
// A relation nothing reads is absent rather than present and empty.
//
// Only the direct dependents are read. A view two steps away depends on the
// view between them, and that one has to be dropped for the chain to come
// apart, so the caller meets it there. A dependent outside the managed schemas
// is included, since it blocks the drop just the same.
//
// The rewrite rule a view holds over itself is left out. Every view depends on
// its own columns that way, and the rule goes with the view.
func (c *Catalog) ViewDependents(ctx context.Context) (map[string][]Dependent, error) {
// A view records its dependency through the rewrite rule it holds, so the
// rule is resolved back to the relation it sits on and the dependent is
// named as that relation. A rule on a plain table records the same kind of
// row, and falls through to pg_identify_object with the rest, which names
// it the way PostgreSQL's own error does.
//
// A relation is reached two ways. Most dependents record the relation
// itself, and a routine that returns the view's row type records the type
// that relation owns, which blocks the drop the same way. Both resolve to
// the same target here.
//
// DISTINCT because a dependent reading several columns of the target has a
// pg_depend row per column, and one that reads the relation and returns
// its row type has a row for each.
q := `
SELECT DISTINCT
tn.nspname,
t.relname,
CASE dc.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE (oi).type END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dn.nspname END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dc.relname ELSE (oi).identity END
FROM
pg_catalog.pg_depend d
LEFT JOIN pg_catalog.pg_type rt ON rt.oid = d.refobjid AND d.refclassid = 'pg_catalog.pg_type'::regclass
JOIN pg_catalog.pg_class t ON t.oid = CASE
WHEN d.refclassid = 'pg_catalog.pg_class'::regclass THEN d.refobjid
ELSE rt.typrelid
END
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace
CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.classid, d.objid, 0) oi
LEFT JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid AND d.classid = 'pg_catalog.pg_rewrite'::regclass
LEFT JOIN pg_catalog.pg_class dc ON dc.oid = r.ev_class
LEFT JOIN pg_catalog.pg_namespace dn ON dn.oid = dc.relnamespace
WHERE
d.refclassid IN ('pg_catalog.pg_class'::regclass, 'pg_catalog.pg_type'::regclass)
AND d.deptype = 'n'
AND t.relkind IN ('v', 'm')
AND tn.nspname = ANY(@schemas)
AND (r.oid IS NULL OR r.ev_class <> t.oid)
ORDER BY
1, 2, 3, 4, 5
`
dependents := map[string][]Dependent{}
var targetSchema, targetName, kind, name string
var depSchema *string
err := c.eachRow(ctx, "view dependents", q, []any{&targetSchema, &targetName, &kind, &depSchema, &name}, func() {
dep := Dependent{Kind: kind}
if depSchema != nil {
dep.Name = model.Ident(*depSchema, name)
dep.Relation = dep.Name
} else {
dep.Name = name
}
target := model.Ident(targetSchema, targetName)
dependents[target] = append(dependents[target], dep)
})
if err != nil {
return nil, err
}
return dependents, nil
}
// ColumnDependents reads what blocks a type change of each column in the
// managed schemas, keyed by <table>.<column>. PostgreSQL refuses ALTER
// COLUMN ... TYPE while a view, a rule, a trigger, a policy, a routine or a
// generated column depends on the column. Indexes, constraints and
// statistics objects are rebuilt instead and are not read.
func (c *Catalog) ColumnDependents(ctx context.Context) (map[string][]Dependent, error) {
q := `
SELECT DISTINCT
tn.nspname,
t.relname,
a.attname,
CASE
WHEN dc.relkind = 'v' THEN 'view'
WHEN dc.relkind = 'm' THEN 'materialized view'
WHEN d.classid = 'pg_catalog.pg_attrdef'::regclass THEN 'generated column'
ELSE (oi).type
END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dn.nspname END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dc.relname END,
ga.attname,
(oi).identity
FROM
pg_catalog.pg_depend d
JOIN pg_catalog.pg_class t ON t.oid = d.refobjid
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace
JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.classid, d.objid, 0) oi
LEFT JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid AND d.classid = 'pg_catalog.pg_rewrite'::regclass
LEFT JOIN pg_catalog.pg_class dc ON dc.oid = r.ev_class
LEFT JOIN pg_catalog.pg_namespace dn ON dn.oid = dc.relnamespace
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_catalog.pg_attrdef'::regclass
LEFT JOIN pg_catalog.pg_attribute ga ON ga.attrelid = ad.adrelid AND ga.attnum = ad.adnum
WHERE
d.refclassid = 'pg_catalog.pg_class'::regclass
AND d.refobjsubid > 0
AND d.deptype = 'n'
AND d.classid IN (
'pg_catalog.pg_rewrite'::regclass,
'pg_catalog.pg_trigger'::regclass,
'pg_catalog.pg_policy'::regclass,
'pg_catalog.pg_proc'::regclass,
'pg_catalog.pg_attrdef'::regclass
)
AND t.relkind IN ('r', 'p')
AND tn.nspname = ANY(@schemas)
ORDER BY
1, 2, 3, 4, 5, 6, 7, 8
`
dependents := map[string][]Dependent{}
var targetSchema, targetName, column, kind, identity string
var depSchema, depRelation, generated *string
err := c.eachRow(ctx, "column dependents", q, []any{&targetSchema, &targetName, &column, &kind, &depSchema, &depRelation, &generated, &identity}, func() {
target := model.Ident(targetSchema, targetName)
dep := Dependent{Kind: kind, Name: identity}
switch {
case depRelation != nil:
dep.Name = model.Ident(*depSchema, *depRelation)
dep.Relation = dep.Name
case generated != nil:
dep.Name = target + "." + model.Ident(*generated)
}
key := target + "." + model.Ident(column)
dependents[key] = append(dependents[key], dep)
})
if err != nil {
return nil, err
}
return dependents, nil
}
// KeyDependents reads what blocks dropping a key or a plain index in the
// managed schemas, keyed by <table>.<constraint> and by index name. A foreign
// key depends on the referenced key's index, and a view that groups by a
// primary key depends on the constraint.
func (c *Catalog) KeyDependents(ctx context.Context) (map[string][]Dependent, map[string][]Dependent, error) {
q := `
WITH
targets AS (
SELECT
con.oid AS con_oid,
con.conindid AS idx_oid,
n.nspname,
t.relname AS table_name,
con.conname AS name,
true AS is_constraint
FROM
pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class t ON t.oid = con.conrelid
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
WHERE
con.contype IN ('p', 'u', 'x')
AND n.nspname = ANY(@schemas)
UNION ALL
SELECT
NULL,
i.indexrelid,
n.nspname,
t.relname,
ci.relname,
false
FROM
pg_catalog.pg_index i
JOIN pg_catalog.pg_class ci ON ci.oid = i.indexrelid
JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
JOIN pg_catalog.pg_namespace n ON n.oid = ci.relnamespace
WHERE
n.nspname = ANY(@schemas)
-- A key's own index is reached through the key. A foreign
-- key's conindid names the index it references, so it is
-- not an owner.
AND NOT EXISTS (
SELECT FROM pg_catalog.pg_constraint con
WHERE con.conindid = i.indexrelid AND con.contype IN ('p', 'u', 'x')
)
)
SELECT DISTINCT
tg.is_constraint,
tg.nspname,
tg.table_name,
tg.name,
CASE
WHEN dc.relkind = 'v' THEN 'view'
WHEN dc.relkind = 'm' THEN 'materialized view'
WHEN fk.contype = 'f' THEN 'foreign key'
ELSE (oi).type
END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dn.nspname END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dc.relname END,
CASE WHEN fk.contype = 'f' THEN fkn.nspname END,
CASE WHEN fk.contype = 'f' THEN fkt.relname END,
CASE WHEN fk.contype = 'f' THEN fk.conname END,
(oi).identity
FROM
targets tg
JOIN pg_catalog.pg_depend d ON d.deptype = 'n' AND (
(d.refclassid = 'pg_catalog.pg_class'::regclass AND d.refobjid = tg.idx_oid)
OR (d.refclassid = 'pg_catalog.pg_constraint'::regclass AND d.refobjid = tg.con_oid)
)
CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.classid, d.objid, 0) oi
LEFT JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid AND d.classid = 'pg_catalog.pg_rewrite'::regclass
LEFT JOIN pg_catalog.pg_class dc ON dc.oid = r.ev_class
LEFT JOIN pg_catalog.pg_namespace dn ON dn.oid = dc.relnamespace
LEFT JOIN pg_catalog.pg_constraint fk ON fk.oid = d.objid AND d.classid = 'pg_catalog.pg_constraint'::regclass
LEFT JOIN pg_catalog.pg_class fkt ON fkt.oid = fk.conrelid
LEFT JOIN pg_catalog.pg_namespace fkn ON fkn.oid = fkt.relnamespace
WHERE
-- A partition's copy of a foreign key goes with the parent's,
-- which is listed on its own.
fk.conparentid IS NULL OR fk.conparentid = 0
ORDER BY
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
`
constraints := map[string][]Dependent{}
indexes := map[string][]Dependent{}
var isConstraint bool
var targetSchema, targetTable, targetName, kind, identity string
var viewSchema, viewName, fkSchema, fkTable, fkName *string
dest := []any{
&isConstraint, &targetSchema, &targetTable, &targetName, &kind,
&viewSchema, &viewName, &fkSchema, &fkTable, &fkName, &identity,
}
err := c.eachRow(ctx, "key dependents", q, dest, func() {
dep := Dependent{Kind: kind, Name: identity}
switch {
case viewName != nil:
dep.Name = model.Ident(*viewSchema, *viewName)
dep.Relation = dep.Name
case fkName != nil:
dep.Constraint = model.Ident(*fkSchema, *fkTable) + "." + model.Ident(*fkName)
}
if isConstraint {
key := model.Ident(targetSchema, targetTable) + "." + model.Ident(targetName)
constraints[key] = append(constraints[key], dep)
} else {
key := model.Ident(targetSchema, targetName)
indexes[key] = append(indexes[key], dep)
}
})
if err != nil {
return nil, nil, err
}
return constraints, indexes, nil
}
// RoutineDependents reads what blocks dropping each routine in the managed
// schemas, keyed by the routine's OID.
func (c *Catalog) RoutineDependents(ctx context.Context) (map[uint32][]Dependent, error) {
q := `
SELECT DISTINCT
p.oid,
CASE
WHEN dc.relkind = 'v' THEN 'view'
WHEN dc.relkind = 'm' THEN 'materialized view'
WHEN ga.attgenerated <> '' THEN 'generated column'
ELSE (oi).type
END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dn.nspname END,
CASE WHEN dc.relkind IN ('v', 'm') THEN dc.relname END,
CASE WHEN ga.attgenerated <> '' THEN gn.nspname END,
CASE WHEN ga.attgenerated <> '' THEN gt.relname END,
CASE WHEN ga.attgenerated <> '' THEN ga.attname END,
(oi).identity
FROM
pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
JOIN pg_catalog.pg_depend d ON d.refclassid = 'pg_catalog.pg_proc'::regclass
AND d.refobjid = p.oid
AND d.deptype = 'n'
CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.classid, d.objid, 0) oi
LEFT JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid AND d.classid = 'pg_catalog.pg_rewrite'::regclass
LEFT JOIN pg_catalog.pg_class dc ON dc.oid = r.ev_class
LEFT JOIN pg_catalog.pg_namespace dn ON dn.oid = dc.relnamespace
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_catalog.pg_attrdef'::regclass
LEFT JOIN pg_catalog.pg_attribute ga ON ga.attrelid = ad.adrelid AND ga.attnum = ad.adnum
LEFT JOIN pg_catalog.pg_class gt ON gt.oid = ga.attrelid
LEFT JOIN pg_catalog.pg_namespace gn ON gn.oid = gt.relnamespace
WHERE
n.nspname = ANY(@schemas)
ORDER BY
1, 2, 3, 4, 5, 6, 7, 8
`
dependents := map[uint32][]Dependent{}
var oid uint32
var kind, identity string
var viewSchema, viewName, genSchema, genTable, genColumn *string
err := c.eachRow(ctx, "routine dependents", q, []any{&oid, &kind, &viewSchema, &viewName, &genSchema, &genTable, &genColumn, &identity}, func() {
dep := Dependent{Kind: kind, Name: identity}
switch {
case viewName != nil:
dep.Name = model.Ident(*viewSchema, *viewName)
dep.Relation = dep.Name
case genColumn != nil:
dep.Name = model.Ident(*genSchema, *genTable) + "." + model.Ident(*genColumn)
}
dependents[oid] = append(dependents[oid], dep)
})
if err != nil {
return nil, err
}
return dependents, nil
}
// eachRow runs q over the managed schemas, scans each row into dest and calls
// fn after each scan. what names the query in an error.
func (c *Catalog) eachRow(ctx context.Context, what, q string, dest []any, fn func()) error {
rows, err := c.conn.Query(ctx, q, pgx.NamedArgs{"schemas": c.schemas})
if err != nil {
return fmt.Errorf("catalog: failed to get %s: %w", what, err)
}
defer rows.Close()
for rows.Next() {
if err := rows.Scan(dest...); err != nil {
return fmt.Errorf("catalog: failed to scan %s: %w", what, err)
}
fn()
}
if err := rows.Err(); err != nil {
return fmt.Errorf("catalog: failed to scan %s rows: %w", what, err)
}
return nil
}
package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) Domains(ctx context.Context) (*orderedmap.Map[string, *model.Domain], error) {
domains, err := c.ListDomains(ctx)
if err != nil {
return nil, err
}
domainByKey := orderedmap.New[string, *model.Domain]()
for _, d := range domains {
domainByKey.Set(d.FQDN(), d)
}
return domainByKey, nil
}
func (c *Catalog) ListDomains(ctx context.Context) ([]*model.Domain, error) {
q := `
WITH
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
t.oid,
n.nspname,
t.typname,
pg_catalog.format_type(t.typbasetype, t.typtypmod) AS base_type,
t.typnotnull,
pg_catalog.pg_get_expr(t.typdefaultbin, 0) AS default_value,
-- A domain over a collated type inherits that collation rather than
-- declaring one, and a schema file writes only what was declared.
-- Comparing against the base type's collation, the way columns.go
-- does, reports only a collation the domain itself sets.
(SELECT quote_ident(cn.nspname) || '.' || quote_ident(c.collname) FROM pg_catalog.pg_collation c JOIN pg_catalog.pg_namespace cn ON cn.oid = c.collnamespace WHERE c.oid = t.typcollation AND t.typcollation <> bt.typcollation) AS collation,
d.description
FROM
pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
JOIN pg_catalog.pg_type bt ON bt.oid = t.typbasetype
LEFT JOIN pg_catalog.pg_description d ON d.objoid = t.oid
AND d.classoid = 'pg_type'::regclass
AND d.objsubid = 0
LEFT JOIN dependency_extension de ON de.objid = t.oid
WHERE
t.typtype = 'd'
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
ORDER BY
n.nspname,
t.typname
`
var domains []*model.Domain
var dom model.Domain
dest := []any{
&dom.OID,
&dom.Schema,
&dom.Name,
&dom.BaseType,
&dom.NotNull,
&dom.Default,
&dom.Collation,
&dom.Comment,
}
err := c.eachRow(ctx, "domain info", q, dest, func() {
d := dom
domains = append(domains, &d)
})
if err != nil {
return nil, err
}
oids := make([]uint32, len(domains))
for i, d := range domains {
oids[i] = d.OID
}
constraintsByDomain, err := c.listDomainConstraints(ctx, oids)
if err != nil {
return nil, err
}
for _, d := range domains {
d.Constraints = constraintsByDomain[d.OID]
}
return domains, nil
}
// listDomainConstraints returns the check constraints of the given domains,
// keyed by domain OID and in conname order. One query serves every domain.
func (c *Catalog) listDomainConstraints(ctx context.Context, domainOIDs []uint32) (map[uint32][]*model.DomainConstraint, error) {
q := `
SELECT
con.contypid,
con.conname,
pg_catalog.pg_get_constraintdef(con.oid) AS definition,
con.convalidated,
descr.description AS comment
FROM
pg_catalog.pg_constraint con
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = con.oid
AND descr.classoid = 'pg_constraint'::regclass
AND descr.objsubid = 0
WHERE
con.contypid = ANY(@domain_oids::oid[])
AND con.contype = 'c'
ORDER BY
con.contypid,
con.conname
`
constraints := map[uint32][]*model.DomainConstraint{}
if len(domainOIDs) == 0 {
return constraints, nil
}
args := pgx.NamedArgs{
"domain_oids": domainOIDs,
}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get domain constraints: %w", err)
}
defer rows.Close()
for rows.Next() {
var dc model.DomainConstraint
var domainOID uint32
err := rows.Scan(&domainOID, &dc.Name, &dc.Definition, &dc.Validated, &dc.Comment)
if err != nil {
return nil, fmt.Errorf("catalog: failed to scan domain constraint: %w", err)
}
// pg_get_constraintdef appends "NOT VALID" for unvalidated
// constraints. Strip it so Definition only holds the constraint body;
// validation state is tracked via Validated.
dc.Definition = strings.TrimSuffix(dc.Definition, " NOT VALID")
constraints[domainOID] = append(constraints[domainOID], &dc)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan domain constraint rows: %w", err)
}
return constraints, nil
}
package catalog
import (
"context"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) Enums(ctx context.Context) (*orderedmap.Map[string, *model.Enum], error) {
enums, err := c.ListEnums(ctx)
if err != nil {
return nil, err
}
enumByKey := orderedmap.New[string, *model.Enum]()
for _, e := range enums {
enumByKey.Set(e.FQEN(), e)
}
return enumByKey, nil
}
func (c *Catalog) ListEnums(ctx context.Context) ([]*model.Enum, error) {
q := `
WITH
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
t.oid,
n.nspname,
t.typname,
array_remove(array_agg(e.enumlabel ORDER BY e.enumsortorder), NULL) AS vals,
d.description
FROM
pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
-- An enum with no labels has no pg_enum row. An inner join would
-- drop the type from the result, so every plan would create it
-- again and the second apply would fail.
LEFT JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid
LEFT JOIN pg_catalog.pg_description d ON d.objoid = t.oid
AND d.classoid = 'pg_type'::regclass
AND d.objsubid = 0
LEFT JOIN dependency_extension de ON de.objid = t.oid
WHERE
t.typtype = 'e'
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
GROUP BY
t.oid,
n.nspname,
t.typname,
d.description
ORDER BY
n.nspname,
t.typname
`
var enums []*model.Enum
var e model.Enum
err := c.eachRow(ctx, "enum info", q, []any{&e.OID, &e.Schema, &e.Name, &e.Values, &e.Comment}, func() {
enum := e
enums = append(enums, &enum)
})
if err != nil {
return nil, err
}
return enums, nil
}
package catalog
import (
"context"
"strings"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) ListIndexes(ctx context.Context) ([]*model.Index, error) {
q := `
-- https://www.postgresql.org/docs/current/catalog-pg-constraint.html
WITH
constraint_t AS (
SELECT DISTINCT
con.conindid
FROM
pg_catalog.pg_constraint con
WHERE
con.contype IN ('p', 'u', 'x')
),
-- https://www.postgresql.org/docs/current/catalog-pg-depend.html
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
ci.oid,
n.nspname,
ci.relname AS name,
ct.relname AS table,
pg_catalog.pg_get_indexdef(i.indexrelid) AS definition,
ts.spcname,
descr.description AS comment,
ci.relispartition AS attached,
ct.relkind = 'p' AS on_partitioned,
NOT i.indisvalid AS invalid
FROM
-- https://www.postgresql.org/docs/current/catalog-pg-index.html
pg_catalog.pg_index i
JOIN pg_catalog.pg_class ci ON ci.oid = i.indexrelid
JOIN pg_catalog.pg_class ct ON ct.oid = i.indrelid
JOIN pg_catalog.pg_namespace n ON n.oid = ci.relnamespace
LEFT JOIN pg_catalog.pg_tablespace ts ON ts.oid = ci.reltablespace
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = ci.oid
AND descr.classoid = 'pg_class'::regclass
AND descr.objsubid = 0
LEFT JOIN dependency_extension de ON de.objid = ci.oid
LEFT JOIN constraint_t con ON con.conindid = ci.oid
WHERE
n.nspname = ANY(@schemas)
AND de.objid IS NULL
AND con.conindid IS NULL
AND i.indislive
ORDER BY
n.nspname,
ct.relname,
ci.relname
`
var indexes []*model.Index
var idx model.Index
var onPartitioned bool
dest := []any{
&idx.OID,
&idx.Schema,
&idx.Name,
&idx.Table,
&idx.Definition,
&idx.TableSpace,
&idx.Comment,
&idx.Attached,
&onPartitioned,
&idx.Invalid,
}
err := c.eachRow(ctx, "index info", q, dest, func() {
i := idx
if onPartitioned {
// pg_get_indexdef writes ONLY for every index on a partitioned
// table. Without it, the index attaches the partitions' matching
// indexes when the plan creates it after them.
i.Definition = strings.Replace(i.Definition, " ON ONLY ", " ON ", 1)
}
indexes = append(indexes, &i)
})
if err != nil {
return nil, err
}
return indexes, nil
}
package catalog
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/model"
)
// ListPoliciesByTables returns the row-level security policies attached to the
// given tables, keyed by table OID and in pg_policy.polname order. One query
// serves every table.
func (c *Catalog) ListPoliciesByTables(ctx context.Context, tables []*model.Table) (map[uint32][]*model.Policy, error) {
// pg_policy.polroles uses OID 0 to represent PUBLIC. pg_roles does not
// contain a row for OID 0, so a join would silently drop PUBLIC. UNION the
// synthetic "public" entry with the named-role rows so all elements are
// preserved and sorted together. PostgreSQL stores PUBLIC alone even when
// the statement names other roles beside it.
q := `
SELECT
pol.polrelid,
pol.polname,
pol.polpermissive,
pol.polcmd,
COALESCE(
(
SELECT array_agg(rolname ORDER BY rolname)
FROM (
SELECT 'public'::name AS rolname
WHERE 0 = ANY(pol.polroles)
UNION
SELECT r.rolname
FROM pg_catalog.pg_roles r
WHERE r.oid = ANY(pol.polroles)
) AS roles
),
ARRAY[]::name[]
) AS roles,
pg_catalog.pg_get_expr(pol.polqual, pol.polrelid) AS using_expr,
pg_catalog.pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check,
descr.description AS comment
FROM
-- https://www.postgresql.org/docs/current/catalog-pg-policy.html
pg_catalog.pg_policy pol
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = pol.oid
AND descr.classoid = 'pg_policy'::regclass
AND descr.objsubid = 0
WHERE
pol.polrelid = ANY(@table_oids::oid[])
ORDER BY
pol.polrelid,
pol.polname
`
policies := map[uint32][]*model.Policy{}
oids := tableOIDs(tables)
if len(oids) == 0 {
return policies, nil
}
tableByOID := tablesByOID(tables)
args := pgx.NamedArgs{"table_oids": oids}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get policy info: %w", err)
}
defer rows.Close()
for rows.Next() {
var tableOID uint32
p := &model.Policy{}
err := rows.Scan(
&tableOID,
&p.Name,
&p.Permissive,
&p.Command,
&p.Roles,
&p.Using,
&p.WithCheck,
&p.Comment,
)
if err != nil {
return nil, fmt.Errorf("catalog: failed to scan policy info: %w", err)
}
table := tableByOID[tableOID]
p.Schema = table.Schema
p.Table = table.Name
policies[tableOID] = append(policies[tableOID], p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan policy info rows: %w", err)
}
return policies, nil
}
package catalog
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
// Routines returns the managed functions and procedures in the filtered
// schemas, keyed by FQRN.
func (c *Catalog) Routines(ctx context.Context) (*orderedmap.Map[string, *model.Routine], error) {
routines, err := c.ListRoutines(ctx)
if err != nil {
return nil, err
}
byKey := orderedmap.New[string, *model.Routine]()
for _, r := range routines {
byKey.Set(r.FQRN(), r)
}
return byKey, nil
}
// ListRoutines returns the functions and procedures pistachio manages.
//
// Each row is read as the CREATE statement pg_get_functiondef renders and
// handed to the same parser the desired schema goes through, so both sides of
// the diff produce the same model and render through the same SQL writer. That
// is what keeps a dump fed back as the desired schema planning clean, without
// the definition-text normalization views and triggers need.
//
// Aggregates (prokind 'a') and window functions (prokind 'w') are left out.
// The parser skips the same ones, so neither side of the diff sees them.
//
// A routine an extension owns (deptype 'e') is left out as well, and so is one
// that is part of another object (deptype 'i'), such as the constructors of a
// range type, which cannot be dropped on their own.
func (c *Catalog) ListRoutines(ctx context.Context) ([]*model.Routine, error) {
q := `
WITH
dependency_owned AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.classid = 'pg_proc'::regclass
AND d.deptype IN ('e', 'i')
)
SELECT
p.oid,
pg_catalog.pg_get_functiondef(p.oid) AS definition,
descr.description AS comment
FROM
pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = p.oid
AND descr.classoid = 'pg_proc'::regclass
AND descr.objsubid = 0
LEFT JOIN dependency_owned de ON de.objid = p.oid
WHERE
p.prokind IN ('f', 'p')
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
ORDER BY
n.nspname,
p.proname,
pg_catalog.pg_get_function_identity_arguments(p.oid)
`
args := pgx.NamedArgs{
"schemas": c.schemas,
}
rows, err := c.conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get routine info: %w", err)
}
defer rows.Close()
var routines []*model.Routine
for rows.Next() {
var (
oid uint32
definition string
comment *string
)
if err := rows.Scan(&oid, &definition, &comment); err != nil {
return nil, fmt.Errorf("catalog: failed to scan routine info: %w", err)
}
// pg_get_functiondef always schema-qualifies the name, so the default
// schema passed here is never consulted.
routine, err := parser.ParseRoutineDef(definition, "")
if errors.Is(err, parser.ErrUnsupportedRoutine) {
// A routine pistachio does not manage. The parser drops the same
// ones from the desired schema, so skipping the row here keeps the
// two sides in step instead of failing the whole read.
continue
}
if err != nil {
return nil, fmt.Errorf("catalog: failed to parse routine definition: %w", err)
}
routine.OID = oid
routine.Comment = comment
routines = append(routines, routine)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan routine info rows: %w", err)
}
return routines, nil
}
package catalog
import (
"context"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// Sequences returns the managed sequences in the filtered schemas, keyed by
// FQN. The sequences of identity columns and of columns read as serial belong
// to the column and are left out. Other owned sequences are included, with
// their owner.
func (c *Catalog) Sequences(ctx context.Context) (*orderedmap.Map[string, *model.Sequence], error) {
q := `
WITH
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
c.oid,
n.nspname,
c.relname,
pg_catalog.format_type(s.seqtypid, NULL) AS data_type,
s.seqstart,
s.seqmin,
s.seqmax,
s.seqincrement,
s.seqcache,
s.seqcycle,
c.relpersistence = 'u' AS unlogged,
-- PostgreSQL keeps the owning table in the sequence's schema, so
-- the bare name is enough.
rc.relname AS owner_table,
a.attname AS owner_column,
descr.description AS comment
FROM
pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
JOIN pg_catalog.pg_sequence s ON s.seqrelid = c.oid
-- deptype 'a' is OWNED BY, which serial sets up and which a user
-- can set by hand; 'i' is an identity column's sequence.
LEFT JOIN pg_catalog.pg_depend d ON d.objid = c.oid
AND d.classid = 'pg_class'::regclass
AND d.refclassid = 'pg_class'::regclass
AND d.deptype IN ('a', 'i')
LEFT JOIN pg_catalog.pg_class rc ON rc.oid = d.refobjid
LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid
AND a.attnum = d.refobjsubid
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = a.attrelid
AND ad.adnum = a.attnum
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = c.oid
AND descr.classoid = 'pg_class'::regclass
AND descr.objsubid = 0
LEFT JOIN dependency_extension de ON de.objid = c.oid
WHERE
c.relkind = 'S'
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
AND d.deptype IS DISTINCT FROM 'i'
-- The sequence of a column that catalog/columns.go reads as a
-- serial: an integer column takes its default from it, and it
-- has the default name and the default options for its type.
AND NOT COALESCE(
a.attidentity = ''
AND a.atttypid IN ('int2'::regtype, 'int4'::regtype, 'int8'::regtype)
AND c.relname = rc.relname || '_' || a.attname || '_seq'
AND (s.seqstart, s.seqincrement, s.seqmin, s.seqcache, s.seqcycle) = (1, 1, 1, 1, false)
AND s.seqmax = CASE s.seqtypid
WHEN 'int2'::regtype THEN 32767
WHEN 'int4'::regtype THEN 2147483647
ELSE 9223372036854775807
END
AND pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) = 'nextval('
|| quote_literal(c.oid::regclass::text)
|| '::regclass)',
false
)
ORDER BY
n.nspname,
c.relname
`
seqByKey := orderedmap.New[string, *model.Sequence]()
var seq model.Sequence
dest := []any{
&seq.OID,
&seq.Schema,
&seq.Name,
&seq.DataType,
&seq.Start,
&seq.Min,
&seq.Max,
&seq.Increment,
&seq.Cache,
&seq.Cycle,
&seq.Unlogged,
&seq.OwnerTable,
&seq.OwnerColumn,
&seq.Comment,
}
err := c.eachRow(ctx, "sequence info", q, dest, func() {
s := seq
seqByKey.Set(s.FQN(), &s)
})
if err != nil {
return nil, err
}
return seqByKey, nil
}
package catalog
import (
"context"
"fmt"
"math"
"time"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/model"
)
// TableStat is what pg_class remembers about a table's size. Both numbers are
// the planner's estimates, written by VACUUM, ANALYZE and CREATE INDEX rather
// than counted on demand, so reading them costs nothing on the table itself.
// Rows is -1 for a table none of those has visited yet.
//
// StatsAt is how old the estimate is: the last time a VACUUM or an ANALYZE,
// by hand or by the autovacuum daemon, visited the table. It is the zero time
// when neither has, and also when the statistics have been reset, since the
// server remembers the times no longer than the counters. CREATE INDEX writes
// reltuples without a time of its own, so an estimate can be newer than this
// says.
type TableStat struct {
Rows int64
Bytes int64
StatsAt time.Time
}
// TableStats reads the size estimate of every table and materialized view in
// the managed schemas, keyed by the same schema-qualified name the Tables and
// Views maps use. The TOAST
// relation's pages count toward the table's bytes, since a rewrite copies them
// too. A partitioned parent holds no rows of its own; the caller sums its
// partitions.
//
// The times come from the same functions pg_stat_all_tables calls, rather
// than from the view, which groups over every table in the database before a
// join can narrow it to the managed schemas. Each is a lookup in the shared
// statistics, made for the tables read here alone.
func (c *Catalog) TableStats(ctx context.Context) (map[string]TableStat, error) {
q := `
SELECT
n.nspname,
c.relname,
c.reltuples::float8,
GREATEST(c.relpages, 0) + GREATEST(COALESCE(t.relpages, 0), 0),
pg_catalog.current_setting('block_size')::bigint,
GREATEST(
pg_catalog.pg_stat_get_last_vacuum_time(c.oid),
pg_catalog.pg_stat_get_last_autovacuum_time(c.oid),
pg_catalog.pg_stat_get_last_analyze_time(c.oid),
pg_catalog.pg_stat_get_last_autoanalyze_time(c.oid)
)
FROM
pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_class t ON t.oid = c.reltoastrelid
WHERE
c.relkind IN ('r', 'p', 'm')
AND n.nspname = ANY(@schemas)
`
stats := map[string]TableStat{}
var schema, name string
var tuples float64
var pages, blockSize int64
var statsAt *time.Time
err := c.eachRow(ctx, "table stats", q, []any{&schema, &name, &tuples, &pages, &blockSize, &statsAt}, func() {
st := TableStat{Rows: -1, Bytes: pages * blockSize}
if tuples >= 0 {
st.Rows = int64(math.Round(tuples))
}
if statsAt != nil {
st.StatsAt = *statsAt
}
stats[model.Ident(schema, name)] = st
})
if err != nil {
return nil, err
}
return stats, nil
}
// IndexSizes reads the size estimate of every index in the managed schemas,
// in bytes, keyed by the schema-qualified index name. Like TableStats it is
// relpages, which VACUUM, ANALYZE and CREATE INDEX write. An index on a
// partitioned table holds nothing itself, so it reads as the sum of the
// indexes attached to it on the partitions in the managed schemas, the same
// partitions a table's size sums.
func (c *Catalog) IndexSizes(ctx context.Context) (map[string]int64, error) {
q := `
SELECT
n.nspname,
c.relname,
CASE c.relkind
WHEN 'I' THEN (
SELECT COALESCE(sum(GREATEST(l.relpages, 0)), 0)::bigint
FROM
pg_catalog.pg_partition_tree(c.oid) pt
JOIN pg_catalog.pg_class l ON l.oid = pt.relid
JOIN pg_catalog.pg_namespace ln ON ln.oid = l.relnamespace
WHERE
pt.isleaf
AND ln.nspname = ANY(@schemas)
)
ELSE GREATEST(c.relpages, 0)::bigint
END * pg_catalog.current_setting('block_size')::bigint
FROM
pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE
c.relkind IN ('i', 'I')
AND n.nspname = ANY(@schemas)
`
sizes := map[string]int64{}
var schema, name string
var bytes int64
err := c.eachRow(ctx, "index sizes", q, []any{&schema, &name, &bytes}, func() {
sizes[model.Ident(schema, name)] = bytes
})
if err != nil {
return nil, err
}
return sizes, nil
}
// TypeChange names the two sides of a column type change, each as a type name
// without a type modifier or an array bound, spelled the way the catalog or
// the desired schema wrote it.
type TypeChange struct {
Src string
Dst string
}
// TypeChangeInfo is what the server says about a TypeChange. Known is false
// when either name does not resolve to a type. A domain stands for its base
// type in SameBase, BaseType and Binary, and Constrained says the destination
// is a domain that carries a NOT NULL or a CHECK, which PostgreSQL verifies by
// rewriting the table.
type TypeChangeInfo struct {
Known bool
SameBase bool
BaseType string
Binary bool
Constrained bool
}
// TypeChanges resolves every change in one query. The names are resolved
// under the connection's search_path, which is the one the catalog printed
// them under, so an unqualified name reads back as the same type.
func (c *Catalog) TypeChanges(ctx context.Context, changes []TypeChange) (map[TypeChange]TypeChangeInfo, error) {
infos := map[TypeChange]TypeChangeInfo{}
if len(changes) == 0 {
return infos, nil
}
srcs := make([]string, len(changes))
dsts := make([]string, len(changes))
for i, ch := range changes {
srcs[i] = ch.Src
dsts[i] = ch.Dst
}
q := `
WITH
pair AS (
SELECT
p.src,
p.dst,
pg_catalog.to_regtype(p.src)::oid AS soid,
pg_catalog.to_regtype(p.dst)::oid AS doid
FROM
unnest(@srcs::text[], @dsts::text[]) AS p(src, dst)
),
resolved AS (
SELECT
p.src,
p.dst,
st.oid IS NOT NULL AND dt.oid IS NOT NULL AS known,
COALESCE(NULLIF(st.typbasetype, 0), st.oid) AS sbase,
COALESCE(NULLIF(dt.typbasetype, 0), dt.oid) AS dbase,
dt.typtype = 'd' AND (
dt.typnotnull
OR EXISTS (SELECT 1 FROM pg_catalog.pg_constraint con WHERE con.contypid = dt.oid)
) AS constrained
FROM
pair p
LEFT JOIN pg_catalog.pg_type st ON st.oid = p.soid
LEFT JOIN pg_catalog.pg_type dt ON dt.oid = p.doid
)
SELECT
r.src,
r.dst,
r.known,
COALESCE(r.sbase = r.dbase, false),
COALESCE(pg_catalog.format_type(r.dbase, NULL), ''),
COALESCE(r.constrained, false),
EXISTS (
SELECT 1
FROM pg_catalog.pg_cast ca
WHERE ca.castsource = r.sbase AND ca.casttarget = r.dbase AND ca.castmethod = 'b'
)
FROM
resolved r
`
rows, err := c.conn.Query(ctx, q, pgx.NamedArgs{"srcs": srcs, "dsts": dsts})
if err != nil {
return nil, fmt.Errorf("catalog: failed to resolve type changes: %w", err)
}
defer rows.Close()
for rows.Next() {
var ch TypeChange
var info TypeChangeInfo
if err := rows.Scan(&ch.Src, &ch.Dst, &info.Known, &info.SameBase, &info.BaseType, &info.Constrained, &info.Binary); err != nil {
return nil, fmt.Errorf("catalog: failed to scan type change: %w", err)
}
infos[ch] = info
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan type change rows: %w", err)
}
return infos, nil
}
// VolatileFunctions reports, for each function name, whether any function of
// that name is volatile. The name alone is matched, without its schema or its
// argument types: a default expression is read from the statement text, where
// the overload is not resolved, and one volatile overload is enough to make
// the answer useful.
func (c *Catalog) VolatileFunctions(ctx context.Context, names []string) (map[string]bool, error) {
volatile := map[string]bool{}
if len(names) == 0 {
return volatile, nil
}
q := `
SELECT
p.proname,
bool_or(p.provolatile = 'v')
FROM
pg_catalog.pg_proc p
WHERE
p.proname = ANY(@names)
GROUP BY
p.proname
`
rows, err := c.conn.Query(ctx, q, pgx.NamedArgs{"names": names})
if err != nil {
return nil, fmt.Errorf("catalog: failed to get function volatility: %w", err)
}
defer rows.Close()
for rows.Next() {
var name string
var v bool
if err := rows.Scan(&name, &v); err != nil {
return nil, fmt.Errorf("catalog: failed to scan function volatility: %w", err)
}
volatile[name] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: failed to scan function volatility rows: %w", err)
}
return volatile, nil
}
package catalog
import (
"context"
"fmt"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) Tables(ctx context.Context) (*orderedmap.Map[string, *model.Table], error) {
tables, err := c.ListTables(ctx)
if err != nil {
return nil, err
}
tableByKey := orderedmap.New[string, *model.Table]()
for _, t := range tables {
tableByKey.Set(t.FQTN(), t)
}
indexes, err := c.ListIndexes(ctx)
if err != nil {
return nil, err
}
for _, idx := range indexes {
if t, ok := tableByKey.GetOk(idx.FQTN()); ok {
t.Indexes.Set(idx.Name, idx)
}
}
triggers, err := c.ListTriggers(ctx)
if err != nil {
return nil, err
}
for _, trg := range triggers {
if t, ok := tableByKey.GetOk(trg.FQTN()); ok {
t.Triggers.Set(trg.Name, trg)
}
}
return tableByKey, nil
}
func (c *Catalog) ListTables(ctx context.Context) ([]*model.Table, error) {
q := `
WITH
-- https://www.postgresql.org/docs/current/catalog-pg-inherits.html
partition AS (
SELECT
i.inhrelid,
n.nspname AS parent_schema,
c.relname AS parent_name
FROM
pg_catalog.pg_inherits i
JOIN pg_catalog.pg_class c ON c.oid = i.inhparent
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE
i.inhseqno = 1
),
-- https://www.postgresql.org/docs/current/catalog-pg-depend.html
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
c.oid,
n.nspname,
c.relname,
ts.spcname,
c.relpersistence = 'u' AS unlogged,
c.relkind = 'p' AS partitioned,
pg_catalog.pg_get_partkeydef(c.oid) AS partition_def,
p.parent_schema,
p.parent_name,
pg_catalog.pg_get_expr(c.relpartbound, c.oid) AS partition_bound,
c.relrowsecurity,
c.relforcerowsecurity,
c.reloptions,
tc.reloptions AS toast_reloptions,
d.description
FROM
-- https://www.postgresql.org/docs/current/catalog-pg-class.html
pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_tablespace ts ON ts.oid = c.reltablespace
LEFT JOIN pg_catalog.pg_class tc ON tc.oid = c.reltoastrelid
LEFT JOIN partition p ON p.inhrelid = c.oid
LEFT JOIN dependency_extension de ON de.objid = c.oid
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid
AND d.classoid = 'pg_catalog.pg_class'::regclass
AND d.objsubid = 0
WHERE
c.relkind IN ('r', 'p')
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
ORDER BY
n.nspname,
c.relname
`
var tables []*model.Table
var row model.Table
var parentSchema, parentName *string
var reloptions, toastReloptions []string
dest := []any{
&row.OID,
&row.Schema,
&row.Name,
&row.TableSpace,
&row.Unlogged,
&row.Partitioned,
&row.PartitionDef,
&parentSchema,
&parentName,
&row.PartitionBound,
&row.RowSecurity,
&row.ForceRowSecurity,
&reloptions,
&toastReloptions,
&row.Comment,
}
err := c.eachRow(ctx, "table info", q, dest, func() {
t := row
// Qualify the parent with its schema so PartitionOf matches the keys
// used elsewhere (Table.FQTN, the parser). A bare relname would not
// resolve in the dependency graph, and would reference the wrong
// relation when emitted as SQL under a different search_path.
if parentName != nil {
schema := ""
if parentSchema != nil {
schema = *parentSchema
}
parent := model.Ident(schema, *parentName)
t.PartitionOf = &parent
}
t.StorageParams = storageParams(reloptions, toastReloptions)
t.Columns = orderedmap.New[string, *model.Column]()
t.Indexes = orderedmap.New[string, *model.Index]()
t.Constraints = orderedmap.New[string, *model.Constraint]()
t.ForeignKeys = orderedmap.New[string, *model.ForeignKey]()
t.Policies = orderedmap.New[string, *model.Policy]()
t.Triggers = orderedmap.New[string, *model.Trigger]()
tables = append(tables, &t)
})
if err != nil {
return nil, err
}
// Each of these is one query for all the tables, keyed by table OID.
// Asking per table cost a round trip each.
colsByTable, err := c.ListColumnsByTables(ctx, tables)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get columns: %w", err)
}
consByTable, fksByTable, err := c.ListConstraintsByTables(ctx, tables)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get constraints: %w", err)
}
policiesByTable, err := c.ListPoliciesByTables(ctx, tables)
if err != nil {
return nil, fmt.Errorf("catalog: failed to get policies: %w", err)
}
for _, t := range tables {
for _, col := range colsByTable[t.OID] {
t.Columns.Set(col.Name, col)
}
for _, con := range consByTable[t.OID] {
t.Constraints.Set(con.Name, con)
}
for _, fk := range fksByTable[t.OID] {
t.ForeignKeys.Set(fk.Name, fk)
}
for _, p := range policiesByTable[t.OID] {
t.Policies.Set(p.Name, p)
}
}
return tables, nil
}
// tableOIDs returns the pg_class OIDs the per-table readers filter on.
func tableOIDs(tables []*model.Table) []uint32 {
oids := make([]uint32, len(tables))
for i, t := range tables {
oids[i] = t.OID
}
return oids
}
// inheritsChildOIDs returns the OIDs of the INHERITS children among the tables.
// The column and constraint readers drop the entries such a child only
// inherits, so that both sides of the diff hold what its CREATE TABLE writes.
func inheritsChildOIDs(tables []*model.Table) map[uint32]bool {
oids := map[uint32]bool{}
for _, t := range tables {
if t.IsInheritsChild() {
oids[t.OID] = true
}
}
return oids
}
// tablesByOID indexes the tables by OID, for readers that need the schema and
// name of the table a row belongs to.
func tablesByOID(tables []*model.Table) map[uint32]*model.Table {
byOID := make(map[uint32]*model.Table, len(tables))
for _, t := range tables {
byOID[t.OID] = t
}
return byOID
}
// storageParams turns the two reloptions arrays a table is read with into one
// map. reloptions holds the table's own parameters, toastReloptions the TOAST
// relation's, which PostgreSQL sets and reads under a `toast.` prefix and
// which pg_dump writes into the same WITH clause. Every entry is name=value,
// including one written without a value, which PostgreSQL stores as true.
func storageParams(reloptions, toastReloptions []string) *orderedmap.Map[string, string] {
params := make(map[string]string, len(reloptions)+len(toastReloptions))
collect := func(entries []string, prefix string) {
for _, e := range entries {
name, value, _ := strings.Cut(e, "=")
params[prefix+name] = value
}
}
collect(reloptions, "")
collect(toastReloptions, "toast.")
return model.SortedStorageParams(params)
}
package catalog
import (
"context"
"github.com/winebarrel/pistachio/model"
)
// ListTriggers returns the triggers on the tables and views in the catalog's
// schemas. Both relation kinds come from one query, the way ListIndexes serves
// tables and materialized views together.
func (c *Catalog) ListTriggers(ctx context.Context) ([]*model.Trigger, error) {
q := `
WITH
-- https://www.postgresql.org/docs/current/catalog-pg-depend.html
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
n.nspname,
c.relname,
t.tgname,
-- The pretty form keeps the WHEN expression free of the parentheses
-- the plain one wraps every operator in, and adds no line breaks of
-- its own, so the definition stays on one line either way.
pg_catalog.pg_get_triggerdef(t.oid, true) AS definition,
t.tgenabled,
descr.description AS comment
FROM
-- https://www.postgresql.org/docs/current/catalog-pg-trigger.html
pg_catalog.pg_trigger t
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN dependency_extension de ON de.objid = t.oid
-- https://www.postgresql.org/docs/current/catalog-pg-description.html
LEFT JOIN pg_catalog.pg_description descr ON descr.objoid = t.oid
AND descr.classoid = 'pg_trigger'::regclass
AND descr.objsubid = 0
WHERE
n.nspname = ANY(@schemas)
AND de.objid IS NULL
-- The triggers behind a foreign key or a deferred unique
-- constraint. PostgreSQL installs and drops them with the
-- constraint, so they are not part of the schema.
AND NOT t.tgisinternal
-- A trigger on a partitioned table is cloned onto every partition
-- with tgparentid pointing back at the parent. The clone cannot be
-- dropped on its own, and the parent's definition already covers
-- it, so only the parent's row belongs to the model.
AND t.tgparentid = 0
ORDER BY
n.nspname,
c.relname,
t.tgname
`
var triggers []*model.Trigger
var trg model.Trigger
err := c.eachRow(ctx, "trigger info", q, []any{&trg.Schema, &trg.Table, &trg.Name, &trg.Definition, &trg.State, &trg.Comment}, func() {
t := trg
triggers = append(triggers, &t)
})
if err != nil {
return nil, err
}
return triggers, nil
}
package catalog
import (
"context"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
func (c *Catalog) Views(ctx context.Context) (*orderedmap.Map[string, *model.View], error) {
views, err := c.ListViews(ctx)
if err != nil {
return nil, err
}
viewByKey := orderedmap.New[string, *model.View]()
for _, v := range views {
viewByKey.Set(v.FQVN(), v)
}
// Attach indexes to materialized views
indexes, err := c.ListIndexes(ctx)
if err != nil {
return nil, err
}
for _, idx := range indexes {
fqvn := idx.FQTN()
if v, ok := viewByKey.GetOk(fqvn); ok && v.Materialized {
v.Indexes.Set(idx.Name, idx)
}
}
// INSTEAD OF triggers sit on plain views. A materialized view cannot carry
// a trigger at all, so the lookup covers both without a kind check.
triggers, err := c.ListTriggers(ctx)
if err != nil {
return nil, err
}
for _, trg := range triggers {
if v, ok := viewByKey.GetOk(trg.FQTN()); ok {
v.Triggers.Set(trg.Name, trg)
}
}
return viewByKey, nil
}
func (c *Catalog) ListViews(ctx context.Context) ([]*model.View, error) {
q := `
WITH
dependency_extension AS (
SELECT DISTINCT
d.objid
FROM
pg_catalog.pg_depend d
WHERE
d.deptype = 'e'
)
SELECT
c.oid,
n.nspname,
c.relname,
pg_catalog.pg_get_viewdef(c.oid, true) AS definition,
c.relkind = 'm' AS materialized,
(
SELECT
lower(split_part(o, '=', 2))
FROM
unnest(c.reloptions) AS o
WHERE
o LIKE 'check_option=%'
) AS check_option,
c.reloptions,
tc.reloptions AS toast_reloptions,
d.description,
cc.names AS column_comment_names,
cc.comments AS column_comments
FROM
pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_class tc ON tc.oid = c.reltoastrelid
LEFT JOIN dependency_extension de ON de.objid = c.oid
LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid
AND d.classoid = 'pg_catalog.pg_class'::regclass
AND d.objsubid = 0
CROSS JOIN LATERAL (
SELECT
coalesce(array_agg(a.attname ORDER BY a.attnum), '{}') AS names,
coalesce(array_agg(ad.description ORDER BY a.attnum), '{}') AS comments
FROM
pg_catalog.pg_attribute a
JOIN pg_catalog.pg_description ad ON ad.objoid = a.attrelid
AND ad.classoid = 'pg_catalog.pg_class'::regclass
AND ad.objsubid = a.attnum
WHERE
a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
) cc
WHERE
c.relkind IN ('v', 'm')
AND n.nspname = ANY(@schemas)
AND de.objid IS NULL
ORDER BY
n.nspname,
c.relname
`
var views []*model.View
var row model.View
var checkOption *string
var reloptions, toastReloptions []string
var columnCommentNames, columnComments []string
dest := []any{
&row.OID,
&row.Schema,
&row.Name,
&row.Definition,
&row.Materialized,
&checkOption,
&reloptions,
&toastReloptions,
&row.Comment,
&columnCommentNames,
&columnComments,
}
err := c.eachRow(ctx, "view info", q, dest, func() {
v := row
if checkOption != nil {
v.CheckOption = *checkOption
}
v.StorageParams = viewStorageParams(reloptions, toastReloptions)
v.Indexes = orderedmap.New[string, *model.Index]()
v.Triggers = orderedmap.New[string, *model.Trigger]()
v.ColumnComments = orderedmap.New[string, string]()
for i, name := range columnCommentNames {
v.ColumnComments.Set(name, columnComments[i])
}
views = append(views, &v)
})
if err != nil {
return nil, err
}
return views, nil
}
// viewStorageParams turns a view's reloptions into the parameter map, leaving
// check_option out: it sits in the same array but is read on its own and
// written as the view's WITH CHECK OPTION, so keeping it here would compare it
// twice and write it twice. A plain view has no TOAST relation, so the second
// array is empty for one; a materialized view carries it the way a table does.
func viewStorageParams(reloptions, toastReloptions []string) *orderedmap.Map[string, string] {
rest := make([]string, 0, len(reloptions))
for _, e := range reloptions {
if name, _, _ := strings.Cut(e, "="); name == "check_option" {
continue
}
rest = append(rest, e)
}
return storageParams(rest, toastReloptions)
}
package pistachio
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"slices"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/winebarrel/pistachio/model"
)
type Client struct {
*Options
}
func NewClient(options *Options) *Client {
client := &Client{
options,
}
return client
}
// validateSchemas guards every public entry point that needs to address at
// least one schema. The CLI sets Schemas to ["public"] by default via kong,
// but library callers can construct Options directly and forget it.
// catalog.NewCatalog already errors on empty Schemas, but Schemas[0] is
// also indexed in diff_all.go before that catalog call would short-circuit
// in some refactor paths; so guard explicitly with a clear message.
//
// Empty/whitespace entries are also rejected: model.Ident drops empty
// components silently, which would otherwise produce malformed DDL or
// route changes through search_path into an unintended schema.
func (client *Client) validateSchemas() error {
if len(client.Schemas) == 0 {
return errors.New("at least one schema must be specified with --schemas")
}
for _, s := range client.Schemas {
if strings.TrimSpace(s) == "" {
return errors.New("--schemas must not contain empty or whitespace-only entries")
}
}
return nil
}
func (client *Client) buildConnConfig() (*pgx.ConnConfig, error) {
cfg, err := pgx.ParseConfig(client.ConnString)
if err != nil {
return nil, fmt.Errorf("failed to parse connection string: %w", err)
}
if client.DBName != "" {
cfg.Database = client.DBName
}
if client.Password != "" {
cfg.Password = client.Password
}
return cfg, nil
}
// ConnInfoComment returns a SQL comment describing the connection target
// (host/port/dbname/user) for inclusion at the top of plan/apply/dump output.
// The password is intentionally never included.
//
// TCP connections render as a libpq URI (postgres://user@host:port/dbname).
// IPv6 hosts are bracketed via net.JoinHostPort; user and dbname are
// URL-escaped via net/url so identifiers with URI-meaningful characters
// (including '/' in the dbname) round-trip safely; Path holds the decoded
// form and RawPath the encoded form, so url.URL.String() uses RawPath when
// the default encoding would differ. libpq unix-socket connections (host
// starts with "/") render as a keyword/value string ("host=/path port=5432
// dbname=db user=u") instead; percent-encoding the socket path into the URI
// host component would be unreadable in a comment. The port stays, since it
// picks the socket file.
func (client *Client) ConnInfoComment() (string, error) {
cfg, err := client.buildConnConfig()
if err != nil {
return "", err
}
if strings.HasPrefix(cfg.Host, "/") {
return fmt.Sprintf("-- Connected to host=%s port=%d dbname=%s user=%s", cfg.Host, cfg.Port, cfg.Database, cfg.User), nil
}
u := url.URL{
Scheme: "postgres",
User: url.User(cfg.User),
Host: net.JoinHostPort(cfg.Host, strconv.Itoa(int(cfg.Port))),
Path: "/" + cfg.Database,
RawPath: "/" + url.PathEscape(cfg.Database),
}
return "-- Connected to " + u.String(), nil
}
// searchPathSQL returns the SET that puts the target schemas, plus public, on
// the search path.
//
// public is appended so a reference to an object commonly kept there
// (extension types such as citext, functions) still resolves from a non-public
// target schema; this extends the default search_path rather than replacing it.
//
// apply issues it before the managed DDL, so an unqualified user-type reference
// in a column or attribute definition resolves. plan issues it before it
// evaluates a -- pista:execute check, so the check answers the same question in
// both commands. Neither affects where objects are created: the generated DDL
// always schema-qualifies its object names.
func (client *Client) searchPathSQL() string {
quoted := make([]string, 0, len(client.Schemas)+1)
for _, s := range client.Schemas {
quoted = append(quoted, model.Ident(s))
}
if !slices.Contains(client.Schemas, "public") {
quoted = append(quoted, "public")
}
return "SET search_path TO " + strings.Join(quoted, ", ")
}
// connect opens a database connection. When readOnly is true, the session
// rejects writes, so read-only operations (plan, dump) cannot modify the
// database even by accident. apply passes false because it applies DDL.
// The read-only flag is set as a startup parameter, so it is in effect for the
// whole connection with no extra round-trip. DSQL rejects that parameter, so
// under --engine dsql the session is made read-only by a statement once the
// connection is open, which costs one round-trip.
func (client *Client) connect(ctx context.Context, readOnly bool) (*pgx.Conn, error) {
cfg, err := client.buildConnConfig()
if err != nil {
return nil, err
}
if readOnly {
if client.Engine.isDSQL() {
// Run as part of the connect, so a failure fails the connection.
cfg.AfterConnect = func(ctx context.Context, conn *pgconn.PgConn) error {
return conn.Exec(ctx, "SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY").Close()
}
} else {
cfg.RuntimeParams["default_transaction_read_only"] = "on"
}
}
// Set search_path so the catalog output follows this setting rather than a
// server-side one. See DefaultSearchPath for why the value matters. The
// default is public rather than the target schemas, which keeps an object
// outside public qualified in the dump and in the diff.
//
// apply issues its own SET later, so the DDL it runs still resolves an
// unqualified reference against the target schemas. Pre-SQL runs before that
// SET, and so under this setting.
//
// A startup parameter costs no extra round-trip. An invalid value fails the
// connection with the server's own message.
cfg.RuntimeParams["search_path"] = client.searchPath()
conn, err := pgx.ConnectConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
return conn, nil
}
package command
import (
"bytes"
"context"
"fmt"
"io"
"time"
"github.com/winebarrel/pistachio"
)
type Apply struct {
pistachio.Options
pistachio.ApplyOptions
}
// AfterApply refuses the options the engine cannot use.
func (cmd *Apply) AfterApply() error {
return pistachio.ValidateApplyEngine(cmd.Engine, &cmd.ApplyOptions)
}
func (cmd *Apply) Run(ctx context.Context, w io.Writer) error {
client := pistachio.NewClient(&cmd.Options)
// The output is buffered until the apply is done, so the header can
// carry the count. The line that says apply is waiting for another
// exclusive apply has to reach the terminal while it waits.
cmd.WaitWriter = w
var buf bytes.Buffer
result, err := client.Apply(ctx, &cmd.ApplyOptions, &buf)
if err != nil {
// Flush any partial output (e.g. transaction-state comments and SQL
// that ran before the error) so the user can see what happened.
w.Write(buf.Bytes()) //nolint:errcheck
return err
}
writeApplyResult(w, client, result, buf.Bytes())
return nil
}
// writeApplyResult prints the connection line, the header, the output apply
// buffered and the closing line.
func writeApplyResult(w io.Writer, client *pistachio.Client, result *pistachio.ApplyResult, out []byte) {
writeHeader(w, client, "Apply to", result.Count)
// The invalid-index warnings come first, as in plan.
if result.InvalidIndexes != "" {
fmt.Fprintln(w, result.InvalidIndexes) //nolint:errcheck
}
// Same ordering as Plan: executed SQL (incl. pre-SQL) first, then skipped
// DROPs as comments. When nothing was applied, skipped DROPs precede
// "-- No changes". The buffer may still hold output (e.g. --with-tx
// transaction comments) even when no schema change was applied, so the
// "-- No changes" and timing decisions are driven by result.Applied rather
// than the buffer length.
w.Write(out) //nolint:errcheck
if result.Ignored != "" {
fmt.Fprintln(w, result.Ignored) //nolint:errcheck
}
if result.DisallowedDrops != "" {
fmt.Fprintln(w, result.DisallowedDrops) //nolint:errcheck
}
if !result.Applied {
fmt.Fprintln(w, "-- No changes") //nolint:errcheck
} else {
// Only report the apply phase duration when statements were applied.
// With no changes there is nothing to time.
fmt.Fprintf(w, "-- Apply finished in %s\n", result.Duration.Round(time.Millisecond)) //nolint:errcheck
}
}
package command
import (
"bytes"
"context"
"io"
"github.com/winebarrel/pistachio"
)
// ApplyFrom takes the connection and the flags that decide how the statements
// are run. Everything that decides what is read or what is run comes from the
// plan file, so those flags are not offered here: one given on the command
// line would either be ignored or turn the plan file's own scope into drift.
type ApplyFrom struct {
pistachio.ConnOptions
pistachio.ApplyFromOptions
}
// AfterApply refuses the options the plan file's engine cannot use.
func (cmd *ApplyFrom) AfterApply() error {
return pistachio.ValidateApplyFromEngine(&cmd.ApplyFromOptions)
}
func (cmd *ApplyFrom) Run(ctx context.Context, w io.Writer) error {
client := pistachio.NewClient(&pistachio.Options{ConnOptions: cmd.ConnOptions})
// The output is buffered until the apply is done, so the header can
// carry the count. The line that says apply is waiting for another
// exclusive apply has to reach the terminal while it waits.
cmd.WaitWriter = w
var buf bytes.Buffer
result, err := client.ApplyFrom(ctx, &cmd.ApplyFromOptions, &buf)
if err != nil {
// Flush any partial output (e.g. transaction-state comments and SQL
// that ran before the error) so the user can see what happened.
w.Write(buf.Bytes()) //nolint:errcheck
return err
}
// Same ordering as apply: executed SQL first, then skipped DROPs as
// comments. Both were decided when the plan file was written.
writeApplyResult(w, client, result, buf.Bytes())
return nil
}
package command
import (
"errors"
"io"
"github.com/winebarrel/pistachio"
)
// ErrDiffChanges is returned by Diff.Run when --check is set and the diff
// contains executable DDL. main maps it to exit code 2. Suppressed drops
// alone do not trigger it.
var ErrDiffChanges = errors.New("diff contains changes")
type Diff struct {
// Schemas is the one connection option diff reads. No database is opened,
// but the parser qualifies an unqualified name with the first entry, and
// only objects in these schemas are compared, the same way plan reads the
// catalog.
Schemas []string `short:"n" env:"PISTA_SCHEMAS" default:"public" help:"Schemas to compare. Unqualified names are qualified with the first."`
pistachio.DiffOptions
Check bool `env:"PISTA_CHECK" help:"Exit with code 2 when the diff contains executable changes."`
}
// AfterApply trims each schema name the way the global option does.
func (cmd *Diff) AfterApply() error {
trimSchemas(cmd.Schemas)
return nil
}
// Validate refuses a wrong number of files while kong parses the command
// line, so the error is a usage error. No tag can say it: the count depends
// on --git.
func (cmd *Diff) Validate() error {
return cmd.ValidateFiles()
}
func (cmd *Diff) Run(w io.Writer) error {
client := pistachio.NewClient(&pistachio.Options{Schemas: cmd.Schemas})
result, err := client.Diff(&cmd.DiffOptions)
if err != nil {
return err
}
// No header comment: unlike plan there is no connection to name, and the
// object counts of a file say little, so the output is the DDL alone.
// The body is written the same way as plan.
writePlanResult(w, result)
if cmd.Check && result.HasChanges {
return ErrDiffChanges
}
return nil
}
package command
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/winebarrel/pistachio"
"github.com/winebarrel/pistachio/document"
)
type Dump struct {
pistachio.Options
pistachio.DumpOptions
}
// AfterApply refuses the options the engine cannot use.
func (cmd *Dump) AfterApply() error {
return pistachio.ValidateDumpEngine(cmd.Engine, &cmd.DumpOptions)
}
func (cmd *Dump) Run(ctx context.Context, w io.Writer) error {
client := pistachio.NewClient(&cmd.Options)
result, err := client.Dump(ctx, &cmd.DumpOptions)
if err != nil {
return err
}
// The JSON carries no header: a comment naming the connection and the
// object count would not parse, and the count is in the document itself.
if cmd.JSON {
return writeJSON(w, document.New(result.Document(), cmd.Schemas))
}
if cmd.Split == "" {
writeHeader(w, client, "Dump of", result.Count)
fmt.Fprintln(w, result) //nolint:errcheck
return nil
}
if err := os.MkdirAll(cmd.Split, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
writeHeader(w, client, "Dump of", result.Count)
count, err := writeDumpFiles(cmd.Split, result.Files())
if err != nil {
return err
}
fmt.Fprintf(w, "-- Wrote %d file(s) to %s\n", count, cmd.Split) //nolint:errcheck
return nil
}
// writeDumpFiles writes each entry of files into dir. Names are required to
// be flat basenames sitting directly under dir, so three guards are applied:
// - No path separator ("/" or "\") may appear in the name. DumpResult.Files()
// never produces nested names from real PostgreSQL identifiers; a separator
// would only come from a hostile quoted identifier such as "subdir/foo",
// and allowing it would let a pre-existing symlink under dir redirect the
// write outside dir.
// - filepath.IsLocal must accept the name, which forbids "..", absolute
// paths, empty strings, and (on Windows) reserved names.
// - The name must already be in Clean form so the on-disk filename matches
// the map key returned by DumpResult.Files(); otherwise a non-canonical
// entry could silently alias a sibling whose dedup check ran on the
// unreduced key.
func writeDumpFiles(dir string, files map[string]string) (int, error) {
count := 0
for name, content := range files {
if strings.ContainsAny(name, `/\`) || !filepath.IsLocal(name) || name != filepath.Clean(name) {
return count, fmt.Errorf("refusing to write dump file with unsafe name %q: name must be a flat basename under --split", name)
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return count, fmt.Errorf("failed to write dump file: %w", err)
}
count++
}
return count, nil
}
package command
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/winebarrel/pistachio/dsql"
"github.com/winebarrel/pistachio/format"
)
// ErrFormatDiff is returned by Fmt.Run when --check is set and a file is not
// formatted. main maps it to exit code 2, the same as plan --check.
var ErrFormatDiff = errors.New("files need formatting")
type Fmt struct {
Files []string `arg:"" help:"Path to the schema SQL file(s)."`
Check bool `env:"PISTA_FMT_CHECK" help:"Report the files that are not formatted instead of writing them. Exits with code 2 when there are any."`
// StripRenamedFrom is for the cleanup after a rename has been applied.
StripRenamedFrom bool `noconfig:"" help:"Remove every -- pista:renamed-from directive."`
// DSQLStripAsync turns a schema written for Aurora DSQL into one
// pistachio reads.
DSQLStripAsync bool `env:"PISTA_DSQL_STRIP_ASYNC" help:"Remove ASYNC from CREATE INDEX ASYNC, which Aurora DSQL takes and PostgreSQL does not."`
}
func (cmd *Fmt) Run(w io.Writer) error {
var failed, diff int
for _, path := range cmd.Files {
changed, err := cmd.formatFile(path, w)
if err != nil {
fmt.Fprintf(os.Stderr, "pista: %s: %s\n", path, err) //nolint:errcheck
failed++
continue
}
if changed {
diff++
}
}
if failed > 0 {
return fmt.Errorf("failed to format %d file(s)", failed)
}
if cmd.Check && diff > 0 {
return ErrFormatDiff
}
return nil
}
// formatFile reports whether path was not formatted already. The file is
// rewritten unless --check is set, and its name is printed either way; a file
// that is already formatted is left alone and says nothing.
//
// A symlink is resolved once, up front, and the file it points at is what is
// read and written. Renaming over the link itself would replace it with a
// plain file and leave the file it pointed at as it was, and resolving after
// the read would let a link retargeted in between carry one file's text onto
// another.
func (cmd *Fmt) formatFile(path string, w io.Writer) (bool, error) {
target, err := filepath.EvalSymlinks(path)
if err != nil {
return false, err
}
src, err := os.ReadFile(target)
if err != nil {
return false, err
}
in := string(src)
if cmd.DSQLStripAsync {
in = dsql.StripAsync(in)
}
if cmd.StripRenamedFrom {
if in, err = format.StripRenamedFrom(in); err != nil {
return false, err
}
}
out, err := format.Format(in)
if err != nil {
return false, err
}
if out == string(src) {
return false, nil
}
if !cmd.Check {
info, err := os.Stat(target)
if err != nil {
return false, err
}
if err := writeFileAtomic(target, out, info.Mode().Perm()); err != nil {
return false, err
}
}
fmt.Fprintln(w, path) //nolint:errcheck
return true, nil
}
// writeFileAtomic writes content next to path and renames it over path, so a
// failure partway through leaves the original file as it was. The checks run
// in order: a rename that follows a failed write would put a half-written file
// where the original was. The rename replaces the name, not the inode, so a
// hard link to the file keeps the old content; the atomicity and the other
// names cannot both be kept.
func writeFileAtomic(path, content string, perm os.FileMode) error {
dir, base := filepath.Dir(path), filepath.Base(path)
tmp, err := os.CreateTemp(dir, base+".pista-*")
if err != nil {
return err
}
name := tmp.Name()
defer func() {
tmp.Close() //nolint:errcheck
os.Remove(name) //nolint:errcheck
}()
if _, err := tmp.WriteString(content); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(name, perm); err != nil {
return err
}
return os.Rename(name, path)
}
package command
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"io"
"github.com/winebarrel/pistachio/model"
)
// writeJSON writes v as the JSON document pistachio prints, and the newline a
// text file ends on. Deterministic sorts the keys of a Go map, without which
// an enum's value_rename_from would come out in a different order on every
// run. model.JSONMarshalers writes what the struct tags do not say by
// themselves. parse and dump share both so one schema describes what they
// write.
func writeJSON(w io.Writer, v any) error {
if err := json.MarshalWrite(w, v, jsontext.WithIndent(" "), json.Deterministic(true), model.JSONMarshalers); err != nil {
return err
}
fmt.Fprintln(w) //nolint:errcheck
return nil
}
package command
import (
"errors"
"fmt"
"io"
"os"
"github.com/winebarrel/pistachio"
"github.com/winebarrel/pistachio/lint"
)
// ErrLintViolations is returned by Lint.Run when a rule is false for an
// object. The violations have been written already.
var ErrLintViolations = errors.New("lint rules violated")
type Lint struct {
Files []string `arg:"" help:"Path to the schema SQL file(s)."`
Rules []string `short:"r" required:"" type:"path" env:"PISTA_LINT_RULES" help:"Rule file, or directory of .yml and .yaml rule files (can be repeated)."`
// Schemas is read as parse reads it: the parser qualifies an unqualified
// name with the first entry.
Schemas []string `short:"n" env:"PISTA_SCHEMAS" default:"public" help:"Schema to qualify unqualified names with. Only the first is used."`
}
// AfterApply trims each schema name the way the global option does.
func (cmd *Lint) AfterApply() error {
trimSchemas(cmd.Schemas)
return nil
}
func (cmd *Lint) Run(w io.Writer) error {
linter, err := lint.Load(cmd.Rules, os.Stderr)
if err != nil {
return err
}
client := pistachio.NewClient(&pistachio.Options{Schemas: cmd.Schemas})
result, err := client.ParseSchema(cmd.Files)
if err != nil {
return err
}
violations, err := linter.Run(result, cmd.Schemas[:1])
if err != nil {
return err
}
for _, v := range violations {
fmt.Fprintln(w, v) //nolint:errcheck
}
if len(violations) > 0 {
return ErrLintViolations
}
return nil
}
package command
import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"sync"
)
// StartPager wraps w with a pager subprocess when all of the following hold:
// - paging hasn't been disabled by --no-pager (pager != nil && *pager == false),
// - either pager was explicitly forced via --pager (*pager == true) or w is a
// TTY (so the user is actually viewing the output interactively),
// - PISTA_PAGER is set to a non-empty command line.
//
// pager == nil means the user passed neither --pager nor --no-pager, so the
// TTY check decides. Otherwise *pager explicitly turns paging on or off, but
// an unset PISTA_PAGER still gates everything (matching the convention that
// the env var is the source of truth for *which* pager to run).
//
// Otherwise it returns w unchanged with a no-op close. The returned close
// function tears down the pager and waits for it to exit, so callers should
// always invoke it (typically via defer).
func StartPager(w io.Writer, pager *bool) (io.Writer, func(), error) {
noop := func() {}
if pager != nil && !*pager {
return w, noop, nil
}
cmdline := os.Getenv("PISTA_PAGER")
if cmdline == "" {
return w, noop, nil
}
f, ok := w.(*os.File)
if !ok {
return w, noop, nil
}
forced := pager != nil && *pager
if !forced && !isTerminalFn(f) {
return w, noop, nil
}
// PISTA_PAGER is interpreted by the platform shell: sh on Unix,
// cmd on Windows.
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", cmdline)
} else {
cmd = exec.Command("sh", "-c", cmdline)
}
cmd.Stdout = f
cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return w, noop, fmt.Errorf("opening pager pipe: %w", err)
}
if err := cmd.Start(); err != nil {
stdin.Close() //nolint:errcheck
return w, noop, fmt.Errorf("starting pager %q: %w", cmdline, err)
}
// Idempotent so callers can pair an explicit close (to drain the pager
// before a process-terminating call like kong.Context.FatalIfErrorf,
// which bypasses defers via os.Exit) with `defer closer()` for panic
// safety, without double-closing the pipe or double-waiting on the
// subprocess.
var once sync.Once
closer := func() {
once.Do(func() {
stdin.Close() //nolint:errcheck
cmd.Wait() //nolint:errcheck
})
}
return stdin, closer, nil
}
// isTerminalFn is a package-level indirection so tests can simulate a TTY
// without an actual controlling terminal.
var isTerminalFn = isTerminalDefault
func isTerminalDefault(f *os.File) bool {
if f == nil {
return false
}
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
package command
import (
"io"
"strings"
"github.com/winebarrel/pistachio"
"github.com/winebarrel/pistachio/document"
)
type Parse struct {
Files []string `arg:"" help:"Path to the schema SQL file(s)."`
// Schemas is the one connection option parse reads. No database is opened,
// but the parser qualifies an unqualified name with the first entry, the
// same way plan and apply read their input.
Schemas []string `short:"n" env:"PISTA_SCHEMAS" default:"public" help:"Schema to qualify unqualified names with. Only the first is used."`
}
// AfterApply trims each schema name the way the global option does.
func (cmd *Parse) AfterApply() error {
trimSchemas(cmd.Schemas)
return nil
}
// trimSchemas trims each schema name in place.
func trimSchemas(schemas []string) {
for i, s := range schemas {
schemas[i] = strings.TrimSpace(s)
}
}
func (cmd *Parse) Run(w io.Writer) error {
client := pistachio.NewClient(&pistachio.Options{Schemas: cmd.Schemas})
result, err := client.ParseSchema(cmd.Files)
if err != nil {
return err
}
// parse qualifies names with the first schema only, so domains are looked
// up there.
return writeJSON(w, document.New(result, cmd.Schemas[:1]))
}
package command
import (
"context"
"errors"
"fmt"
"io"
"github.com/winebarrel/pistachio"
)
// ErrPlanDiff is returned by Plan.Run when --check is set and the plan
// contains executable DDL. main maps it to exit code 2. Suppressed drops
// alone do not trigger it.
var ErrPlanDiff = errors.New("plan contains changes")
type Plan struct {
pistachio.Options
pistachio.PlanOptions
Check bool `env:"PISTA_CHECK" help:"Exit with code 2 when the plan contains executable changes."`
}
// AfterApply refuses the options the engine cannot use.
func (cmd *Plan) AfterApply() error {
return pistachio.ValidatePlanEngine(cmd.Engine, &cmd.PlanOptions)
}
func (cmd *Plan) Run(ctx context.Context, w io.Writer) error {
client := pistachio.NewClient(&cmd.Options)
result, err := client.Plan(ctx, &cmd.PlanOptions)
if err != nil {
return err
}
writeHeader(w, client, "Plan for", result.Count)
writePlanResult(w, result)
if cmd.Check && result.HasChanges {
return ErrPlanDiff
}
return nil
}
// writeHeader prints the connection line and the "-- <title> <schemas>
// (<counts>)" line that open the output of plan, apply and dump.
func writeHeader(w io.Writer, client *pistachio.Client, title string, count pistachio.ObjectCount) {
if connInfo, err := client.ConnInfoComment(); err == nil {
fmt.Fprintln(w, connInfo) //nolint:errcheck
}
fmt.Fprintf(w, "-- %s %s (%s)\n", title, count.SchemaLabel(), count.Summary()) //nolint:errcheck
}
// writePlanResult prints the body of a plan or a diff. The invalid-index
// warnings come first, as comments. Executable SQL (incl. pre-SQL) follows so
// it can be piped/copied as a runnable script; skipped DROPs follow as
// informational comments. In the no-SQL case, skipped
// DROPs come before "-- No changes" so the summary line reads naturally at
// the end.
func writePlanResult(w io.Writer, result *pistachio.PlanResult) {
if result.InvalidIndexes != "" {
fmt.Fprintln(w, result.InvalidIndexes) //nolint:errcheck
}
if result.HasChanges {
fmt.Fprintln(w, result.SQL) //nolint:errcheck
}
if result.Ignored != "" {
fmt.Fprintln(w, result.Ignored) //nolint:errcheck
}
if result.DisallowedDrops != "" {
fmt.Fprintln(w, result.DisallowedDrops) //nolint:errcheck
}
if !result.HasChanges {
fmt.Fprintln(w, "-- No changes") //nolint:errcheck
}
}
package main
import (
"context"
"errors"
"io"
"os"
"runtime/debug"
"github.com/alecthomas/kong"
"github.com/winebarrel/pistachio"
"github.com/winebarrel/pistachio/cmd/command"
)
var version string
// versionString is what --version prints: the version of pista, then the
// version of pg_query_go, which parses and deparses the SQL.
func versionString() string {
var pgQueryGo string
if info, ok := debug.ReadBuildInfo(); ok {
for _, dep := range info.Deps {
if dep.Path == "github.com/pganalyze/pg_query_go/v6" {
pgQueryGo = dep.Version
}
}
}
return version + "\npg_query_go " + pgQueryGo
}
// cli holds only what every command shares. The connection flags sit on the
// commands that open a connection, so kong does not offer them to fmt, which
// reads no database, or to parse, which needs the schema name alone.
type cli struct {
Config kong.ConfigFlag `short:"C" name:"config" placeholder:"FILE" env:"PISTA_CONFIG" help:"Load options from a YAML file."`
Version kong.VersionFlag
Pager *bool `name:"pager" negatable:"" help:"Force paging via $PISTA_PAGER even when stdout is not a TTY. PISTA_PAGER must be set."`
Apply command.Apply `cmd:"" help:"Apply schema changes to the database."`
ApplyFrom command.ApplyFrom `cmd:"" help:"Apply a plan file written by plan --out. The statements are the plan's; the database is only checked for changes since."`
Plan command.Plan `cmd:"" help:"Print the schema diff SQL without applying it."`
Diff command.Diff `cmd:"" help:"Print the DDL that takes one schema SQL file to another, or one git revision of it to another. No database is read."`
Dump command.Dump `cmd:"" help:"Dump the current database schema as SQL."`
Fmt command.Fmt `cmd:"" help:"Format schema SQL files in place."`
Parse command.Parse `cmd:"" help:"Parse schema SQL files and print the result as JSON."`
Lint command.Lint `cmd:"" help:"Check schema SQL files against lint rules. No database is read."`
}
func main() {
run(os.Args[1:], os.Stdout, os.Stderr, os.Exit)
}
// run parses args and runs the command. The command writes to stdout, which
// is also where the pager writes when one is started, and stderr takes the
// usage and error messages. exit stands in for os.Exit and is not expected
// to return; a test passes one that panics.
func run(args []string, stdout, stderr io.Writer, exit func(int)) {
var cli cli
ctx := context.Background()
parser, err := kong.New(&cli,
kong.Vars{"version": versionString()},
kong.Configuration(pistachio.YAMLConfig),
kong.Writers(stdout, stderr),
kong.Exit(exit),
kong.BindTo(ctx, (*context.Context)(nil)),
kong.BindTo(stdout, (*io.Writer)(nil)),
)
if err != nil {
panic(err)
}
kctx, err := parser.Parse(args)
parser.FatalIfErrorf(err)
w, closePager, err := command.StartPager(stdout, cli.Pager)
kctx.FatalIfErrorf(err)
// Defer covers panics; the explicit closePager() below covers the
// os.Exit path inside FatalIfErrorf so the pager always finishes
// flushing before the parent exits.
defer closePager()
if w != stdout {
kctx.BindTo(w, (*io.Writer)(nil))
}
err = kctx.Run()
closePager()
// plan --check, diff --check and fmt --check report a difference, and lint
// a violated rule, as exit code 2 instead of a fatal error. The output has
// already been written.
if errors.Is(err, command.ErrPlanDiff) || errors.Is(err, command.ErrDiffChanges) || errors.Is(err, command.ErrFormatDiff) || errors.Is(err, command.ErrLintViolations) {
kctx.Exit(2)
return
}
kctx.FatalIfErrorf(err)
}
package pistachio
import (
"errors"
"fmt"
"io"
"os"
"reflect"
"slices"
"strings"
"github.com/alecthomas/kong"
"gopkg.in/yaml.v3"
)
// Built-in kong meta flags are not loadable from the config file. --help is
// matched by pointer via Application.HelpFlag; the rest by their kong type.
var metaFlagTypes = map[reflect.Type]bool{
reflect.TypeFor[kong.ConfigFlag](): true,
reflect.TypeFor[kong.VersionFlag](): true,
reflect.TypeFor[kong.ChangeDirFlag](): true,
}
// YAMLConfig is a kong.ConfigurationLoader that reads options from a YAML file.
// Keys must exactly match CLI flag names (e.g. "conn-string"); snake_case or
// camelCase variants are not accepted. Keys that do not match any flag are
// rejected. Values only apply to flags not set on the command line or by an
// environment variable.
func YAMLConfig(r io.Reader) (kong.Resolver, error) {
values := map[string]any{}
if err := yaml.NewDecoder(r).Decode(&values); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return &yamlResolver{values: values}, nil
}
type yamlResolver struct {
values map[string]any
}
func (y *yamlResolver) Resolve(_ *kong.Context, _ *kong.Path, flag *kong.Flag) (any, error) {
if isMetaFlag(flag) {
return nil, nil
}
v, ok := y.values[flag.Name]
if !ok {
return nil, nil
}
// Environment variables take precedence over the config file. Kong applies
// an env value before resolvers run, so defer to it by returning nil when
// one is set for this flag. Precedence: flag > env > config > default.
for _, env := range flag.Tag.Envs {
if _, set := os.LookupEnv(env); set {
return nil, nil
}
}
return v, nil
}
func (y *yamlResolver) Validate(app *kong.Application) error {
// configurable maps a flag name to whether the file may set it.
configurable := map[string]bool{}
collectFlagNames(app.Node, app.HelpFlag, configurable)
var unknown, commandLineOnly []string
for key := range y.values {
ok, found := configurable[key]
switch {
case !found:
unknown = append(unknown, key)
case !ok:
commandLineOnly = append(commandLineOnly, key)
}
}
if len(unknown) > 0 {
slices.Sort(unknown)
return fmt.Errorf("unknown config key(s): %s", strings.Join(unknown, ", "))
}
if len(commandLineOnly) > 0 {
slices.Sort(commandLineOnly)
return fmt.Errorf("config key(s) only for the command line: %s", strings.Join(commandLineOnly, ", "))
}
return nil
}
func collectFlagNames(node *kong.Node, help *kong.Flag, out map[string]bool) {
for _, flag := range node.Flags {
if flag == help || isMetaFlag(flag) {
continue
}
out[flag.Name] = !isCommandLineOnly(flag)
}
for _, child := range node.Children {
collectFlagNames(child, help, out)
}
}
func isMetaFlag(flag *kong.Flag) bool {
return metaFlagTypes[flag.Target.Type()]
}
// isCommandLineOnly reports a flag tagged noconfig, which the config file may
// not set. A value there would apply to every run, so such a flag has no env
// var either.
func isCommandLineOnly(flag *kong.Flag) bool {
return flag.Tag.Has("noconfig")
}
package pistachio
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/gitfile"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
type DiffOptions struct {
FilterOptions
DropPolicy
Files []string `arg:"" help:"Paths to the schema SQL files. Without --git, exactly two: the current schema and the desired schema."`
Git string `short:"g" env:"PISTA_GIT" placeholder:"RANGE" help:"Read the files from git. A..B compares the two revisions, A...B compares B against its merge base with A, and A alone compares A against the working tree."`
DisableIndexConcurrently bool `xor:"index-concurrently" env:"PISTA_DISABLE_INDEX_CONCURRENTLY" help:"Ignore CONCURRENTLY opt-ins (directive and inline) and emit plain CREATE/DROP INDEX."`
ForceIndexConcurrently bool `xor:"index-concurrently" env:"PISTA_FORCE_INDEX_CONCURRENTLY" help:"Force CONCURRENTLY on every CREATE/DROP INDEX, including pure drops."`
BulkAlter bool `env:"PISTA_BULK_ALTER" help:"Combine consecutive ALTER TABLE actions on the same table into a single statement. FK changes, RENAME, VALIDATE CONSTRAINT, RLS toggles, and skipped DROPs stay separate."`
AssumeValidated bool `env:"PISTA_ASSUME_VALIDATED" help:"Treat every table constraint, domain constraint, and foreign key as validated: ignore NOT VALID and never emit VALIDATE CONSTRAINT."`
Explain bool `env:"PISTA_EXPLAIN" help:"Comment each statement that scans or rewrites a table with what it does and what its lock blocks. No database is read, so no size is shown, and a type change or a default that calls a function reads as may rewrite."`
}
// ValidateFiles checks the number of files. Without --git it is one per side;
// with it every file is read at both ends of the range, so any number goes.
func (options *DiffOptions) ValidateFiles() error {
if options.Git == "" && len(options.Files) != 2 {
return fmt.Errorf("diff takes a current and a desired schema SQL file, got %d file(s); --git reads them from git instead", len(options.Files))
}
return nil
}
// Diff diffs two schemas without a database: the first stands in for the
// current state the catalog gives Plan, the second is the desired state.
// Without --git the two are the files named on the command line; with it both
// come out of a git repository. The output is the DDL that takes the first
// schema to the second, and nothing else: -- pista:execute statements are
// left out.
func (client *Client) Diff(options *DiffOptions) (*PlanResult, error) {
if err := client.validateSchemas(); err != nil {
return nil, err
}
current, desired, err := client.diffInput(options)
if err != nil {
return nil, err
}
// The current schema plays the catalog's role, and the catalog reads only
// the target schemas, so an object outside them is out of scope rather
// than a drop. No schema map applies: its names are already current-side.
filterDesiredBySchemas(current, client.Schemas, nil)
// Routines mirror the catalog side: read only when --manage-routine asked
// for it.
currentRoutines := orderedmap.New[string, *model.Routine]()
if options.ManageRoutine {
currentRoutines = current.Routines
}
result, err := client.diffObjects(&schemaObjects{
Tables: current.Tables,
Views: current.Views,
Enums: current.Enums,
Domains: current.Domains,
CompositeTypes: current.CompositeTypes,
Sequences: current.Sequences,
Routines: currentRoutines,
}, &diffAllOptions{
FilterOptions: options.FilterOptions,
DropPolicy: options.DropPolicy,
Desired: &desiredInput{schema: desired},
DisableIndexConcurrently: options.DisableIndexConcurrently,
ForceIndexConcurrently: options.ForceIndexConcurrently,
BulkAlter: options.BulkAlter,
AssumeValidated: options.AssumeValidated,
})
if err != nil {
return nil, err
}
if options.Explain {
result.Stmts = client.explainStmtsOffline(result.Stmts, result.CurrentTables, result.DesiredTables, result.DesiredDomains)
}
// A -- pista:execute statement is not part of the diff: it is not schema
// state, and its check SQL cannot be evaluated without a database. apply
// runs execute statements as usual.
return &PlanResult{
SQL: strings.Join(result.Stmts, "\n"),
DisallowedDrops: strings.Join(result.DisallowedDrops, "\n"),
Ignored: strings.Join(result.Ignored, "\n"),
Count: result.Count,
HasChanges: len(result.Stmts) > 0,
}, nil
}
// diffInput parses the two sides of the diff. Without --git they are the two
// files on the command line, one per side. With it every file is read at both
// ends of the range, and the schema of a side is all of its files together,
// the way plan reads the files of a desired schema.
func (client *Client) diffInput(options *DiffOptions) (*parser.ParseResult, *parser.ParseResult, error) {
if err := options.ValidateFiles(); err != nil {
return nil, nil, err
}
if options.Git == "" {
// Not wrapped: the parser's message already names the file.
current, err := parser.ParseSQLFilesWithSchema(options.Files[:1], client.Schemas[0])
if err != nil {
return nil, nil, err
}
desired, err := parser.ParseSQLFilesWithSchema(options.Files[1:], client.Schemas[0])
if err != nil {
return nil, nil, err
}
return current, desired, nil
}
revs, err := gitfile.ParseRange(options.Git)
if err != nil {
return nil, nil, err
}
currentSources, desiredSources, err := gitSources(revs, options.Files)
if err != nil {
return nil, nil, err
}
current, err := parser.ParseSQLSourcesWithSchema(currentSources, client.Schemas[0])
if err != nil {
return nil, nil, err
}
desired, err := parser.ParseSQLSourcesWithSchema(desiredSources, client.Schemas[0])
if err != nil {
return nil, nil, err
}
return current, desired, nil
}
// gitSources reads every path at both ends of the range. A path one side does
// not hold is an empty file there, which is what a file added or removed
// between the two revisions is. A path neither side holds is an error: a
// typed path that answers to nothing would otherwise read as an empty schema
// and plan a drop of everything.
func gitSources(revs *gitfile.Range, paths []string) ([]parser.Source, []parser.Source, error) {
currentSources := make([]parser.Source, 0, len(paths))
desiredSources := make([]parser.Source, 0, len(paths))
for _, path := range paths {
currentSQL, inCurrent, err := gitfile.Read(revs.Current, path)
if err != nil {
return nil, nil, err
}
var desiredSQL string
var inDesired bool
if revs.Desired == "" {
desiredSQL, inDesired, err = readWorkingTree(path)
} else {
desiredSQL, inDesired, err = gitfile.Read(revs.Desired, path)
}
if err != nil {
return nil, nil, err
}
if !inCurrent && !inDesired {
return nil, nil, fmt.Errorf("%s is in neither %s nor %s", path, revs.Current, desiredSide(revs))
}
currentSources = append(currentSources, parser.Source{Name: sourceName(revs.Current, path), SQL: currentSQL})
desiredSources = append(desiredSources, parser.Source{Name: sourceName(revs.Desired, path), SQL: desiredSQL})
}
return currentSources, desiredSources, nil
}
// readWorkingTree reads the desired side of a range that names one revision.
// A file that is not there is not an error: the revision holds it and the
// working tree does not, which is a drop.
func readWorkingTree(path string) (string, bool, error) {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("failed to read SQL file: %w", err)
}
return string(data), true, nil
}
// sourceName is what a parse error calls the file. The working tree side
// carries the path alone, the way a file on the command line does.
func sourceName(rev, path string) string {
if rev == "" {
return path
}
return rev + ":" + path
}
func desiredSide(revs *gitfile.Range) string {
if revs.Desired == "" {
return "the working tree"
}
return revs.Desired
}
package diff
import "github.com/winebarrel/pistachio/model"
// equalCollation reports whether two COLLATE clauses name the same collation.
// The catalog always qualifies the name and the desired schema may not, so the
// schema is compared only when both sides carry one. The split respects
// quoting: a collation name can contain a dot ("C.utf8"). Parts are compared
// unquoted, so the two sides need not agree on which names to quote.
func equalCollation(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
pa := splitCollation(*a)
pb := splitCollation(*b)
if len(pa) == 0 || len(pb) == 0 {
return *a == *b
}
if pa[len(pa)-1] != pb[len(pb)-1] {
return false
}
if len(pa) > 1 && len(pb) > 1 {
return pa[len(pa)-2] == pb[len(pb)-2]
}
return true
}
func splitCollation(s string) []string {
parts := model.SplitQualifiedName(s)
for i, p := range parts {
parts[i] = model.UnquoteIdent(p)
}
return parts
}
package diff
import (
"fmt"
"slices"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
type CompositeTypeDiffResult struct {
Stmts []string
DropStmts []string
DisallowedDropStmts []string
}
func DiffCompositeTypes(current, desired *orderedmap.Map[string, *model.CompositeType], dc DropChecker) (*CompositeTypeDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &CompositeTypeDiffResult{}
// Detect renames
renameStmts, current, err := detectCompositeTypeRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
// New composite types
for k, desiredCT := range desired.All() {
if _, ok := current.GetOk(k); !ok {
result.Stmts = append(result.Stmts, desiredCT.SQL())
if commentSQL := desiredCT.CommentSQL(); commentSQL != "" {
result.Stmts = append(result.Stmts, commentSQL)
}
result.Stmts = append(result.Stmts, desiredCT.AttributeCommentSQLs()...)
}
}
// Modified composite types
for k, desiredCT := range desired.All() {
currentCT, ok := current.GetOk(k)
if !ok {
continue
}
stmts, disallowed, err := diffCompositeType(k, currentCT, desiredCT, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, stmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, disallowed...)
}
// Dropped composite types. When the composite-type-drop policy disallows it,
// emit a commented DROP.
drops, skipped := dropMissing(current, desired, "TYPE", dc.IsDropAllowed("composite_type"))
result.DropStmts = append(result.DropStmts, drops...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, skipped...)
return result, nil
}
// cloneCompositeAttributes returns value copies of the attributes so renames
// applied to the working slice do not mutate the current model.
func cloneCompositeAttributes(attrs []*model.CompositeAttribute) []*model.CompositeAttribute {
out := make([]*model.CompositeAttribute, len(attrs))
for i, a := range attrs {
copied := *a
out[i] = &copied
}
return out
}
func indexCompositeAttribute(attrs []*model.CompositeAttribute, name string) int {
return slices.IndexFunc(attrs, func(a *model.CompositeAttribute) bool { return a.Name == name })
}
// diffCompositeType returns the ALTER statements to converge a composite type,
// plus any disallowed DROP ATTRIBUTE statements (gated by the composite_type
// drop policy). Attributes are matched by name after applying renames;
// attribute order is not enforced because PostgreSQL cannot reorder them and
// ADD ATTRIBUTE always appends.
func diffCompositeType(fqcn string, current, desired *model.CompositeType, dc DropChecker) ([]string, []string, error) {
var stmts []string
var disallowed []string
// Apply attribute renames first so renamed attributes are not seen as a
// drop plus add.
working := cloneCompositeAttributes(current.Attributes)
for _, da := range desired.Attributes {
if da.RenameFrom == nil || *da.RenameFrom == da.Name {
continue
}
old := *da.RenameFrom
oldIdx := indexCompositeAttribute(working, old)
if oldIdx < 0 {
if indexCompositeAttribute(working, da.Name) >= 0 {
// Already applied
continue
}
return nil, nil, fmt.Errorf("rename source attribute %s not found in composite type %s", model.Ident(old), fqcn)
}
if indexCompositeAttribute(working, da.Name) >= 0 {
return nil, nil, fmt.Errorf("cannot rename attribute %s to %s in composite type %s: destination already exists", model.Ident(old), model.Ident(da.Name), fqcn)
}
stmts = append(stmts, "ALTER TYPE "+fqcn+" RENAME ATTRIBUTE "+model.Ident(old)+" TO "+model.Ident(da.Name)+";")
working[oldIdx].Name = da.Name
}
workingByName := make(map[string]*model.CompositeAttribute, len(working))
for _, a := range working {
workingByName[a.Name] = a
}
desiredByName := make(map[string]*model.CompositeAttribute, len(desired.Attributes))
for _, a := range desired.Attributes {
desiredByName[a.Name] = a
}
// Drop attributes present in current but not desired.
ctAllowed := dc.IsDropAllowed("composite_type")
for _, wa := range working {
if _, ok := desiredByName[wa.Name]; !ok {
drop := "ALTER TYPE " + fqcn + " DROP ATTRIBUTE " + model.Ident(wa.Name) + ";"
if ctAllowed {
stmts = append(stmts, drop)
} else {
disallowed = append(disallowed, "-- skipped: "+drop)
}
}
}
// Add new attributes and alter changed types.
for _, da := range desired.Attributes {
wa, ok := workingByName[da.Name]
if !ok {
stmts = append(stmts, "ALTER TYPE "+fqcn+" ADD ATTRIBUTE "+model.Ident(da.Name)+" "+da.TypeSQL()+";")
continue
}
if !equalTypeName(wa.TypeName, da.TypeName, schemaOf(fqcn)) || !equalCollation(wa.Collation, da.Collation) {
stmts = append(stmts, "ALTER TYPE "+fqcn+" ALTER ATTRIBUTE "+model.Ident(da.Name)+" TYPE "+da.TypeSQL()+";")
}
}
// Type comment change.
if !equalPtr(current.Comment, desired.Comment) {
stmts = append(stmts, commentOnSQL("TYPE "+fqcn, desired.Comment))
}
// Attribute comment changes. The current comment is read from the working
// attribute (matched by the desired name after rename); a newly added
// attribute has no current comment.
for _, da := range desired.Attributes {
var currentComment *string
if wa, ok := workingByName[da.Name]; ok {
currentComment = wa.Comment
}
if equalPtr(currentComment, da.Comment) {
continue
}
col := model.Ident(desired.Schema, desired.Name, da.Name)
stmts = append(stmts, commentOnSQL("COLUMN "+col, da.Comment))
}
return stmts, disallowed, nil
}
// detectCompositeTypeRenames finds desired composite types with RenameFrom that
// match a current composite type.
func detectCompositeTypeRenames(current, desired *orderedmap.Map[string, *model.CompositeType]) ([]string, *orderedmap.Map[string, *model.CompositeType], error) {
return detectRenames(current, desired, "TYPE",
func(ct *model.CompositeType) (string, string, *string) { return ct.Schema, ct.Name, ct.RenameFrom },
func(old *model.CompositeType, name string) *model.CompositeType {
renamed := *old
renamed.Name = name
renamed.Attributes = cloneCompositeAttributes(old.Attributes)
return &renamed
})
}
package diff
import (
pg_query "github.com/pganalyze/pg_query_go/v6"
"google.golang.org/protobuf/proto"
)
// The folds here undo the rewrites the PostgreSQL grammar and parse analysis
// perform on the way into the catalog. pg_get_constraintdef, pg_get_expr,
// pg_get_indexdef, pg_get_viewdef and pg_get_triggerdef print the stored form,
// so a definition written with the keyword spelling never matches what comes
// back and re-creates its object on every plan. A generated column fails the
// run instead, since its expression cannot be altered in place.
//
// Each fold moves the written side onto the stored form, the direction
// PostgreSQL itself defines. None changes what the expression means, and none
// reaches the DDL: the diff emits the node it parsed.
//
// A typed literal is re-printed in its type's output form and still drifts.
// LIMITATIONS.md covers it.
// desugarBetween expands BETWEEN into the comparisons parse analysis defines it
// as, and returns nil for anything else. The four shapes follow
// transformAExprBetween (src/backend/parser/parse_expr.c):
//
// x BETWEEN a AND b -> (x >= a) AND (x <= b)
// x NOT BETWEEN a AND b -> (x < a) OR (x > b)
// x BETWEEN SYMMETRIC a AND b -> ((x >= a) AND (x <= b)) OR ((x >= b) AND (x <= a))
// x NOT BETWEEN SYMMETRIC a AND b -> ((x < a) OR (x > b)) AND ((x < b) OR (x > a))
//
// The operand appears in each comparison, as a clone: a later walk must not
// reach one copy through another.
func desugarBetween(node *pg_query.Node) *pg_query.Node {
ae := node.GetAExpr()
if ae == nil {
return nil
}
var negated, symmetric bool
switch ae.Kind {
case pg_query.A_Expr_Kind_AEXPR_BETWEEN:
case pg_query.A_Expr_Kind_AEXPR_NOT_BETWEEN:
negated = true
case pg_query.A_Expr_Kind_AEXPR_BETWEEN_SYM:
symmetric = true
case pg_query.A_Expr_Kind_AEXPR_NOT_BETWEEN_SYM:
negated, symmetric = true, true
default:
return nil
}
list := ae.Rexpr.GetList()
if ae.Lexpr == nil || list == nil || len(list.Items) != 2 {
return nil
}
x, lo, hi := ae.Lexpr, list.Items[0], list.Items[1]
// The negated form is the plain one with both operators flipped and the
// two levels of AND/OR swapped, so one pair of names covers both.
loOp, hiOp := ">=", "<="
inner, outer := pg_query.BoolExprType_AND_EXPR, pg_query.BoolExprType_OR_EXPR
if negated {
loOp, hiOp = "<", ">"
inner, outer = outer, inner
}
bounded := makeBoolExpr(inner,
makeCmpExpr(loOp, cloneNode(x), lo),
makeCmpExpr(hiOp, cloneNode(x), hi),
)
if !symmetric {
return bounded
}
return makeBoolExpr(outer, bounded, makeBoolExpr(inner,
makeCmpExpr(loOp, cloneNode(x), cloneNode(hi)),
makeCmpExpr(hiOp, cloneNode(x), cloneNode(lo)),
))
}
// desugarPatternMatch drops the keyword spelling from LIKE, ILIKE and SIMILAR
// TO. The grammar already puts the operator in the node's name (`~~`, `~~*`,
// `!~~`, `!~~*`, `~`, `!~`) and an ESCAPE clause in the right operand as a
// like_escape() or similar_to_escape() call, so all that is left is the kind
// tag that makes the deparser print the keyword. The catalog prints the
// operator, which parses as a plain one.
//
// The grammar writes the escape call pg_catalog-qualified and the catalog
// prints it bare. stripFuncSchema settles that, and the walk has already
// reached the call by the time it reaches this node.
func desugarPatternMatch(node *pg_query.Node) {
ae := node.GetAExpr()
if ae == nil {
return
}
switch ae.Kind {
case pg_query.A_Expr_Kind_AEXPR_LIKE,
pg_query.A_Expr_Kind_AEXPR_ILIKE,
pg_query.A_Expr_Kind_AEXPR_SIMILAR:
ae.Kind = pg_query.A_Expr_Kind_AEXPR_OP
}
}
// normalizeRowFormat settles `(a, b)` against `ROW(a, b)`. Both build the same
// row constructor and differ only in the field ruleutils reads to pick a
// spelling. Both sides take the explicit form, which the catalog prints and
// which a single-element row needs to stay a row.
func normalizeRowFormat(node *pg_query.Node) {
if re := node.GetRowExpr(); re != nil {
re.RowFormat = pg_query.CoercionForm_COERCE_EXPLICIT_CALL
}
}
// foldArrayComparison turns the scalar-array comparison an IN list is stored as
// back into the list: `= ANY (ARRAY[...])` for IN and `<> ALL (ARRAY[...])` for
// NOT IN, the two shapes transformAExprIn produces.
//
// The operator is part of the match. Every other one reaches the catalog as
// written, and folding it would make `x > ANY (...)` and `x > ALL (...)`
// compare equal.
func foldArrayComparison(node *pg_query.Node) {
ae := node.GetAExpr()
if ae == nil {
return
}
var op string
switch ae.Kind {
case pg_query.A_Expr_Kind_AEXPR_OP_ANY:
op = "="
case pg_query.A_Expr_Kind_AEXPR_OP_ALL:
op = "<>"
default:
return
}
if operatorName(ae.Name) != op {
return
}
arr := ae.Rexpr.GetAArrayExpr()
if arr == nil {
return
}
ae.Kind = pg_query.A_Expr_Kind_AEXPR_IN
ae.Rexpr = &pg_query.Node{
Node: &pg_query.Node_List{List: &pg_query.List{Items: arr.Elements}},
}
}
// foldSingleElementIn collapses a one-element IN list into the comparison
// parse analysis stores it as: `x IN (a)` becomes `x = a` and `x NOT IN (a)`
// becomes `x <> a` (transformAExprIn, src/backend/parser/parse_expr.c). The
// operator is already in the node's name, so only the kind and the right
// operand change.
//
// foldArrayComparison runs first, so a hand-written `= ANY (ARRAY[a])` reaches
// this through the IN form.
//
// A row on the left expands into one comparison per column instead, which an
// operator does not produce, so it is left as written. LIMITATIONS.md covers
// it.
func foldSingleElementIn(node *pg_query.Node) {
ae := node.GetAExpr()
if ae == nil || ae.Kind != pg_query.A_Expr_Kind_AEXPR_IN {
return
}
if ae.Lexpr.GetRowExpr() != nil {
return
}
list := ae.Rexpr.GetList()
if list == nil || len(list.Items) != 1 {
return
}
ae.Kind = pg_query.A_Expr_Kind_AEXPR_OP
ae.Rexpr = list.Items[0]
}
// flattenBoolExpr merges an AND argument of an AND, and an OR argument of an
// OR, into the argument list holding it.
//
// makeAndExpr and makeOrExpr (src/backend/parser/gram.y) do this while parsing,
// but only for a left operand that is a BoolExpr by then.
// `(x BETWEEN 1 AND 10) AND y` is not one, so expanding the BETWEEN afterwards
// leaves nesting the grammar would have removed, while re-parsing the catalog's
// own output flattens it. The two sides would deparse differently over the same
// expression.
//
// AND and OR are associative, so the flat list means what the nesting meant.
// The walk reaches an argument before the node holding it, so one pass
// collapses a chain of any depth.
func flattenBoolExpr(node *pg_query.Node) {
be := node.GetBoolExpr()
if be == nil || be.Boolop == pg_query.BoolExprType_NOT_EXPR {
return
}
args := make([]*pg_query.Node, 0, len(be.Args))
for _, arg := range be.Args {
if nested := arg.GetBoolExpr(); nested != nil && nested.Boolop == be.Boolop {
args = append(args, nested.Args...)
continue
}
args = append(args, arg)
}
be.Args = args
}
// operatorName returns a bare operator name, and "" for a qualified one such as
// OPERATOR(pg_catalog.=), which the folds above have no rule for.
func operatorName(name []*pg_query.Node) string {
if len(name) != 1 {
return ""
}
return name[0].GetString_().GetSval()
}
func makeCmpExpr(op string, lexpr, rexpr *pg_query.Node) *pg_query.Node {
return &pg_query.Node{Node: &pg_query.Node_AExpr{AExpr: &pg_query.A_Expr{
Kind: pg_query.A_Expr_Kind_AEXPR_OP,
Name: []*pg_query.Node{pg_query.MakeStrNode(op)},
Lexpr: lexpr,
Rexpr: rexpr,
}}}
}
func makeBoolExpr(op pg_query.BoolExprType, args ...*pg_query.Node) *pg_query.Node {
return &pg_query.Node{Node: &pg_query.Node_BoolExpr{BoolExpr: &pg_query.BoolExpr{
Boolop: op,
Args: args,
}}}
}
func cloneNode(node *pg_query.Node) *pg_query.Node {
return proto.Clone(node).(*pg_query.Node)
}
package diff
import (
"fmt"
"slices"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
type DomainDiffResult struct {
Stmts []string
DropStmts []string
DisallowedDropStmts []string
}
func DiffDomains(current, desired *orderedmap.Map[string, *model.Domain], dc DropChecker) (*DomainDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &DomainDiffResult{}
// Detect renames
renameStmts, current, err := detectDomainRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
// New domains
for k, desiredDomain := range desired.All() {
if _, ok := current.GetOk(k); !ok {
result.Stmts = append(result.Stmts, desiredDomain.SQL())
result.Stmts = append(result.Stmts, desiredDomain.NotValidConSQL()...)
if commentSQL := desiredDomain.CommentSQL(); commentSQL != "" {
result.Stmts = append(result.Stmts, commentSQL)
}
result.Stmts = append(result.Stmts, desiredDomain.ConstraintCommentSQL()...)
}
}
// Modified domains
for k, desiredDomain := range desired.All() {
currentDomain, ok := current.GetOk(k)
if !ok {
continue
}
stmts, err := diffDomain(k, currentDomain, desiredDomain)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, stmts...)
}
// Dropped domains. When the domain-drop policy disallows it, emit a commented DROP.
drops, skipped := dropMissing(current, desired, "DOMAIN", dc.IsDropAllowed("domain"))
result.DropStmts = append(result.DropStmts, drops...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, skipped...)
return result, nil
}
func diffDomain(fqdn string, current, desired *model.Domain) ([]string, error) {
var stmts []string
// Base type change is not supported by PostgreSQL ALTER DOMAIN. The
// comparison folds the modifier, so a base type the catalog reports in
// mixed case (geometry(Polygon,4326)) matches the declaration it was read
// from, and strips the domain's own schema, since format_type writes a base
// type on the search path unqualified while a file may name it in full.
if !equalTypeName(current.BaseType, desired.BaseType, schemaOf(fqdn)) {
return nil, fmt.Errorf("cannot change base type of domain %s from %s to %s: PostgreSQL does not support this", fqdn, current.BaseType, desired.BaseType)
}
// Collation change is not supported by PostgreSQL ALTER DOMAIN
if !equalCollation(current.Collation, desired.Collation) {
return nil, fmt.Errorf("cannot change collation of domain %s: PostgreSQL does not support this", fqdn)
}
// Default change (use AST comparison to handle type alias differences)
if !equalDefault(current.Default, desired.Default) {
if desired.Default != nil {
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" SET DEFAULT "+*desired.Default+";")
} else {
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" DROP DEFAULT;")
}
}
// NOT NULL change
if current.NotNull != desired.NotNull {
if desired.NotNull {
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" SET NOT NULL;")
} else {
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" DROP NOT NULL;")
}
}
// Constraint changes
stmts = append(stmts, diffDomainConstraints(fqdn, current.Constraints, desired.Constraints)...)
// Comment change
if !equalPtr(current.Comment, desired.Comment) {
stmts = append(stmts, commentOnSQL("DOMAIN "+fqdn, desired.Comment))
}
return stmts, nil
}
func diffDomainConstraints(fqdn string, current, desired []*model.DomainConstraint) []string {
var stmts []string
currentByName := make(map[string]*model.DomainConstraint)
for _, c := range current {
currentByName[c.Name] = c
}
// A validated constraint the desired schema writes NOT VALID is dropped
// and added back, as a table's is, since nothing clears the flag.
changes := make(map[string]definitionChange, len(desired))
for _, des := range desired {
if cur, ok := currentByName[des.Name]; ok {
changes[des.Name] = newDefinitionChange(equalConstraintDef(cur.Definition, des.Definition), cur.Validated, des.Validated)
}
}
// Drop removed or changed constraints. One that only needs validating is
// kept.
for _, c := range current {
if ch, ok := changes[c.Name]; ok && (!ch.changed || ch.validateOnly) {
continue
}
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" DROP CONSTRAINT "+model.Ident(c.Name)+";")
}
// Add new or changed constraints, or validate a NOT VALID one. A
// constraint added here has no comment yet.
currentComments := map[string]*string{}
for _, c := range desired {
if cur, ok := currentByName[c.Name]; ok {
ch := changes[c.Name]
if !ch.changed || ch.validateOnly {
currentComments[c.Name] = cur.Comment
}
if !ch.changed {
continue
}
if ch.validateOnly {
stmts = append(stmts, "ALTER DOMAIN "+fqdn+" VALIDATE CONSTRAINT "+model.Ident(c.Name)+";")
continue
}
}
sql := "ALTER DOMAIN " + fqdn + " ADD CONSTRAINT " + model.Ident(c.Name) + " " + c.Definition
if !c.Validated {
sql += " NOT VALID"
}
stmts = append(stmts, sql+";")
}
for _, c := range desired {
if !equalPtr(currentComments[c.Name], c.Comment) {
stmts = append(stmts, commentOnSQL("CONSTRAINT "+model.Ident(c.Name)+" ON DOMAIN "+fqdn, c.Comment))
}
}
return stmts
}
// detectDomainRenames finds desired domains with RenameFrom that match a current domain.
func detectDomainRenames(current, desired *orderedmap.Map[string, *model.Domain]) ([]string, *orderedmap.Map[string, *model.Domain], error) {
return detectRenames(current, desired, "DOMAIN",
func(d *model.Domain) (string, string, *string) { return d.Schema, d.Name, d.RenameFrom },
func(old *model.Domain, name string) *model.Domain {
renamed := *old
renamed.Name = name
renamed.Constraints = slices.Clone(old.Constraints)
return &renamed
})
}
package diff
import "github.com/winebarrel/orderedmap/v2"
// DropChecker checks whether dropping a specific object type is allowed.
type DropChecker interface {
IsDropAllowed(objectType string) bool
}
// denyAllDrops is a DropChecker that denies all drops.
type denyAllDrops struct{}
func (denyAllDrops) IsDropAllowed(string) bool { return false }
// normalizeDropChecker returns dc if non-nil, otherwise returns denyAllDrops.
func normalizeDropChecker(dc DropChecker) DropChecker {
if dc == nil {
return denyAllDrops{}
}
return dc
}
// dropMissing returns "DROP <keyword> <name>;" for each current object that
// desired lacks, in current's order. When allowed is false the statements go
// to skipped instead, commented out.
func dropMissing[V any](current, desired *orderedmap.Map[string, V], keyword string, allowed bool) (drops, skipped []string) {
for k := range current.Keys() {
if _, ok := desired.GetOk(k); !ok {
if allowed {
drops = append(drops, "DROP "+keyword+" "+k+";")
} else {
skipped = append(skipped, "-- skipped: DROP "+keyword+" "+k+";")
}
}
}
return drops, skipped
}
package diff
import (
"fmt"
"slices"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
type EnumDiffResult struct {
Stmts []string
DropStmts []string
DisallowedDropStmts []string
}
func DiffEnums(current, desired *orderedmap.Map[string, *model.Enum], dc DropChecker) (*EnumDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &EnumDiffResult{}
// Detect renames
renameStmts, current, err := detectEnumRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
// New enums
for k, desiredEnum := range desired.All() {
if _, ok := current.GetOk(k); !ok {
result.Stmts = append(result.Stmts, desiredEnum.SQL())
if commentSQL := desiredEnum.CommentSQL(); commentSQL != "" {
result.Stmts = append(result.Stmts, commentSQL)
}
}
}
// Modified enums (add new values, comment changes)
for k, desiredEnum := range desired.All() {
currentEnum, ok := current.GetOk(k)
if !ok {
continue
}
stmts, err := diffEnumValues(k, currentEnum.Values, desiredEnum.Values, desiredEnum.ValueRenameFrom)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, stmts...)
// Comment changes
if !equalPtr(currentEnum.Comment, desiredEnum.Comment) {
result.Stmts = append(result.Stmts, commentOnSQL("TYPE "+k, desiredEnum.Comment))
}
}
// Dropped enums. When the enum-drop policy disallows it, emit a commented DROP.
drops, skipped := dropMissing(current, desired, "TYPE", dc.IsDropAllowed("enum"))
result.DropStmts = append(result.DropStmts, drops...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, skipped...)
return result, nil
}
func diffEnumValues(fqen string, current, desired []string, renameFrom map[string]string) ([]string, error) {
var stmts []string
// Apply value renames first so renamed values are not seen as removals.
// RENAME VALUE keeps the value's position, so the rename is reflected
// in-place on the current side.
if len(renameFrom) > 0 {
current = slices.Clone(current)
for _, val := range desired {
old, ok := renameFrom[val]
if !ok || old == val {
continue
}
oldIdx := slices.Index(current, old)
if oldIdx < 0 {
if slices.Contains(current, val) {
// Already applied
continue
}
return nil, fmt.Errorf("rename source %s not found for enum value %s in %s", model.QuoteLiteral(old), model.QuoteLiteral(val), fqen)
}
if slices.Contains(current, val) {
return nil, fmt.Errorf("cannot rename enum value %s to %s in %s: destination already exists", model.QuoteLiteral(old), model.QuoteLiteral(val), fqen)
}
stmts = append(stmts, "ALTER TYPE "+fqen+" RENAME VALUE "+model.QuoteLiteral(old)+" TO "+model.QuoteLiteral(val)+";")
current[oldIdx] = val
}
}
// Detect removed values (not supported by PostgreSQL)
for _, val := range current {
if !slices.Contains(desired, val) {
return nil, fmt.Errorf("cannot remove enum value %s from %s: PostgreSQL does not support removing enum values", model.QuoteLiteral(val), fqen)
}
}
// Detect reordering (not supported by PostgreSQL)
if isReordered(current, desired) {
return nil, fmt.Errorf("cannot reorder enum values in %s: PostgreSQL does not support reordering enum values", fqen)
}
// Add new enum values with correct positioning.
// Maintain a working slice that tracks values added so far,
// so later insertions can reference previously added values.
working := slices.Clone(current)
for i, val := range desired {
if slices.Contains(working, val) {
continue
}
stmt := "ALTER TYPE " + fqen + " ADD VALUE " + model.QuoteLiteral(val)
after := findPrecedingExisting(desired, i, working)
if after != "" {
stmt += " AFTER " + model.QuoteLiteral(after)
// Insert into working after the anchor
idx := slices.Index(working, after)
working = slices.Insert(working, idx+1, val)
} else {
before := findFollowingExisting(desired, i, working)
if before != "" {
stmt += " BEFORE " + model.QuoteLiteral(before)
idx := slices.Index(working, before)
working = slices.Insert(working, idx, val)
} else {
working = append(working, val)
}
}
stmts = append(stmts, stmt+";")
}
return stmts, nil
}
// isReordered checks if existing values in current appear in a different
// relative order in desired. New values in desired are ignored.
func isReordered(current, desired []string) bool {
var desiredExisting []string
for _, v := range desired {
if slices.Contains(current, v) {
desiredExisting = append(desiredExisting, v)
}
}
return !slices.Equal(current, desiredExisting)
}
// findPrecedingExisting finds the closest value before index i in desired that exists in working.
func findPrecedingExisting(desired []string, i int, working []string) string {
for j := i - 1; j >= 0; j-- {
if slices.Contains(working, desired[j]) {
return desired[j]
}
}
return ""
}
// findFollowingExisting finds the closest value after index i in desired that exists in working.
func findFollowingExisting(desired []string, i int, working []string) string {
for j := i + 1; j < len(desired); j++ {
if slices.Contains(working, desired[j]) {
return desired[j]
}
}
return ""
}
package diff
import (
"slices"
"github.com/winebarrel/pistachio/model"
)
// persistenceChange is one table's logged <-> unlogged transition, kept next to
// the desired table so the ordering below can read its foreign keys.
type persistenceChange struct {
table *model.Table
stmt string
}
// diffPersistence returns the logged <-> unlogged transition for a table that
// holds storage, or nil when there is nothing to change.
//
// A partitioned table is skipped. PostgreSQL changes persistence by rewriting
// the table, and a partitioned one has nothing to rewrite, so ALTER TABLE ...
// SET LOGGED reports success and leaves relpersistence alone, on the parent and
// on the partitions under it alike; emitting it there would replan forever. The
// test is on the current table, since what decides whether the statement does
// anything is the relation already in the database. A partition holds its own
// storage and takes the transition normally.
func diffPersistence(fqtn string, current, desired *model.Table) *persistenceChange {
if current.Partitioned || current.Unlogged == desired.Unlogged {
return nil
}
stmt := "ALTER TABLE " + fqtn + " SET LOGGED;"
if desired.Unlogged {
stmt = "ALTER TABLE " + fqtn + " SET UNLOGGED;"
}
return &persistenceChange{table: desired, stmt: stmt}
}
// orderPersistenceChanges puts the transitions in an order PostgreSQL accepts.
// It refuses to leave a logged table referencing an unlogged one and checks it
// on every ALTER, so a table turning unlogged waits for the tables that
// reference it, and a table turning logged waits for the tables it references.
// The two directions therefore run in opposite orders, and the tables turning
// logged go first. Nothing is owed to a cross-direction pair. Take X
// referencing Y: X turning unlogged next to Y turning logged would need a
// logged X already referencing an unlogged Y, which is the state the rule
// forbids, so it never reaches here. The mirror, X turning logged next to Y
// turning unlogged, does reach here and asks for a logged X referencing an
// unlogged Y, which PostgreSQL rejects whichever order this picks.
//
// Only the tables that change are ordered. One that keeps its persistence is
// already on the right side of the rule, or the desired schema breaks it
// regardless. The order is built from the desired foreign keys, which are a
// superset of the ones in force at this point: the drops have already run, so
// what exists here is the current keys intersected with the desired, and a key
// that only the desired side has is added later. Every extra edge tightens an
// order that was valid without it, so the superset is safe. It bites in one
// place, which is that a key added in the same plan can close a cycle the keys
// in force do not have, and the tables in a cycle keep their input order.
func orderPersistenceChanges(changes []*persistenceChange) []string {
var toLogged, toUnlogged []*persistenceChange
for _, c := range changes {
if c.table.Unlogged {
toUnlogged = append(toUnlogged, c)
} else {
toLogged = append(toLogged, c)
}
}
stmts := make([]string, 0, len(changes))
for _, c := range sortByRefs(toLogged) {
stmts = append(stmts, c.stmt)
}
referencingFirst := sortByRefs(toUnlogged)
slices.Reverse(referencingFirst)
for _, c := range referencingFirst {
stmts = append(stmts, c.stmt)
}
return stmts
}
// sortByRefs orders the changes so a table comes after the tables it
// references, counting only references that reach another table in the same
// set. Mutual foreign keys have no valid order at all, and the tables in such
// a cycle keep the order they came in, so a cycle is not an error here and
// nothing has to detect one.
func sortByRefs(changes []*persistenceChange) []*persistenceChange {
if len(changes) < 2 {
return changes
}
index := make(map[string]int, len(changes))
for i, c := range changes {
index[c.table.FQTN()] = i
}
refs := make([][]int, len(changes))
for i, c := range changes {
for _, fk := range c.table.ForeignKeys.CollectValues() {
// A reference written without a schema arrives with one already.
// The parser fills RefSchema with the first target schema, which
// is where search_path lands a bare name, not with the owning
// table's, and the catalog reads it from pg_namespace. So there is
// no bare name left to resolve and nothing like
// toposort.resolveUnqualified to do. The nil test below is a cheap
// guard rather than part of that: a foreign key carries both
// halves or neither.
if fk.RefSchema == nil || fk.RefTable == nil {
continue
}
if j, ok := index[model.Ident(*fk.RefSchema, *fk.RefTable)]; ok && j != i {
refs[i] = append(refs[i], j)
}
}
}
visited := make([]bool, len(changes))
ordered := make([]*persistenceChange, 0, len(changes))
var visit func(int)
visit = func(i int) {
if visited[i] {
return
}
visited[i] = true
for _, j := range refs[i] {
visit(j)
}
ordered = append(ordered, changes[i])
}
for i := range changes {
visit(i)
}
return ordered
}
package diff
import (
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// diffRLS emits ALTER TABLE ... ENABLE/DISABLE/FORCE/NO FORCE ROW LEVEL
// SECURITY statements for changes to the table-level RLS flags.
func diffRLS(fqtn string, current, desired *model.Table) []string {
var stmts []string
if current.RowSecurity != desired.RowSecurity {
if desired.RowSecurity {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ENABLE ROW LEVEL SECURITY;")
} else {
stmts = append(stmts, "ALTER TABLE "+fqtn+" DISABLE ROW LEVEL SECURITY;")
}
}
if current.ForceRowSecurity != desired.ForceRowSecurity {
if desired.ForceRowSecurity {
stmts = append(stmts, "ALTER TABLE "+fqtn+" FORCE ROW LEVEL SECURITY;")
} else {
stmts = append(stmts, "ALTER TABLE "+fqtn+" NO FORCE ROW LEVEL SECURITY;")
}
}
return stmts
}
// diffPolicies emits CREATE POLICY / ALTER POLICY / DROP POLICY statements for
// changes between current and desired policies on the same table.
//
// Pure removals (policy absent from desired) honor the policy-drop policy via
// dc; definition changes still run as DROP+CREATE when a property that cannot
// be modified in place (Command / Permissive) changes. USING / WITH CHECK /
// roles changes use ALTER POLICY for an in-place update.
func diffPolicies(
fqtn string,
current, desired *orderedmap.Map[string, *model.Policy],
dc DropChecker,
) (stmts []string, disallowed []string, err error) {
dc = normalizeDropChecker(dc)
// Callers (diffTable) always pass initialized maps from parser/catalog,
// so no nil-guard is needed here.
// Detect renames first so subsequent diff steps see the renamed policy
// under its new name in the adjusted current map.
current, renamedFrom, err := detectPolicyRenames(fqtn, current, desired)
if err != nil {
return nil, nil, err
}
// Renamed policies whose definition also requires DROP+CREATE: skip the
// RENAME and let the recreate-with-new-name cover the change. Keyed by
// newName because renamedFrom is keyed by newName (unique in desired);
// concatenated keys could collide on quoted identifiers containing the
// separator.
needsRecreateRenamed := map[string]bool{}
for newName := range renamedFrom {
cur, ok := current.GetOk(newName)
des, dok := desired.GetOk(newName)
if !ok || !dok {
continue
}
if needsRecreate(cur, des) {
needsRecreateRenamed[newName] = true
}
}
// The renames follow the desired schema's order. renamedFrom is a Go map,
// so iterating it would put them in a different order on every run.
for newName := range desired.Keys() {
oldName, renamed := renamedFrom[newName]
if !renamed || needsRecreateRenamed[newName] {
continue
}
stmts = append(stmts, renamePolicySQL(fqtn, oldName, newName))
}
policyAllowed := dc.IsDropAllowed("policy")
// Drop removed or recreate-required policies first so a CREATE for the same
// name later does not conflict. When a policy was both renamed and needs
// recreation, the DROP must reference the old name because the RENAME was
// suppressed above.
for name, cur := range current.All() {
des, ok := desired.GetOk(name)
if !ok {
drop := dropPolicySQL(fqtn, name)
if !policyAllowed {
disallowed = append(disallowed, "-- skipped: "+drop)
continue
}
stmts = append(stmts, drop)
continue
}
if needsRecreate(cur, des) {
dropName := name
if oldName, renamed := renamedFrom[name]; renamed {
dropName = oldName
}
stmts = append(stmts, dropPolicySQL(fqtn, dropName))
}
}
// Add new or recreated policies, then ALTER for in-place changes.
for name, des := range desired.All() {
cur, ok := current.GetOk(name)
if !ok || needsRecreate(cur, des) {
stmts = append(stmts, des.SQL())
continue
}
if alterStmt := alterPolicySQL(fqtn, cur, des); alterStmt != "" {
stmts = append(stmts, alterStmt)
}
}
// ALTER POLICY keeps the comment. A policy created or recreated above has
// none.
for name, des := range desired.All() {
var currentComment *string
if cur, ok := current.GetOk(name); ok && !needsRecreate(cur, des) {
currentComment = cur.Comment
}
if !equalPtr(currentComment, des.Comment) {
stmts = append(stmts, commentOnSQL("POLICY "+model.Ident(name)+" ON "+fqtn, des.Comment))
}
}
return stmts, disallowed, nil
}
// needsRecreate reports whether two policies differ in a way that cannot be
// expressed via ALTER POLICY. PostgreSQL's ALTER POLICY can only set clauses;
// it cannot remove a clause that was previously set, and Command / Permissive
// are immutable in place.
func needsRecreate(a, b *model.Policy) bool {
return a.Command != b.Command ||
a.Permissive != b.Permissive ||
(a.Using != nil && b.Using == nil) ||
(a.WithCheck != nil && b.WithCheck == nil)
}
func dropPolicySQL(fqtn, name string) string {
return "DROP POLICY " + model.Ident(name) + " ON " + fqtn + ";"
}
func renamePolicySQL(fqtn, oldName, newName string) string {
return "ALTER POLICY " + model.Ident(oldName) + " ON " + fqtn + " RENAME TO " + model.Ident(newName) + ";"
}
// alterPolicySQL returns a single ALTER POLICY statement covering the
// in-place modifiable attributes (TO, USING, WITH CHECK). Returns "" if no
// in-place change is needed. The caller must have already routed clause
// removals (current set, desired nil) to DROP+CREATE via needsRecreate.
func alterPolicySQL(fqtn string, current, desired *model.Policy) string {
rolesChanged := !equalRoles(current.Roles, desired.Roles)
usingChanged := exprChanged(current.Using, desired.Using)
withCheckChanged := exprChanged(current.WithCheck, desired.WithCheck)
if !rolesChanged && !usingChanged && !withCheckChanged {
return ""
}
var b strings.Builder
b.WriteString("ALTER POLICY ")
b.WriteString(model.Ident(desired.Name))
b.WriteString(" ON ")
b.WriteString(fqtn)
if rolesChanged {
// Treat empty desired roles as PUBLIC so ALTER POLICY ... TO is always
// well-formed; PostgreSQL stores no-roles policies as PUBLIC anyway.
roles := desired.Roles
if len(roles) == 0 {
roles = []string{"public"}
}
b.WriteString(" TO ")
b.WriteString(model.RolesSQL(roles))
}
if usingChanged && desired.Using != nil {
b.WriteString(" USING (")
b.WriteString(*desired.Using)
b.WriteString(")")
}
if withCheckChanged && desired.WithCheck != nil {
b.WriteString(" WITH CHECK (")
b.WriteString(*desired.WithCheck)
b.WriteString(")")
}
b.WriteString(";")
return b.String()
}
// equalRoles compares two role lists, treating an empty list as equivalent to
// ["public"] (PostgreSQL stores both the same way).
func equalRoles(a, b []string) bool {
na := normalizeRoles(a)
nb := normalizeRoles(b)
return slices.Equal(na, nb)
}
func normalizeRoles(r []string) []string {
if len(r) == 0 {
return []string{"public"}
}
out := slices.Clone(r)
slices.Sort(out)
return out
}
// exprChanged compares two optional expression strings via equalSelectExpr.
// Used for RLS policy USING / WITH CHECK and stored-generated column
// expression comparisons; both want pg_get_expr-added casts and formatting
// noise normalised away without picking up equalDefault's DEFAULT-specific
// symmetric top-level cast strip.
func exprChanged(a, b *string) bool {
if a == nil || b == nil {
return a != b
}
return !equalSelectExpr(*a, *b)
}
// equalSelectExpr returns true if two bare expressions (parsed by wrapping
// in `SELECT <expr>`) are semantically equal under the strict asymmetric
// cast normalization: parses both via parseSelectExpr, runs
// normalizeCheckExpr symmetrically (text-like cast strip, paren cleanup,
// `IN`<->`= ANY(ARRAY[...])`), strips any current-only TypeCast via
// alignCurrentCasts, and coerces stripped numeric Sval back to Ival/Fval
// when the desired side is a bare numeric A_Const.
//
// Unlike equalDefault, this does NOT apply a symmetric top-level
// TypeCast strip and does NOT treat `'0'::bigint` == `'0'::integer` as
// equal; a non-text top-level cast (e.g. `(...)::numeric`,
// `(...)::time without time zone`), or a change to a cast's target
// type, surfaces as a real difference. Text-like casts (`::text`,
// `::varchar`) remain stripped symmetrically by normalizeCheckExpr
// because pg_get_expr adds them indiscriminately to anything
// string-typed and the user-written form practically never includes
// them. Used for RLS policy USING / WITH CHECK comparison and
// stored-generated column expression comparison, where in-place
// rewrites of the expression are either expensive (ALTER POLICY) or
// impossible (PG has no ALTER COLUMN ... SET GENERATED AS) and a
// false-equal would silently hide the change.
func equalSelectExpr(current, desired string) bool {
if current == desired {
return true
}
curResult, curTarget, parseErrCur := pgast.ParseExpr(current)
desResult, desTarget, parseErrDes := pgast.ParseExpr(desired)
if parseErrCur != nil || parseErrDes != nil {
return false
}
curTarget.Val = normalizeCheckExpr(curTarget.Val)
desTarget.Val = normalizeCheckExpr(desTarget.Val)
curTarget.Val = alignCurrentCasts(desTarget.Val, curTarget.Val)
curStr, deparseErrCur := pg_query.Deparse(curResult)
desStr, deparseErrDes := pg_query.Deparse(desResult)
return deparseErrCur == nil && deparseErrDes == nil && curStr == desStr
}
package diff
import (
"errors"
"fmt"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// detectRenames finds desired objects with RenameFrom that match a current
// object, emits ALTER <keyword> ... RENAME TO for each, and returns the current
// map with the renamed objects moved to their new keys. ident reports an
// object's schema, name and RenameFrom; rename returns a copy of old named name.
func detectRenames[V any](
current, desired *orderedmap.Map[string, V],
keyword string,
ident func(V) (schema, name string, renameFrom *string),
rename func(old V, name string) V,
) ([]string, *orderedmap.Map[string, V], error) {
var stmts []string
adjusted := current.Clone()
for newKey, desiredObj := range desired.All() {
desiredSchema, desiredName, renameFrom := ident(desiredObj)
if renameFrom == nil {
continue
}
oldKey := *renameFrom
if oldKey == newKey {
continue
}
oldObj, ok := adjusted.GetOk(oldKey)
if !ok {
if _, exists := adjusted.GetOk(newKey); exists {
continue
}
return nil, nil, fmt.Errorf("rename source %s not found for %s", oldKey, newKey)
}
if _, exists := adjusted.GetOk(newKey); exists {
return nil, nil, fmt.Errorf("cannot rename %s to %s: destination already exists", oldKey, newKey)
}
if oldSchema, _, _ := ident(oldObj); oldSchema != desiredSchema {
return nil, nil, fmt.Errorf("cannot rename %s to %s: cross-schema rename is not supported", oldKey, newKey)
}
stmts = append(stmts, "ALTER "+keyword+" "+oldKey+" RENAME TO "+model.Ident(desiredName)+";")
adjusted.Delete(oldKey)
adjusted.Set(newKey, rename(oldObj, desiredName))
}
return stmts, adjusted, nil
}
// detectEnumRenames finds desired enums with RenameFrom that match a current enum.
func detectEnumRenames(current, desired *orderedmap.Map[string, *model.Enum]) ([]string, *orderedmap.Map[string, *model.Enum], error) {
return detectRenames(current, desired, "TYPE",
func(e *model.Enum) (string, string, *string) { return e.Schema, e.Name, e.RenameFrom },
func(old *model.Enum, name string) *model.Enum {
renamed := *old
renamed.Name = name
return &renamed
})
}
// detectSequenceRenames finds desired sequences with RenameFrom that match a current sequence.
func detectSequenceRenames(current, desired *orderedmap.Map[string, *model.Sequence]) ([]string, *orderedmap.Map[string, *model.Sequence], error) {
return detectRenames(current, desired, "SEQUENCE",
func(s *model.Sequence) (string, string, *string) { return s.Schema, s.Name, s.RenameFrom },
func(old *model.Sequence, name string) *model.Sequence {
renamed := *old
renamed.Name = name
return &renamed
})
}
// detectTableRenames finds desired tables with RenameFrom that match a current table.
//
// NOTE: After a table rename, other objects that reference the old table name
// (e.g. foreign keys in other tables, view definitions) are not updated in the
// adjusted current state. PostgreSQL automatically updates these on RENAME, so
// running plan/apply a second time after a rename will produce a clean diff.
// A single plan may emit redundant DROP/CREATE for dependent objects.
func detectTableRenames(current, desired *orderedmap.Map[string, *model.Table]) ([]string, *orderedmap.Map[string, *model.Table], error) {
var stmts []string
adjusted := current.Clone()
for newKey, desiredTable := range desired.All() {
if desiredTable.RenameFrom == nil {
continue
}
oldKey := *desiredTable.RenameFrom
if oldKey == newKey {
continue
}
oldTable, ok := adjusted.GetOk(oldKey)
if !ok {
if _, exists := adjusted.GetOk(newKey); exists {
continue
}
return nil, nil, fmt.Errorf("rename source %s not found for %s", oldKey, newKey)
}
if _, exists := adjusted.GetOk(newKey); exists {
return nil, nil, fmt.Errorf("cannot rename %s to %s: destination already exists", oldKey, newKey)
}
if oldTable.Schema != desiredTable.Schema {
return nil, nil, fmt.Errorf("cannot rename %s to %s: cross-schema rename is not supported", oldKey, newKey)
}
stmts = append(stmts, "ALTER TABLE "+oldKey+" RENAME TO "+model.Ident(desiredTable.Name)+";")
adjusted.Delete(oldKey)
renamed := *oldTable
renamed.Name = desiredTable.Name
indexes, err := renameIndexesRelation(renamed.Indexes, desiredTable.Name)
if err != nil {
return nil, nil, err
}
renamed.Indexes = indexes
// Update FK table name
if renamed.ForeignKeys.Len() > 0 {
newFKs := orderedmap.New[string, *model.ForeignKey]()
for fkName, fk := range renamed.ForeignKeys.All() {
fkCopy := *fk
fkCopy.Table = desiredTable.Name
newFKs.Set(fkName, &fkCopy)
}
renamed.ForeignKeys = newFKs
}
if renamed.Triggers != nil {
newTriggers, err := renameTriggerRelation(renamed.Triggers, desiredTable.Name)
if err != nil {
return nil, nil, err
}
renamed.Triggers = newTriggers
}
adjusted.Set(newKey, &renamed)
}
return stmts, adjusted, nil
}
// renameIndexesRelation returns copies of indexes pointing at the renamed
// relation, so a rename alone does not drop and re-create them.
func renameIndexesRelation(indexes *orderedmap.Map[string, *model.Index], newName string) (*orderedmap.Map[string, *model.Index], error) {
if indexes == nil || indexes.Len() == 0 {
return indexes, nil
}
renamed := orderedmap.New[string, *model.Index]()
for idxName, idx := range indexes.All() {
idxCopy := *idx
idxCopy.Table = newName
def, err := updateIndexTableName(idx.Definition, newName)
if err != nil {
return nil, err
}
idxCopy.Definition = def
renamed.Set(idxName, &idxCopy)
}
return renamed, nil
}
// updateIndexTableName parses an index definition, updates the table name,
// and deparses it back to canonical SQL.
func updateIndexTableName(def string, newTableName string) (string, error) {
result, err := pg_query.Parse(def)
if err != nil {
return "", fmt.Errorf("failed to parse index definition: %w", err)
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil || is.Relation == nil {
return "", errors.New("failed to parse index definition: expected IndexStmt with relation")
}
is.Relation.Relname = newTableName
deparsed, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse index definition: %w", err)
}
return deparsed, nil
}
// renameTriggerRelation returns a clone of triggers moved onto the renamed
// table or view, the way the catalog reports them after RENAME TO. Without it
// the comparison reads the old relation name in each definition as a change
// and emits a redundant CREATE OR REPLACE TRIGGER, or a DROP and CREATE for a
// constraint trigger.
func renameTriggerRelation(triggers *orderedmap.Map[string, *model.Trigger], newName string) (*orderedmap.Map[string, *model.Trigger], error) {
out := orderedmap.New[string, *model.Trigger]()
for name, trg := range triggers.All() {
result, err := pg_query.Parse(trg.Definition)
if err != nil {
return nil, fmt.Errorf("failed to parse trigger definition: %w", err)
}
if len(result.Stmts) != 1 {
return nil, fmt.Errorf("unexpected parse result for trigger definition: %s", trg.Definition)
}
ct := result.Stmts[0].Stmt.GetCreateTrigStmt()
if ct == nil {
return nil, fmt.Errorf("expected CreateTrigStmt in trigger definition: %s", trg.Definition)
}
// A constraint trigger's FROM can name its own table, which the
// rename moves too. A bare name there means the same table.
if from := ct.Constrrel; from != nil && from.Relname == ct.Relation.Relname &&
(from.Schemaname == "" || from.Schemaname == ct.Relation.Schemaname) {
from.Relname = newName
}
ct.Relation.Relname = newName
def, err := pg_query.Deparse(result)
if err != nil {
return nil, fmt.Errorf("failed to deparse trigger definition: %w", err)
}
clone := *trg
clone.Table = newName
clone.Definition = def
out.Set(name, &clone)
}
return out, nil
}
// detectViewRenames finds desired views with RenameFrom that match a current view.
func detectViewRenames(current, desired *orderedmap.Map[string, *model.View]) ([]string, *orderedmap.Map[string, *model.View], map[string]string, error) {
var stmts []string
adjusted := current.Clone()
renamedFrom := map[string]string{}
for newKey, desiredView := range desired.All() {
if desiredView.RenameFrom == nil {
continue
}
oldKey := *desiredView.RenameFrom
if oldKey == newKey {
continue
}
oldView, ok := adjusted.GetOk(oldKey)
if !ok {
if _, exists := adjusted.GetOk(newKey); exists {
continue
}
return nil, nil, nil, fmt.Errorf("rename source %s not found for %s", oldKey, newKey)
}
if _, exists := adjusted.GetOk(newKey); exists {
return nil, nil, nil, fmt.Errorf("cannot rename %s to %s: destination already exists", oldKey, newKey)
}
if oldView.Schema != desiredView.Schema {
return nil, nil, nil, fmt.Errorf("cannot rename %s to %s: cross-schema rename is not supported", oldKey, newKey)
}
if oldView.Materialized != desiredView.Materialized {
return nil, nil, nil, fmt.Errorf("cannot rename %s to %s: view type mismatch (cannot rename between VIEW and MATERIALIZED VIEW)", oldKey, newKey)
}
stmts = append(stmts, "ALTER "+oldView.ObjType()+" "+oldKey+" RENAME TO "+model.Ident(desiredView.Name)+";")
adjusted.Delete(oldKey)
renamed := *oldView
renamed.Name = desiredView.Name
indexes, err := renameIndexesRelation(renamed.Indexes, desiredView.Name)
if err != nil {
return nil, nil, nil, err
}
renamed.Indexes = indexes
if renamed.Triggers != nil {
newTriggers, err := renameTriggerRelation(renamed.Triggers, desiredView.Name)
if err != nil {
return nil, nil, nil, err
}
renamed.Triggers = newTriggers
}
adjusted.Set(newKey, &renamed)
renamedFrom[newKey] = oldKey
}
return stmts, adjusted, renamedFrom, nil
}
// detectColumnRenames finds desired columns with RenameFrom that match a current column.
// Column references in same-table indexes, constraints, foreign keys and
// triggers are rewritten in the adjusted current state by
// `rewriteColumnRefsInIndexes`, `rewriteColumnRefsInConstraints`,
// `rewriteColumnRefsInForeignKeys` and `rewriteColumnRefsInTriggers`
// (called from diffTable), so a plain rename does not produce redundant
// drop/recreate operations on those dependents. View definitions and
// foreign-key references in *other* tables are still not rewritten; see
// LIMITATIONS.md "A rename is not carried into another object's reference".
func detectColumnRenames(fqtn string, current, desired *orderedmap.Map[string, *model.Column]) ([]string, *orderedmap.Map[string, *model.Column], error) {
var stmts []string
adjusted := current.Clone()
for newName, desiredCol := range desired.All() {
if desiredCol.RenameFrom == nil {
continue
}
oldName := *desiredCol.RenameFrom
if oldName == newName {
continue
}
oldCol, ok := adjusted.GetOk(oldName)
if !ok {
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source column %s not found in %s", model.Ident(oldName), fqtn)
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename column %s to %s in %s: destination already exists", model.Ident(oldName), model.Ident(newName), fqtn)
}
stmts = append(stmts, "ALTER TABLE "+fqtn+" RENAME COLUMN "+model.Ident(oldName)+" TO "+model.Ident(newName)+";")
adjusted.Delete(oldName)
renamed := *oldCol
renamed.Name = newName
adjusted.Set(newName, &renamed)
}
return stmts, adjusted, nil
}
// detectConstraintRenames finds desired constraints with RenameFrom that match a
// current constraint. Returns (adjustedCurrent, renamedFrom map[newName]oldName,
// error). The caller renders the ALTER TABLE ... RENAME CONSTRAINT statements,
// so it can leave out the ones whose constraint is being recreated and keep the
// rest in the desired schema's order.
func detectConstraintRenames(fqtn string, current, desired *orderedmap.Map[string, *model.Constraint]) (*orderedmap.Map[string, *model.Constraint], map[string]string, error) {
adjusted := current.Clone()
renamedFrom := map[string]string{}
for newName, desiredCon := range desired.All() {
if desiredCon.RenameFrom == nil {
continue
}
oldName := *desiredCon.RenameFrom
if oldName == newName {
continue
}
oldCon, ok := adjusted.GetOk(oldName)
if !ok {
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source constraint %s not found in %s", model.Ident(oldName), fqtn)
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename constraint %s to %s in %s: destination already exists", model.Ident(oldName), model.Ident(newName), fqtn)
}
renamedFrom[newName] = oldName
adjusted.Delete(oldName)
renamed := *oldCon
renamed.Name = newName
adjusted.Set(newName, &renamed)
}
return adjusted, renamedFrom, nil
}
// detectIndexRenames finds desired indexes with RenameFrom that match a current index.
func detectIndexRenames(current, desired *orderedmap.Map[string, *model.Index]) ([]string, *orderedmap.Map[string, *model.Index], error) {
var stmts []string
adjusted := current.Clone()
for newName, desiredIdx := range desired.All() {
if desiredIdx.RenameFrom == nil {
continue
}
oldName := *desiredIdx.RenameFrom
if oldName == newName {
continue
}
oldIdx, ok := adjusted.GetOk(oldName)
if !ok {
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source index %s not found", model.Ident(oldName))
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename index %s to %s: destination already exists", model.Ident(oldName), model.Ident(newName))
}
stmts = append(stmts, "ALTER INDEX "+model.Ident(oldIdx.Schema, oldName)+" RENAME TO "+model.Ident(newName)+";")
adjusted.Delete(oldName)
renamed := *oldIdx
renamed.Name = newName
// Update definition to reflect the new index name via pg_query parse/deparse
updatedDef, err := updateIndexName(renamed.Definition, newName)
if err != nil {
return nil, nil, err
}
renamed.Definition = updatedDef
adjusted.Set(newName, &renamed)
}
return stmts, adjusted, nil
}
// updateIndexName parses an index definition, updates the index name, and deparses.
func updateIndexName(def string, newName string) (string, error) {
result, err := pg_query.Parse(def)
if err != nil {
return "", fmt.Errorf("failed to parse index definition: %w", err)
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return "", errors.New("failed to parse index definition: expected IndexStmt")
}
is.Idxname = newName
deparsed, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse index definition: %w", err)
}
return deparsed, nil
}
// detectForeignKeyRenames finds desired foreign keys with RenameFrom that match
// a current FK. Returns (adjustedCurrent, renamedFrom map[newName]oldName,
// error). The caller renders the ALTER TABLE ... RENAME CONSTRAINT statements,
// the same way detectConstraintRenames leaves it to diffConstraints.
func detectForeignKeyRenames(fqtn string, current, desired *orderedmap.Map[string, *model.ForeignKey]) (*orderedmap.Map[string, *model.ForeignKey], map[string]string, error) {
adjusted := current.Clone()
renamedFrom := map[string]string{}
for newName, desiredFK := range desired.All() {
if desiredFK.RenameFrom == nil {
continue
}
oldName := *desiredFK.RenameFrom
if oldName == newName {
continue
}
oldFK, ok := adjusted.GetOk(oldName)
if !ok {
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source foreign key %s not found in %s", model.Ident(oldName), fqtn)
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename foreign key %s to %s in %s: destination already exists", model.Ident(oldName), model.Ident(newName), fqtn)
}
renamedFrom[newName] = oldName
adjusted.Delete(oldName)
renamed := *oldFK
renamed.Name = newName
adjusted.Set(newName, &renamed)
}
return adjusted, renamedFrom, nil
}
// detectPolicyRenames finds desired policies with RenameFrom that match a
// current policy on the same table. Returns the adjusted current map keyed by
// new names and a renamedFrom[newName] = oldName map. The caller renders the
// ALTER POLICY ... RENAME statements, so it can both suppress the ones whose
// policy needs DROP+CREATE and keep the rest in the desired schema's order.
func detectPolicyRenames(fqtn string, current, desired *orderedmap.Map[string, *model.Policy]) (*orderedmap.Map[string, *model.Policy], map[string]string, error) {
adjusted := current.Clone()
renamedFrom := map[string]string{}
for newName, desiredPol := range desired.All() {
if desiredPol.RenameFrom == nil {
continue
}
oldName := *desiredPol.RenameFrom
if oldName == newName {
continue
}
oldPol, ok := adjusted.GetOk(oldName)
if !ok {
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source policy %s not found on %s", model.Ident(oldName), fqtn)
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename policy %s to %s on %s: destination already exists", model.Ident(oldName), model.Ident(newName), fqtn)
}
renamedFrom[newName] = oldName
adjusted.Delete(oldName)
renamed := *oldPol
renamed.Name = newName
adjusted.Set(newName, &renamed)
}
return adjusted, renamedFrom, nil
}
// collectColumnRenames returns a map of old column name -> new column name
// for desired columns annotated with -- pista:renamed-from.
func collectColumnRenames(desired *orderedmap.Map[string, *model.Column]) map[string]string {
renames := make(map[string]string)
for name, col := range desired.All() {
if col.RenameFrom != nil && *col.RenameFrom != name {
renames[*col.RenameFrom] = name
}
}
return renames
}
// rewriteColumnRefsInExpr walks an expression tree and rewrites each
// unqualified ColumnRef whose name appears in renames to the mapped new
// name, mutating the tree in place. Each ColumnRef is matched against the
// original-name set in a single pass, so chained renames (a->b alongside
// b->c) cannot cascade.
func rewriteColumnRefsInExpr(node *pg_query.Node, renames map[string]string) {
pgast.WalkExprColumnRefs(node, func(s *pg_query.String) {
if newName, ok := renames[s.Sval]; ok {
s.Sval = newName
}
})
}
// rewriteColumnsInIndexDef returns a new index definition with column
// references rewritten according to the renames map (old -> new). Returns an
// error (and an empty string) if pg_query parse/deparse fails; callers fall
// back to the original definition.
//
// All renames are applied in a single AST walk, so chained renames (a->b and
// b->c) do not cascade; each original column name is matched once against
// the renames map.
func rewriteColumnsInIndexDef(def string, renames map[string]string) (string, error) {
result, is, err := parseIndexDef(def)
if err != nil {
return "", err
}
rewriteIndexElems := func(params []*pg_query.Node) {
for _, p := range params {
ie := p.GetIndexElem()
if ie == nil {
continue
}
if newName, ok := renames[ie.Name]; ok {
ie.Name = newName
}
rewriteColumnRefsInExpr(ie.Expr, renames)
}
}
rewriteIndexElems(is.IndexParams)
rewriteIndexElems(is.IndexIncludingParams)
rewriteColumnRefsInExpr(is.WhereClause, renames)
return pg_query.Deparse(result)
}
// rewriteColumnsInConstraintDef returns a new constraint definition fragment
// (e.g. "PRIMARY KEY (id)", "CHECK ((x > 0))", "FOREIGN KEY (a) REFERENCES t(b)")
// with column references rewritten according to the renames map (old -> new).
// PkAttrs (referenced columns on the foreign side) are intentionally NOT
// rewritten because they refer to a different table.
//
// All renames are applied in a single AST walk so chained renames cannot
// cascade.
func rewriteColumnsInConstraintDef(def string, renames map[string]string) (string, error) {
result, con, err := pgast.ParseConstraintDefStrict(def)
if err != nil {
return "", err
}
rewriteStringList := func(nodes []*pg_query.Node) {
for _, n := range nodes {
if s := n.GetString_(); s != nil {
if newName, ok := renames[s.Sval]; ok {
s.Sval = newName
}
}
}
}
rewriteStringList(con.Keys)
rewriteStringList(con.Including)
rewriteStringList(con.FkAttrs)
rewriteColumnRefsInExpr(con.RawExpr, renames)
rewriteColumnRefsInExpr(con.WhereClause, renames)
// EXCLUDE constraints encode each (column, operator) pair as a List node
// with an IndexElem followed by an operator. Walk the IndexElem side.
for _, ex := range con.Exclusions {
list := ex.GetList()
if list == nil {
continue
}
for _, item := range list.Items {
ie := item.GetIndexElem()
if ie == nil {
continue
}
if newName, ok := renames[ie.Name]; ok {
ie.Name = newName
}
rewriteColumnRefsInExpr(ie.Expr, renames)
}
}
return pgast.DeparseConstraintDef(result)
}
// rewriteColumnRefsInIndexes returns a clone of indexes with each Definition
// updated to reflect the column renames. Definitions that fail to parse/deparse
// are left unchanged so downstream comparison still functions (will fall back
// to redundant DROP/CREATE in that case).
func rewriteColumnRefsInIndexes(indexes *orderedmap.Map[string, *model.Index], renames map[string]string) *orderedmap.Map[string, *model.Index] {
out := orderedmap.New[string, *model.Index]()
for name, idx := range indexes.All() {
clone := *idx
if updated, err := rewriteColumnsInIndexDef(clone.Definition, renames); err == nil {
clone.Definition = updated
}
out.Set(name, &clone)
}
return out
}
// rewriteColumnRefsInConstraints returns a clone of constraints with each
// Definition updated to reflect column renames.
func rewriteColumnRefsInConstraints(cons *orderedmap.Map[string, *model.Constraint], renames map[string]string) *orderedmap.Map[string, *model.Constraint] {
out := orderedmap.New[string, *model.Constraint]()
for name, con := range cons.All() {
clone := *con
if updated, err := rewriteColumnsInConstraintDef(clone.Definition, renames); err == nil {
clone.Definition = updated
}
out.Set(name, &clone)
}
return out
}
// renameColumnKeys returns a clone of the columns map where each key in
// renames is replaced by its mapped new name. Iteration is single-pass
// (each existing key is matched once against the renames map) so chained
// renames such as a->b alongside b->c do not cascade. Order is preserved.
func renameColumnKeys(cols *orderedmap.Map[string, *model.Column], renames map[string]string) *orderedmap.Map[string, *model.Column] {
out := orderedmap.New[string, *model.Column]()
for name, col := range cols.All() {
if newName, ok := renames[name]; ok {
out.Set(newName, col)
} else {
out.Set(name, col)
}
}
return out
}
// rewriteColumnRefsInForeignKeys returns a clone of FKs with each Definition
// updated to reflect column renames on the local side (FkAttrs). Referenced
// columns are not touched.
func rewriteColumnRefsInForeignKeys(fks *orderedmap.Map[string, *model.ForeignKey], renames map[string]string) *orderedmap.Map[string, *model.ForeignKey] {
out := orderedmap.New[string, *model.ForeignKey]()
for name, fk := range fks.All() {
clone := *fk
if updated, err := rewriteColumnsInConstraintDef(clone.Definition, renames); err == nil {
clone.Definition = updated
}
out.Set(name, &clone)
}
return out
}
// rewriteTriggerWhenColumnRefs walks a trigger WHEN expression and rewrites
// each NEW.<col> / OLD.<col> reference whose column name appears in renames.
// NEW and OLD are the only qualifiers a WHEN clause takes, so a two-field
// ColumnRef names a column here, unlike in an index or constraint expression
// where the qualifier could be a table.
func rewriteTriggerWhenColumnRefs(node *pg_query.Node, renames map[string]string) {
pgast.Walk(node, pgast.WalkOptions{SkipSubqueries: true}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
cr := n.GetColumnRef()
if cr == nil || len(cr.Fields) != 2 {
return n
}
qual := cr.Fields[0].GetString_()
if qual == nil || (qual.Sval != "new" && qual.Sval != "old") {
return n
}
if s := cr.Fields[1].GetString_(); s != nil {
if newName, ok := renames[s.Sval]; ok {
s.Sval = newName
}
}
return n
})
}
// rewriteColumnsInTriggerDef returns a new CREATE TRIGGER definition with the
// column names rewritten according to the renames map (old -> new). A trigger
// names columns in the UPDATE OF list and the WHEN expression. The arguments
// passed to the trigger function are left alone, as PostgreSQL does not
// rewrite them on RENAME COLUMN either.
//
// All renames are applied in a single pass, so chained renames (a->b and
// b->c) do not cascade.
func rewriteColumnsInTriggerDef(def string, renames map[string]string) (string, error) {
result, ct, err := parseCreateTrigStmt(def)
if err != nil {
return "", err
}
for _, n := range ct.Columns {
if s := n.GetString_(); s != nil {
if newName, ok := renames[s.Sval]; ok {
s.Sval = newName
}
}
}
rewriteTriggerWhenColumnRefs(ct.WhenClause, renames)
return pg_query.Deparse(result)
}
// rewriteColumnRefsInTriggers returns a clone of triggers with each Definition
// updated to reflect the column renames. PostgreSQL applies RENAME COLUMN to a
// trigger on the same table, so without the rewrite the comparison reads the
// rename as a definition change and emits a redundant CREATE OR REPLACE
// TRIGGER. Definitions that fail to parse/deparse are left unchanged, which
// falls back to that redundant statement.
func rewriteColumnRefsInTriggers(triggers *orderedmap.Map[string, *model.Trigger], renames map[string]string) *orderedmap.Map[string, *model.Trigger] {
out := orderedmap.New[string, *model.Trigger]()
for name, trg := range triggers.All() {
clone := *trg
if updated, err := rewriteColumnsInTriggerDef(clone.Definition, renames); err == nil {
clone.Definition = updated
}
out.Set(name, &clone)
}
return out
}
// rewriteColumnsInExpr returns a bare SQL expression with column references
// rewritten according to the renames map (old -> new). Policy USING /
// WITH CHECK clauses and stored-generated column expressions are stored this
// way, and both reference columns of their own table unqualified.
//
// All renames are applied in a single AST walk, so chained renames (a->b and
// b->c) do not cascade.
func rewriteColumnsInExpr(expr string, renames map[string]string) (string, error) {
result, target, err := pgast.ParseExpr(expr)
if err != nil {
return "", fmt.Errorf("failed to parse expression: %w", err)
}
rewriteColumnRefsInExpr(target.Val, renames)
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse expression: %w", err)
}
return strings.TrimPrefix(sql, "SELECT "), nil
}
// rewriteColumnRefsInPolicies returns a clone of policies with USING and
// WITH CHECK updated to reflect the column renames. PostgreSQL applies
// RENAME COLUMN to a policy on the same table, so without the rewrite the
// comparison reads the rename as an expression change and emits a redundant
// ALTER POLICY. Expressions that fail to parse/deparse are left unchanged,
// which falls back to that redundant statement.
func rewriteColumnRefsInPolicies(policies *orderedmap.Map[string, *model.Policy], renames map[string]string) *orderedmap.Map[string, *model.Policy] {
out := orderedmap.New[string, *model.Policy]()
for name, pol := range policies.All() {
clone := *pol
clone.Using = rewriteColumnsInExprPtr(pol.Using, renames)
clone.WithCheck = rewriteColumnsInExprPtr(pol.WithCheck, renames)
out.Set(name, &clone)
}
return out
}
// rewriteColumnsInExprPtr rewrites an optional expression, returning the
// original pointer when there is nothing to rewrite or the rewrite fails.
func rewriteColumnsInExprPtr(expr *string, renames map[string]string) *string {
if expr == nil {
return nil
}
updated, err := rewriteColumnsInExpr(*expr, renames)
if err != nil {
return expr
}
return &updated
}
// rewriteColumnRefsInGenerated returns a clone of columns with each generated
// expression updated to reflect the column renames. PostgreSQL applies
// RENAME COLUMN to the expression, so without the rewrite diffColumns reads
// the rename as an expression change, which it reports as an error since a
// generated expression cannot be altered in place. A plain DEFAULT is left
// alone: it cannot reference a column, so a rename never reaches it.
//
// STORED is the only kind the desired side reaches today, since pg_query does
// not parse PostgreSQL 18's VIRTUAL yet. The test is on the column being
// generated at all, so a virtual one needs nothing here once it does.
func rewriteColumnRefsInGenerated(cols *orderedmap.Map[string, *model.Column], renames map[string]string) *orderedmap.Map[string, *model.Column] {
out := orderedmap.New[string, *model.Column]()
for name, col := range cols.All() {
clone := *col
if col.Generated.IsGeneratedColumn() && col.Default != nil {
clone.Default = rewriteColumnsInExprPtr(col.Default, renames)
}
out.Set(name, &clone)
}
return out
}
package diff
import (
"slices"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
type RoutineDiffResult struct {
Stmts []string
DropStmts []string
DisallowedDropStmts []string
// AtomicStmts and AtomicDropStmts hold the statements for a routine with a
// SQL-standard body, which PostgreSQL parses at creation time. They run
// with the views: the creates once the tables exist, the drops before the
// tables change. A modified routine goes by its desired form, a dropped
// one by its current form.
AtomicStmts []string
AtomicDropStmts []string
// DroppedAtomic names the routines AtomicDropStmts drops the way
// PostgreSQL names a dependent, such as "function public.f(integer)".
// They are dropped with the views, before anything they could block. A
// recreated routine whose current body is SQL-standard is dropped there
// too. A recreated routine is created again by the list its desired form
// goes to, whichever list its DROP went to.
DroppedAtomic []string
// Recreated lists the current routines that Stmts drops and creates again.
Recreated []*model.Routine
}
// DiffRoutines compares functions and procedures. Both maps are keyed by FQRN,
// which carries the identity argument types, so an overload set appears as
// several independent objects and a change to an argument type reads as a drop
// plus a create rather than a modification.
func DiffRoutines(current, desired *orderedmap.Map[string, *model.Routine], dc DropChecker) (*RoutineDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &RoutineDiffResult{}
dropAllowed := dc.IsDropAllowed("routine")
// New routines
for k, desiredRoutine := range desired.All() {
if _, ok := current.GetOk(k); ok {
continue
}
stmts := result.stmtsFor(desiredRoutine)
*stmts = append(*stmts, desiredRoutine.SQL())
if commentSQL := desiredRoutine.CommentSQL(); commentSQL != "" {
*stmts = append(*stmts, commentSQL)
}
}
// Modified routines
for k, desiredRoutine := range desired.All() {
currentRoutine, ok := current.GetOk(k)
if !ok {
continue
}
stmts, disallowed, recreated := diffRoutine(currentRoutine, desiredRoutine, dropAllowed)
if recreated {
// The DROP goes where the current form puts it and the rest where
// the desired form does. An old SQL-standard body still reads what
// the table changes may drop or retype, so its DROP goes with the
// views, ahead of them. An old plain routine can name a table in
// its signature, so its DROP goes with the other routines, ahead
// of the table drops.
if currentRoutine.Atomic() {
result.AtomicDropStmts = append(result.AtomicDropStmts, stmts[0])
result.DroppedAtomic = append(result.DroppedAtomic, atomicDependentName(currentRoutine))
} else {
result.Stmts = append(result.Stmts, stmts[0])
}
stmts = stmts[1:]
result.Recreated = append(result.Recreated, currentRoutine)
}
out := result.stmtsFor(desiredRoutine)
*out = append(*out, stmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, disallowed...)
}
// Dropped routines. When the drop policy disallows it, emit a commented DROP.
for k, currentRoutine := range current.All() {
if _, ok := desired.GetOk(k); ok {
continue
}
if dropAllowed {
if currentRoutine.Atomic() {
result.AtomicDropStmts = append(result.AtomicDropStmts, currentRoutine.DropSQL())
result.DroppedAtomic = append(result.DroppedAtomic, atomicDependentName(currentRoutine))
} else {
result.DropStmts = append(result.DropStmts, currentRoutine.DropSQL())
}
} else {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+currentRoutine.DropSQL())
}
}
return result, nil
}
// atomicDependentName names r the way PostgreSQL names a dependent.
func atomicDependentName(r *model.Routine) string {
return strings.ToLower(r.Kind()) + " " + r.Signature()
}
// stmtsFor returns the list the create and modify statements of r go to.
func (result *RoutineDiffResult) stmtsFor(r *model.Routine) *[]string {
if r.Atomic() {
return &result.AtomicStmts
}
return &result.Stmts
}
// normalizeTypes returns a copy whose signature types have the routine's own
// schema stripped. The catalog reports such a type bare when search_path
// reaches it while a desired schema may write it qualified, and comparing the
// two spellings directly would read as a change on every run.
func normalizeTypes(r *model.Routine) *model.Routine {
c := *r
c.ReturnType = model.StripTypeSchema(r.ReturnType, model.Ident(r.Schema))
c.Args = make([]*model.RoutineArg, len(r.Args))
for i, a := range r.Args {
arg := *a
arg.Type = model.StripTypeSchema(a.Type, model.Ident(r.Schema))
c.Args[i] = &arg
}
return &c
}
// alignDefaults gives desired the current spelling of each parameter default
// that is the same expression. The catalog writes a default with its type, so
// 'x' reads back as 'x'::text, and comparing the text alone would read as a
// change on every run. It only looks at lists of the same shape; any other
// difference is a change regardless of the defaults.
func alignDefaults(current, desired *model.Routine) {
if !equalArgShapes(current.Args, desired.Args) {
return
}
for i, c := range current.Args {
d := desired.Args[i]
if c.Default != "" && d.Default != "" && equalDefault(&c.Default, &d.Default) {
d.Default = c.Default
}
}
}
// diffRoutine returns the statements that bring one routine in line, plus any
// drop the policy suppressed, and whether the statements recreate it.
func diffRoutine(current, desired *model.Routine, dropAllowed bool) (stmts, disallowed []string, recreated bool) {
currentNorm, desiredNorm := normalizeTypes(current), normalizeTypes(desired)
alignDefaults(currentNorm, desiredNorm)
if currentNorm.SQL() != desiredNorm.SQL() {
if needsDropCreate(currentNorm, desiredNorm) {
// PostgreSQL rejects CREATE OR REPLACE for this change, so the
// routine has to go first. Without the drop policy the current
// definition stays, and the comment is left alone with it.
if !dropAllowed {
return nil, []string{"-- skipped: " + current.DropSQL()}, false
}
// A recreated routine loses its comment, so re-apply it.
stmts = append(stmts, current.DropSQL(), desired.SQL())
if commentSQL := desired.CommentSQL(); commentSQL != "" {
stmts = append(stmts, commentSQL)
}
return stmts, nil, true
}
stmts = append(stmts, desired.SQL())
}
if !equalPtr(current.Comment, desired.Comment) {
stmts = append(stmts, commentOnSQL(desired.Kind()+" "+desired.Signature(), desired.Comment))
}
return stmts, nil, false
}
// needsDropCreate reports whether PostgreSQL refuses to apply the change with
// CREATE OR REPLACE. It rejects a change of routine kind, of return type, and
// of a parameter's name, mode or type, and it refuses to remove a parameter
// default. Everything else - the body, the language, and the attributes -
// replaces in place.
func needsDropCreate(current, desired *model.Routine) bool {
if current.Procedure != desired.Procedure {
return true
}
if current.ReturnType != desired.ReturnType || current.ReturnsSet != desired.ReturnsSet {
return true
}
if !equalArgShapes(current.Args, desired.Args) {
return true
}
return defaultRemoved(current.Args, desired.Args)
}
// equalArgShapes compares every parameter's mode, name and type. The identity
// types already match (both sides share a key), so this catches a renamed
// parameter and any change to the OUT or RETURNS TABLE parameters, each of
// which PostgreSQL rejects in place.
func equalArgShapes(current, desired []*model.RoutineArg) bool {
return slices.EqualFunc(current, desired, func(c, d *model.RoutineArg) bool {
return argShape(c) == argShape(d)
})
}
func argShape(a *model.RoutineArg) string {
return strings.Join([]string{a.Mode, a.Name, a.Type}, "\x00")
}
// defaultRemoved reports whether a parameter that had a default no longer has
// one. PostgreSQL allows adding or changing a default in place but not
// dropping one. The caller has already established that the two lists have the
// same shape, so they are the same length here.
func defaultRemoved(current, desired []*model.RoutineArg) bool {
for i, c := range current {
if c.Default != "" && desired[i].Default == "" {
return true
}
}
return false
}
package diff
import (
"strconv"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
type SequenceDiffResult struct {
Stmts []string
// OwnedByStmts set the owner. They run after the table statements,
// because the column must exist.
OwnedByStmts []string
// DisownStmts remove the owner. They run before the table statements, so
// that dropping the table or column does not drop the sequence.
DisownStmts []string
DropStmts []string
DisallowedDropStmts []string
}
func DiffSequences(current, desired *orderedmap.Map[string, *model.Sequence], dc DropChecker) (*SequenceDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &SequenceDiffResult{}
original := current
// Detect renames
renameStmts, current, err := detectSequenceRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
// New sequences
for k, desiredSeq := range desired.All() {
if _, ok := current.GetOk(k); !ok {
result.Stmts = append(result.Stmts, desiredSeq.SQL())
if commentSQL := desiredSeq.CommentSQL(); commentSQL != "" {
result.Stmts = append(result.Stmts, commentSQL)
}
if ownedBySQL := desiredSeq.OwnedBySQL(); ownedBySQL != "" {
result.OwnedByStmts = append(result.OwnedByStmts, ownedBySQL)
}
}
}
// Modified sequences
for k, desiredSeq := range desired.All() {
currentSeq, ok := current.GetOk(k)
if !ok {
continue
}
result.Stmts = append(result.Stmts, diffSequence(k, currentSeq, desiredSeq)...)
if equalPtr(currentSeq.OwnerTable, desiredSeq.OwnerTable) && equalPtr(currentSeq.OwnerColumn, desiredSeq.OwnerColumn) {
continue
}
if desiredSeq.Owned() {
result.OwnedByStmts = append(result.OwnedByStmts, desiredSeq.OwnedBySQL())
continue
}
// This runs before the rename, so it uses the old name.
name := k
if from := desiredSeq.RenameFrom; from != nil {
if _, ok := original.GetOk(*from); ok {
name = *from
}
}
result.DisownStmts = append(result.DisownStmts, "ALTER SEQUENCE "+name+" OWNED BY NONE;")
}
// Dropped sequences. When the sequence-drop policy disallows it, emit a commented DROP.
drops, skipped := dropMissing(current, desired, "SEQUENCE", dc.IsDropAllowed("sequence"))
result.DropStmts = append(result.DropStmts, drops...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, skipped...)
return result, nil
}
// diffSequence emits a single ALTER SEQUENCE combining every changed option,
// plus a separate COMMENT statement when the comment changed.
func diffSequence(fqn string, current, desired *model.Sequence) []string {
var stmts []string
// SET LOGGED / SET UNLOGGED is a form of its own; the option list of
// ALTER SEQUENCE does not take it.
if current.Unlogged != desired.Unlogged {
if desired.Unlogged {
stmts = append(stmts, "ALTER SEQUENCE "+fqn+" SET UNLOGGED;")
} else {
stmts = append(stmts, "ALTER SEQUENCE "+fqn+" SET LOGGED;")
}
}
var clauses []string
if current.DataType != desired.DataType {
clauses = append(clauses, "AS "+desired.DataType)
}
if current.Increment != desired.Increment {
clauses = append(clauses, "INCREMENT BY "+strconv.FormatInt(desired.Increment, 10))
}
if current.Min != desired.Min {
clauses = append(clauses, "MINVALUE "+strconv.FormatInt(desired.Min, 10))
}
if current.Max != desired.Max {
clauses = append(clauses, "MAXVALUE "+strconv.FormatInt(desired.Max, 10))
}
if current.Start != desired.Start {
clauses = append(clauses, "START WITH "+strconv.FormatInt(desired.Start, 10))
}
if current.Cache != desired.Cache {
clauses = append(clauses, "CACHE "+strconv.FormatInt(desired.Cache, 10))
}
if current.Cycle != desired.Cycle {
if desired.Cycle {
clauses = append(clauses, "CYCLE")
} else {
clauses = append(clauses, "NO CYCLE")
}
}
if len(clauses) > 0 {
stmts = append(stmts, "ALTER SEQUENCE "+fqn+" "+strings.Join(clauses, " ")+";")
}
if !equalPtr(current.Comment, desired.Comment) {
stmts = append(stmts, commentOnSQL("SEQUENCE "+fqn, desired.Comment))
}
return stmts
}
package diff
import (
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// diffStorageParams returns the statements that bring a table's storage
// parameters in line with the desired schema: one SET for the parameters that
// arrive or change value, one RESET for the ones the desired schema no longer
// names. Neither rewrites the table. Both sides are keyed in name order, so
// the statements come out the same on every run.
//
// The caller clears the parameters on both sides unless --manage-storage-param
// asked for them, so an unmanaged run reaches here with two empty maps and
// plans nothing.
//
// A partitioned table needs no special case, since PostgreSQL rejects every
// parameter on a relation that holds no storage. A partition holds its own,
// which it does not take from the parent, and is diffed like any other table.
func diffStorageParams(fqtn string, current, desired *model.Table) []string {
set, reset := storageParamChanges(current.StorageParams, desired.StorageParams)
var stmts []string
if len(set) > 0 {
stmts = append(stmts, model.SetStorageParamsSQL(fqtn, set))
}
if len(reset) > 0 {
stmts = append(stmts, model.ResetStorageParamsSQL(fqtn, reset))
}
return stmts
}
// diffViewStorageParams is diffStorageParams for a view, which takes the same
// two statements under its own keyword. It is reached only when the definition
// is left alone. A view the plan replaces or recreates carries the parameters
// on that statement, which replaces the options as a whole.
func diffViewStorageParams(fqvn string, current, desired *model.View) []string {
set, reset := storageParamChanges(current.StorageParams, desired.StorageParams)
var stmts []string
if len(set) > 0 {
stmts = append(stmts, model.SetViewStorageParamsSQL(fqvn, desired.ObjType(), set))
}
if len(reset) > 0 {
stmts = append(stmts, model.ResetViewStorageParamsSQL(fqvn, desired.ObjType(), reset))
}
return stmts
}
// storageParamChanges reads two parameter maps and returns the name=value
// entries a SET carries and the names a RESET carries.
func storageParamChanges(current, desired *orderedmap.Map[string, string]) (set, reset []string) {
currentParams := nonNilParams(current)
desiredParams := nonNilParams(desired)
for name, value := range desiredParams.All() {
if cur, ok := currentParams.GetOk(name); !ok || cur != value {
set = append(set, name+"="+model.QuoteLiteral(value))
}
}
for name := range currentParams.Keys() {
if _, ok := desiredParams.GetOk(name); !ok {
reset = append(reset, name)
}
}
return set, reset
}
// nonNilParams stands an empty map in for one built without any. The catalog
// and the parser always set it, other callers may not, and clearing it is what
// leaves the parameters unmanaged.
func nonNilParams(params *orderedmap.Map[string, string]) *orderedmap.Map[string, string] {
if params == nil {
return orderedmap.New[string, string]()
}
return params
}
package diff
import (
"cmp"
"errors"
"fmt"
"slices"
"strconv"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
"google.golang.org/protobuf/proto"
)
// TableDiffResult separates FK operations from other statements to allow
// correct ordering: FK drops first, then schema changes, then FK adds last.
type TableDiffResult struct {
FKDropStmts []string // FK drops (should run first)
// PersistenceStmts holds the logged <-> unlogged transitions, already in an
// order PostgreSQL accepts. They are kept out of Stmts because the caller
// sorts that by object dependency, which is the right order for one
// direction and the wrong one for the other.
PersistenceStmts []string
Stmts []string // CREATE/ALTER TABLE, columns, constraints, indexes, comments
FKAddStmts []string // FK adds and renames (should run last)
// PolicyStmts holds a new table's policies. They run after every table and
// view, since a policy can read any of them. An existing table's policies
// stay in Stmts, next to its RLS change, so a live table is never left
// without them or without its RLS.
PolicyStmts []string
// PartitionedIndexStmts holds the CREATE INDEX and COMMENT ON INDEX for
// indexes on partitioned tables, deepest level first. They run after the
// tables and the partitions' own indexes, so each index attaches the
// partitions' matching indexes instead of creating copies.
PartitionedIndexStmts []string
// RetypedColumns lists the columns of existing tables that go out as SET
// DATA TYPE.
RetypedColumns []RetypedColumn
DropStmts []string // DROP TABLE (separate from Stmts for ordering)
DisallowedDropStmts []string // DROP TABLE / DROP COLUMN / DROP CONSTRAINT (incl. FK) / DROP INDEX suppressed by DropChecker, with "-- skipped: " prefix
HasConcurrently bool // true if any index operation uses CONCURRENTLY
}
// RetypedColumn is a column that goes out as SET DATA TYPE. Name is
// <table>.<column> as the desired schema names it, and Current as the catalog
// names it, before any rename in the plan.
type RetypedColumn struct {
Name string
Current string
}
// retypedColumns lists the table's columns that go out as SET DATA TYPE.
// currentFQTN is the table's name before any rename. The change reaches the
// table's partitions and INHERITS children, which original holds under their
// names before any rename, so their copies of the column are listed too. A
// partition declares no columns of its own.
func retypedColumns(fqtn, currentFQTN string, current, desired *model.Table, original *orderedmap.Map[string, *model.Table]) []RetypedColumn {
if desired.IsPartitionChild() {
return nil
}
var cols []RetypedColumn
for name, desiredCol := range desired.Columns.All() {
currentName := name
if desiredCol.RenameFrom != nil {
if _, ok := current.Columns.GetOk(*desiredCol.RenameFrom); ok {
currentName = *desiredCol.RenameFrom
}
}
if currentCol, ok := current.Columns.GetOk(currentName); ok && columnRetyped(fqtn, currentCol, desiredCol) {
cols = append(cols, RetypedColumn{
Name: fqtn + "." + model.Ident(name),
Current: currentFQTN + "." + model.Ident(currentName),
})
for _, child := range descendants(original, currentFQTN) {
key := child + "." + model.Ident(currentName)
cols = append(cols, RetypedColumn{Name: key, Current: key})
}
}
}
return cols
}
// descendants lists the partitions and INHERITS children of the table, at
// every level below it.
func descendants(tables *orderedmap.Map[string, *model.Table], fqtn string) []string {
var keys []string
for k, t := range tables.All() {
if t.PartitionOf != nil && *t.PartitionOf == fqtn {
keys = append(keys, k)
keys = append(keys, descendants(tables, k)...)
}
}
return keys
}
// partitionedIndexStmts is one partitioned table's share of
// PartitionedIndexStmts and its partition depth.
type partitionedIndexStmts struct {
depth int
stmts []string
}
// indexColumns returns the columns a table's indexes are compared against. A
// partition child declares none, so they come from the nearest desired
// ancestor that does. When a filter leaves that ancestor out, its columns do
// not change in this plan, and the ones the catalog reads onto the child
// serve.
func indexColumns(tables *orderedmap.Map[string, *model.Table], desired, current *model.Table) *orderedmap.Map[string, *model.Column] {
t := desired
for t.IsPartitionChild() {
parent, ok := tables.GetOk(*t.PartitionOf)
if !ok {
return current.Columns
}
t = parent
}
return t.Columns
}
// partitionDepth counts the partitioned tables above t. A parent outside
// tables, as a filter leaves it, counts as one level and ends the count.
func partitionDepth(tables *orderedmap.Map[string, *model.Table], t *model.Table) int {
depth := 0
for t.PartitionOf != nil {
depth++
parent, ok := tables.GetOk(*t.PartitionOf)
if !ok {
break
}
t = parent
}
return depth
}
// flattenPartitionedIndexStmts orders the groups deepest level first, so a
// level's index exists before the level above attaches it.
func flattenPartitionedIndexStmts(groups []partitionedIndexStmts) []string {
slices.SortStableFunc(groups, func(a, b partitionedIndexStmts) int {
return cmp.Compare(b.depth, a.depth)
})
var stmts []string
for _, g := range groups {
stmts = append(stmts, g.stmts...)
}
return stmts
}
func DiffTables(current, desired *orderedmap.Map[string, *model.Table], dc DropChecker) (*TableDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &TableDiffResult{}
// Detect renames
original := current
renameStmts, current, err := detectTableRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
var partitionedIdx []partitionedIndexStmts
// New tables
for k, v := range desired.All() {
if _, ok := current.GetOk(k); !ok {
result.Stmts = append(result.Stmts, v.SQL())
stmts, idxStmts, fkStmts, extraHasConcurrently, err := newTableExtras(v, parentIndexCopies(current, desired, v))
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, stmts...)
if len(idxStmts) > 0 {
partitionedIdx = append(partitionedIdx, partitionedIndexStmts{partitionDepth(desired, v), idxStmts})
}
result.FKAddStmts = append(result.FKAddStmts, fkStmts...)
result.PolicyStmts = append(result.PolicyStmts, v.PolicySQL()...)
result.PolicyStmts = append(result.PolicyStmts, v.PolicyCommentSQL()...)
if extraHasConcurrently {
result.HasConcurrently = true
}
}
}
// Modified tables. The persistence changes are collected rather than
// emitted in place, since the order they run in is set by the foreign keys
// between them and no single table can decide it alone.
var persistence []*persistenceChange
for k, desiredTable := range desired.All() {
if currentTable, ok := current.GetOk(k); ok {
if pc := diffPersistence(desiredTable.FQTN(), currentTable, desiredTable); pc != nil {
persistence = append(persistence, pc)
}
currentKey := k
if desiredTable.RenameFrom != nil {
if _, ok := original.GetOk(*desiredTable.RenameFrom); ok {
currentKey = *desiredTable.RenameFrom
}
}
result.RetypedColumns = append(result.RetypedColumns, retypedColumns(k, currentKey, currentTable, desiredTable, original)...)
tableResult, err := diffTable(currentTable, desiredTable, indexColumns(desired, desiredTable, currentTable), dc)
if err != nil {
return nil, err
}
result.FKDropStmts = append(result.FKDropStmts, tableResult.FKDropStmts...)
result.Stmts = append(result.Stmts, tableResult.Stmts...)
if len(tableResult.PartitionedIndexStmts) > 0 {
partitionedIdx = append(partitionedIdx, partitionedIndexStmts{partitionDepth(desired, desiredTable), tableResult.PartitionedIndexStmts})
}
result.FKAddStmts = append(result.FKAddStmts, tableResult.FKAddStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, tableResult.DisallowedDropStmts...)
if tableResult.HasConcurrently {
result.HasConcurrently = true
}
}
}
result.PersistenceStmts = orderPersistenceChanges(persistence)
result.PartitionedIndexStmts = flattenPartitionedIndexStmts(partitionedIdx)
// Dropped tables: drop FKs on dropped tables first to avoid dependency errors.
// When the table-drop policy disallows it, emit the same DROPs as comments
// (with "-- skipped: " prefix) into DisallowedDropStmts for visibility.
// A partition's copy of its parent's key takes no statement: PostgreSQL
// rejects dropping it on its own, and it goes with the partition anyway.
tableAllowed := dc.IsDropAllowed("table")
for k, tbl := range current.All() {
if _, ok := desired.GetOk(k); ok {
continue
}
for name, fk := range tbl.ForeignKeys.All() {
if fk.Inherited {
continue
}
drop := "ALTER TABLE " + k + " DROP CONSTRAINT " + model.Ident(name) + ";"
if tableAllowed {
result.FKDropStmts = append(result.FKDropStmts, drop)
} else {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+drop)
}
}
drop := "DROP TABLE " + k + ";"
if tableAllowed {
result.DropStmts = append(result.DropStmts, drop)
} else {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+drop)
}
}
return result, nil
}
// parentIndexCopies returns the indexes of the new table t that PostgreSQL
// creates with t. CREATE TABLE ... PARTITION OF copies every index the parent
// has, so a CREATE INDEX for the copy fails with "already exists". An index of
// t is a copy when it has the name PostgreSQL gives the copy and the parent
// has the same index before the plan runs: the parent exists and the index is
// unchanged or only renamed, or the parent is new and gets the index as a copy
// itself.
//
// An index the parent gains in this plan is not copied. The partition's index
// is created first and the parent's index attaches it; see
// PartitionedIndexStmts. A parent a filter leaves out is not seen, so the
// partition's indexes are created as written.
func parentIndexCopies(current, desired *orderedmap.Map[string, *model.Table], t *model.Table) map[string]bool {
// An INHERITS child gets no copy.
if !t.IsPartitionChild() {
return nil
}
parent, ok := desired.GetOk(*t.PartitionOf)
if !ok {
return nil
}
currentParent, parentExists := current.GetOk(*t.PartitionOf)
var currentIndexes *orderedmap.Map[string, *model.Index]
var parentCopies map[string]bool
if parentExists {
// A renamed index is still there when the partition is created. A
// rename that cannot be planned fails the parent's own diff.
var err error
if _, currentIndexes, err = detectIndexRenames(currentParent.Indexes, parent.Indexes); err != nil {
return nil
}
} else {
parentCopies = parentIndexCopies(current, desired, parent)
}
columns := indexColumns(desired, t, t)
copies := map[string]bool{}
for idx := range t.Indexes.Values() {
for parentName, parentIdx := range parent.Indexes.All() {
if parentExists {
currentIdx, ok := currentIndexes.GetOk(parentName)
if !ok || !equalIndexDef(currentIdx.Definition, parentIdx.Definition, columns) {
continue
}
} else if !parentCopies[parentName] {
continue
}
if isPartitionIndexCopy(idx, parentIdx, columns) {
copies[idx.Name] = true
break
}
}
}
return copies
}
// IsPartitionIndexCopy reports whether idx, an index of a partition, is the
// copy PostgreSQL makes of parentIdx, an index of its parent: it carries the
// name PostgreSQL gives the copy, and it indexes what parentIdx does, the
// storage parameters included. The two are compared with the index name and
// the table of idx replaced by those of parentIdx.
func IsPartitionIndexCopy(idx, parentIdx *model.Index) bool {
return isPartitionIndexCopy(idx, parentIdx, nil)
}
// isPartitionIndexCopy is IsPartitionIndexCopy with the columns the definition
// of idx is compared against; see equalIndexDef.
func isPartitionIndexCopy(idx, parentIdx *model.Index, columns *orderedmap.Map[string, *model.Column]) bool {
result, is, err := parseIndexDef(idx.Definition)
if err != nil || idx.Name != parser.AutoNameIndex(is) {
return false
}
_, parentIS, err := parseIndexDef(parentIdx.Definition)
if err != nil {
return false
}
is.Idxname = parentIS.Idxname
is.Relation = parentIS.Relation
def, err := pg_query.Deparse(result)
return err == nil && equalIndexDef(parentIdx.Definition, def, columns)
}
// newTableExtras returns non-FK extras and FK statements separately. For a
// partitioned table, the indexes and their comments go in idxStmts instead.
// The indexes in copies are left out, since PostgreSQL creates them with the
// table; their comments are not.
func newTableExtras(t *model.Table, copies map[string]bool) (stmts, idxStmts, fkStmts []string, hasConcurrently bool, err error) {
stmts = append(stmts, t.NotValidConSQL()...)
stmts = append(stmts, t.FoldedKeySQL()...)
stmts = append(stmts, t.StorageSQL()...)
for _, idx := range t.Indexes.CollectValues() {
if copies[idx.Name] {
continue
}
if err := checkPartitionedConcurrently(idx, t.Partitioned); err != nil {
return nil, nil, nil, false, err
}
stmt, err := createIndexSQL(idx.Definition, idx.Concurrently)
if err != nil {
return nil, nil, nil, false, err
}
if t.Partitioned {
idxStmts = append(idxStmts, stmt)
} else {
stmts = append(stmts, stmt)
}
if idx.Concurrently {
hasConcurrently = true
}
}
// The index comments go before the USING INDEX constraints, since ADD
// CONSTRAINT renames the index it takes over.
if t.Partitioned {
idxStmts = append(idxStmts, t.IndexCommentSQL()...)
} else {
stmts = append(stmts, t.IndexCommentSQL()...)
}
stmts = append(stmts, t.UsingIndexConSQL()...)
for _, fk := range t.ForeignKeys.CollectValues() {
fkStmts = append(fkStmts, fk.SQL(t.Partitioned))
}
fkStmts = append(fkStmts, t.FkCommentSQL()...)
stmts = append(stmts, t.RLSSQL()...)
stmts = append(stmts, t.TrigSQL()...)
stmts = append(stmts, t.RelationCommentSQL()...)
stmts = append(stmts, t.ConstraintCommentSQL()...)
stmts = append(stmts, t.TrigCommentSQL()...)
return
}
type tableDiffResult struct {
FKDropStmts []string
Stmts []string
PartitionedIndexStmts []string
FKAddStmts []string
DisallowedDropStmts []string
HasConcurrently bool
}
// diffTable diffs one table. columns are the ones its indexes are compared
// against; see indexColumns.
func diffTable(current, desired *model.Table, columns *orderedmap.Map[string, *model.Column], dc DropChecker) (*tableDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &tableDiffResult{}
fqtn := desired.FQTN()
// Partition children inherit columns and constraints from the parent,
// so skip diffing them to avoid false DROP statements. RLS flags,
// policies and comments are owned per-relation (children do not
// auto-inherit them), so they're still diffed here, mirroring how
// indexes and FKs work.
if desired.IsPartitionChild() {
idxResult, err := diffIndexes(current.Indexes, desired.Indexes, columns, usingIndexNames(current.Constraints, desired.Constraints), desired.Partitioned, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, idxResult.Stmts...)
result.PartitionedIndexStmts = append(result.PartitionedIndexStmts, idxResult.PartitionedStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, idxResult.DisallowedDropStmts...)
if idxResult.HasConcurrently {
result.HasConcurrently = true
}
fkDrops, fkAdds, fkDisallowed, err := diffForeignKeys(fqtn, desired.Schema, desired.Partitioned, current.ForeignKeys, desired.ForeignKeys, dc)
if err != nil {
return nil, err
}
result.FKDropStmts = append(result.FKDropStmts, fkDrops...)
result.FKAddStmts = append(result.FKAddStmts, fkAdds...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, fkDisallowed...)
result.Stmts = append(result.Stmts, diffRLS(fqtn, current, desired)...)
polStmts, polDisallowed, err := diffPolicies(fqtn, current.Policies, desired.Policies, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, polStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, polDisallowed...)
trgStmts, trgDisallowed, err := diffTriggers(fqtn, current.Triggers, desired.Triggers, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, trgStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, trgDisallowed...)
result.Stmts = append(result.Stmts, diffComments(current, withInheritedColumns(current, desired))...)
result.Stmts = append(result.Stmts, diffStorageParams(fqtn, current, desired)...)
return result, nil
}
colStmts, notNullDrops, colDropStmts, colDisallowed, err := diffColumns(fqtn, current.Columns, desired.Columns, dc)
if err != nil {
return nil, err
}
// Rewrite column references in dependent objects so that subsequent diffs
// don't see the renamed column as a definition change. PostgreSQL applies
// RENAME COLUMN transparently to the indexes, constraints, FKs, triggers
// and policies on the same table. Also rekey current.Columns so
// diffComments looks up the right entry by the new column name.
if columnRenames := collectColumnRenames(desired.Columns); len(columnRenames) > 0 {
clone := *current
clone.Indexes = rewriteColumnRefsInIndexes(current.Indexes, columnRenames)
clone.Constraints = rewriteColumnRefsInConstraints(current.Constraints, columnRenames)
clone.ForeignKeys = rewriteColumnRefsInForeignKeys(current.ForeignKeys, columnRenames)
clone.Triggers = rewriteColumnRefsInTriggers(current.Triggers, columnRenames)
clone.Policies = rewriteColumnRefsInPolicies(current.Policies, columnRenames)
clone.Columns = renameColumnKeys(current.Columns, columnRenames)
current = &clone
}
conDrops, conStmts, conCommentStmts, conDisallowed, usingIndexStmts, err := diffConstraints(fqtn, current.Constraints, desired.Constraints, dc)
if err != nil {
return nil, err
}
// The constraint drops go before the column statements: a type change
// rebuilds the CHECK constraints on the column, and fails on one the new
// type rejects.
result.Stmts = append(result.Stmts, conDrops...)
result.Stmts = append(result.Stmts, colStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, colDisallowed...)
result.Stmts = append(result.Stmts, conStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, conDisallowed...)
// DROP NOT NULL follows the constraint statements: PostgreSQL refuses it
// while a primary key holds the column, so the key's DROP CONSTRAINT has to
// run first. It stays ahead of the index statements, which keeps the ALTER
// TABLE run that --bulk-alter merges in one piece.
result.Stmts = append(result.Stmts, notNullDrops...)
idxResult2, err := diffIndexes(current.Indexes, desired.Indexes, columns, usingIndexNames(current.Constraints, desired.Constraints), desired.Partitioned, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, idxResult2.Stmts...)
result.Stmts = append(result.Stmts, usingIndexStmts...)
result.PartitionedIndexStmts = append(result.PartitionedIndexStmts, idxResult2.PartitionedStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, idxResult2.DisallowedDropStmts...)
if idxResult2.HasConcurrently {
result.HasConcurrently = true
}
fkDrops, fkAdds, fkDisallowed, err := diffForeignKeys(fqtn, desired.Schema, desired.Partitioned, current.ForeignKeys, desired.ForeignKeys, dc)
if err != nil {
return nil, err
}
result.FKDropStmts = append(result.FKDropStmts, fkDrops...)
result.FKAddStmts = append(result.FKAddStmts, fkAdds...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, fkDisallowed...)
// RLS toggles run before CREATE POLICY so a newly enabled table has its
// flag set when policies attach. Disabling RLS likewise comes before any
// policy DROP that the user may have stacked alongside.
result.Stmts = append(result.Stmts, diffRLS(fqtn, current, desired)...)
polStmts, polDisallowed, err := diffPolicies(fqtn, current.Policies, desired.Policies, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, polStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, polDisallowed...)
trgStmts, trgDisallowed, err := diffTriggers(fqtn, current.Triggers, desired.Triggers, dc)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, trgStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, trgDisallowed...)
result.Stmts = append(result.Stmts, diffComments(current, desired)...)
result.Stmts = append(result.Stmts, conCommentStmts...)
// Column drops go last so dependent objects (UNIQUE/CHECK constraints,
// indexes, policies) are dropped first. PostgreSQL silently cascades
// single-column UNIQUE/CHECK constraints and dependent indexes when a
// column is dropped, which would make a later explicit DROP
// CONSTRAINT/INDEX fail with "does not exist". Policies referencing a
// dropped column block the DROP COLUMN entirely.
result.Stmts = append(result.Stmts, colDropStmts...)
// Storage parameters go last. Nothing in SET / RESET depends on the
// column statements, and sitting among them would split a --bulk-alter
// group, which merges only consecutive statements. They also have to stay
// after ADD COLUMN: a toast. parameter needs the TOAST relation that a
// first toastable column creates, and PostgreSQL discards it otherwise.
result.Stmts = append(result.Stmts, diffStorageParams(fqtn, current, desired)...)
return result, nil
}
// diffColumns returns (stmts, notNullDrops, dropStmts, disallowed, err). Adds,
// alters, and renames go into stmts. A DROP NOT NULL goes into notNullDrops so
// the caller can run it after the constraint statements, since PostgreSQL
// refuses it while a primary key holds the column. Pure drops (columns absent
// from desired) go into dropStmts so the caller can sequence them after
// constraint/index/policy drops on the same table; PostgreSQL auto-cascades
// single-column UNIQUE/CHECK constraints and dependent indexes during DROP
// COLUMN, which makes a later explicit DROP CONSTRAINT/INDEX fail with "does
// not exist".
// Within dropStmts, generated columns are emitted before their potential
// source columns so a paired drop (e.g. dropping both `body` and a stored
// `word_count GENERATED ALWAYS AS (length(body))`) succeeds without CASCADE.
func diffColumns(fqtn string, current, desired *orderedmap.Map[string, *model.Column], dc DropChecker) (stmts []string, notNullDrops []string, dropStmts []string, disallowed []string, err error) {
dc = normalizeDropChecker(dc)
// Detect renames
renameStmts, current, err := detectColumnRenames(fqtn, current, desired)
if err != nil {
return nil, nil, nil, nil, err
}
stmts = append(stmts, renameStmts...)
// A generated expression on the same table names the renamed column, and
// PostgreSQL carries it through RENAME COLUMN. Rewrite it on the current
// side so the comparison below does not read the rename as an expression
// change, which it cannot express as an ALTER.
if columnRenames := collectColumnRenames(desired); len(columnRenames) > 0 {
current = rewriteColumnRefsInGenerated(current, columnRenames)
}
// Add new columns
for name, col := range desired.All() {
if _, ok := current.GetOk(name); !ok {
stmts = append(stmts, addColumnSQL(fqtn, col))
stmts = append(stmts, columnStorageSQL(fqtn, nil, col, false)...)
}
}
// Alter existing columns
var seqStmts []string
for name, desiredCol := range desired.All() {
if currentCol, ok := current.GetOk(name); ok {
cur := currentCol.Generated.IsStoredGeneratedColumn()
des := desiredCol.Generated.IsStoredGeneratedColumn()
if cur != des {
return nil, nil, nil, nil, fmt.Errorf("column %s.%s: cannot toggle GENERATED; DROP COLUMN + ADD COLUMN is required",
fqtn, model.Ident(name))
}
if cur && exprChanged(currentCol.Default, desiredCol.Default) {
// Both sides are STORED GENERATED but the expression differs.
// PostgreSQL has no `ALTER COLUMN ... SET GENERATED AS (expr)`;
// the only way to change the expression is DROP COLUMN +
// ADD COLUMN, which loses the column's data. Surface the
// change as an error so the user explicitly drops/re-adds.
// Use exprChanged (strict equalSelectExpr) rather than
// equalDefault: equalDefault has DEFAULT-specific semantics
// (symmetric top-level cast strip, `'0'::bigint` ==
// `'0'::integer`) that would silently hide a real top-level
// cast change or a cast-target-type change on a GENERATED
// expression. equalSelectExpr keeps the asymmetric strip
// from #201 / #203 but no DEFAULT-style softening.
return nil, nil, nil, nil, fmt.Errorf("column %s.%s: cannot change GENERATED expression; DROP COLUMN + ADD COLUMN is required",
fqtn, model.Ident(name))
}
stmts = append(stmts, alterColumnSQL(fqtn, currentCol, desiredCol)...)
if s := dropNotNullSQL(fqtn, currentCol, desiredCol); s != "" {
notNullDrops = append(notNullDrops, s)
}
if seqSQL := alterSerialSequenceSQL(fqtn, currentCol, desiredCol); seqSQL != "" {
seqStmts = append(seqStmts, seqSQL)
}
}
}
// The sequence statements go after every ALTER TABLE rather than next to
// the column they belong to. --bulk-alter merges a run of consecutive
// ALTER TABLE statements on one table, and one of these in the middle
// would break the run in two. The sort puts them ahead of the ALTER TABLE
// again, since the table's default draws from the sequence.
stmts = append(stmts, seqStmts...)
// Drop removed columns. When the column-drop policy disallows it, emit
// the same DROP as a comment for visibility. Two passes: generated
// columns first so dropping them doesn't fail when their source column
// is also being dropped in the same batch.
colAllowed := dc.IsDropAllowed("column")
for _, pass := range []bool{true, false} {
for name, col := range current.All() {
if _, ok := desired.GetOk(name); ok {
continue
}
if col.Generated.IsStoredGeneratedColumn() != pass {
continue
}
drop := "ALTER TABLE " + fqtn + " DROP COLUMN " + model.Ident(name) + ";"
if colAllowed {
dropStmts = append(dropStmts, drop)
} else {
disallowed = append(disallowed, "-- skipped: "+drop)
}
}
}
return stmts, notNullDrops, dropStmts, disallowed, nil
}
func addColumnSQL(fqtn string, col *model.Column) string {
sql := "ALTER TABLE " + fqtn + " ADD COLUMN " + model.Ident(col.Name) + " " + col.TypeName
if col.Collation != nil {
sql += " COLLATE " + *col.Collation
}
if col.Identity.IsGeneratedAlways() {
sql += " GENERATED ALWAYS AS IDENTITY" + col.IdentitySeq.OptionsSQL(col.TypeName)
} else if col.Identity.IsGeneratedByDefault() {
sql += " GENERATED BY DEFAULT AS IDENTITY" + col.IdentitySeq.OptionsSQL(col.TypeName)
} else if col.Generated.IsStoredGeneratedColumn() {
if col.Default != nil {
sql += " GENERATED ALWAYS AS (" + *col.Default + ") STORED"
}
} else if col.Default != nil {
sql += " DEFAULT " + *col.Default
}
if col.NotNull && !col.Identity.IsIdentityColumn() {
if col.NotNullName != nil {
sql += " CONSTRAINT " + model.Ident(*col.NotNullName)
}
sql += " NOT NULL"
}
return sql + ";"
}
// columnRetyped reports whether the column's type or collation changes, which
// goes out as SET DATA TYPE.
func columnRetyped(fqtn string, current, desired *model.Column) bool {
return !equalTypeName(current.TypeName, desired.TypeName, schemaOf(fqtn)) || !equalCollation(current.Collation, desired.Collation)
}
func alterColumnSQL(fqtn string, current, desired *model.Column) []string {
var stmts []string
colIdent := model.Ident(desired.Name)
// Type or collation change. Collation is altered via SET DATA TYPE
// because PostgreSQL has no separate "set collation" syntax; re-issuing
// SET DATA TYPE without COLLATE reverts to the type's default collation.
retyped := columnRetyped(fqtn, current, desired)
// The USING expression of -- pista:retype-using is not applied to the
// default, which can fail to convert, so the default is dropped before
// the type change and the default section below sets it again. A serial
// column's nextval() converts to any integer type, and a generated
// column has no default to drop.
defaultDropped := false
if retyped {
if desired.RetypeUsing != nil && current.Default != nil &&
current.SerialSequence == nil && !current.Generated.IsGeneratedColumn() {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" DROP DEFAULT;")
defaultDropped = true
}
sql := "ALTER TABLE " + fqtn + " ALTER COLUMN " + colIdent + " SET DATA TYPE " + alterTypeName(desired.TypeName)
if desired.Collation != nil {
sql += " COLLATE " + *desired.Collation
}
if desired.RetypeUsing != nil {
sql += " USING " + *desired.RetypeUsing
}
stmts = append(stmts, sql+";")
}
curIsIdent := current.Identity.IsIdentityColumn()
desIsIdent := desired.Identity.IsIdentityColumn()
// Identity transitions. Handle before default/NOT NULL diff because adding
// IDENTITY requires the column to have no default and NOT NULL set.
switch {
case !curIsIdent && desIsIdent:
// none -> identity: clear default and ensure NOT NULL, then ADD IDENTITY.
// A serial column's nextval() default blocks ADD IDENTITY. The catalog
// reads it into Default, but a column built from a schema file carries
// none, so detect those by TypeName as well.
if current.Default != nil || isSerialType(current.TypeName) {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" DROP DEFAULT;")
}
if !current.NotNull {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" SET NOT NULL;")
}
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" ADD GENERATED "+identityKind(desired.Identity)+" AS IDENTITY"+desired.IdentitySeq.OptionsSQL(desired.TypeName)+";")
case curIsIdent && !desIsIdent:
// identity -> none: DROP IDENTITY. The column stays NOT NULL afterwards;
// dropNotNullSQL emits DROP NOT NULL if desired is nullable.
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" DROP IDENTITY IF EXISTS;")
case curIsIdent && desIsIdent:
// always <-> by default, and the sequence options behind the column.
// One statement takes both, and PostgreSQL checks the result as a
// whole, so the clauses need no particular order.
var clauses []string
if current.Identity != desired.Identity {
clauses = append(clauses, "SET GENERATED "+identityKind(desired.Identity))
}
// A SET DATA TYPE above already moved the bounds that sat at the old
// type's default, so the current side is read as it is after it.
currentSeq := current.IdentitySeq
if retyped {
currentSeq = currentSeq.RetypedTo(current.TypeName, desired.TypeName)
}
clauses = append(clauses, identitySeqClauses(currentSeq, desired.IdentitySeq)...)
if len(clauses) > 0 {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" "+strings.Join(clauses, " ")+";")
}
}
// Default change. For non-generated columns, emit SET DEFAULT / DROP
// DEFAULT as usual. For generated columns the "default" stores the
// generated expression and cannot be altered via SET DEFAULT; caller
// (diffColumns) already errors on a Generated-state toggle and on an
// expression-only change within a generated column, so this branch is
// skipped when either side is generated. Skip also when desired is
// identity: we explicitly DROP DEFAULT above as part of the ADD
// IDENTITY transition.
if !current.Generated.IsStoredGeneratedColumn() && !desired.Generated.IsStoredGeneratedColumn() && !desIsIdent {
// A serial column's nextval() comes with the type, so a desired side
// that writes no default leaves it alone. One that writes a default is
// compared with it. This applies to any column that takes its default
// from a sequence it owns: dumps from earlier versions left out such
// a default.
currentDefault := current.Default
if current.SerialSequence != nil && desired.Default == nil || defaultDropped {
currentDefault = nil
}
if !equalDefault(currentDefault, desired.Default) {
if desired.Default != nil {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" SET DEFAULT "+*desired.Default+";")
} else {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" DROP DEFAULT;")
}
}
}
// NOT NULL change. Identity columns are implicitly NOT NULL, so skip when
// the desired side is identity (the ADD IDENTITY path sets NOT NULL above).
// A DROP NOT NULL is not here: dropNotNullSQL renders it, and the caller
// runs it after the constraint statements.
if !current.NotNull && desired.NotNull && !desIsIdent {
stmts = append(stmts, "ALTER TABLE "+fqtn+" ALTER COLUMN "+colIdent+" SET NOT NULL;")
} else if current.NotNull && desired.NotNull && !desIsIdent &&
current.NotNullName != nil && desired.NotNullName != nil &&
*current.NotNullName != *desired.NotNullName {
// Both sides NOT NULL with explicit but different names; rename in place.
// Adding/removing a name on an existing NOT NULL needs PG18's standalone
// ALTER ... ADD CONSTRAINT NOT NULL syntax (not yet supported by
// pg_query_go); left out of v1. Identity columns are skipped to match
// Table.SQL / addColumnSQL, which intentionally do not render
// "CONSTRAINT <name> NOT NULL" on identity columns; emitting a rename
// here would surface a name the dumper hides.
stmts = append(stmts, renameConstraintSQL(fqtn, *current.NotNullName, *desired.NotNullName))
}
// TOAST storage and compression last, since SET DATA TYPE resets both.
stmts = append(stmts, columnStorageSQL(fqtn, current, desired, retyped)...)
return stmts
}
// dropNotNullSQL returns the DROP NOT NULL a column change needs, or "" when
// it needs none. It is separate from alterColumnSQL because PostgreSQL refuses
// the statement while a primary key holds the column, so the caller runs it
// after the DROP CONSTRAINT that frees it. A column becoming an identity stays
// NOT NULL, so it is skipped like the SET NOT NULL side.
func dropNotNullSQL(fqtn string, current, desired *model.Column) string {
if !current.NotNull || desired.NotNull || desired.Identity.IsIdentityColumn() {
return ""
}
return "ALTER TABLE " + fqtn + " ALTER COLUMN " + model.Ident(desired.Name) + " DROP NOT NULL;"
}
// columnStorageSQL returns the statements that put a column's TOAST storage
// and compression where the desired definition wants them. current is nil for
// a column being added, and retyped says a SET DATA TYPE runs before these.
//
// A definition that names neither asks for the defaults: the strategy the
// column's type carries, and whatever default_toast_compression holds when a
// value is written. Only the catalog knows the first, so an unwritten strategy
// is resolved against the current column's type. Compression is a value the
// column does not carry either way, so the two sides compare as written.
//
// A column being added, and one a SET DATA TYPE rebuilds, sit at the type's
// defaults whatever they held before; PostgreSQL resets them there even when
// the type does not change, which a collation change goes through. What the
// definition names is therefore written out again, and what it leaves out is
// already where it belongs.
//
// Neither statement rewrites the table; both decide how values inserted later
// are stored.
func columnStorageSQL(fqtn string, current, desired *model.Column, retyped bool) []string {
var stmts []string
reset := current == nil || retyped
switch {
case reset:
if desired.StorageType != "" {
stmts = append(stmts, model.SetStorageSQL(fqtn, desired.Name, desired.StorageType))
}
default:
storage := desired.StorageType
if storage == "" {
storage = current.TypeStorage
}
if storage != "" && current.StorageType != storage {
stmts = append(stmts, model.SetStorageSQL(fqtn, desired.Name, storage))
}
}
switch {
case reset:
if desired.Compression != "" {
stmts = append(stmts, model.SetCompressionSQL(fqtn, desired.Name, desired.Compression))
}
case current.Compression != desired.Compression:
stmts = append(stmts, model.SetCompressionSQL(fqtn, desired.Name, desired.Compression))
}
return stmts
}
// identitySeqClauses returns a SET clause per changed sequence option of an
// identity column. Both sides carry every value resolved, so an option the
// desired definition leaves out reads as its default and is reset here.
// START WITH only moves where the sequence restarts; the current value is left
// alone, the same as ALTER SEQUENCE.
func identitySeqClauses(current, desired *model.IdentitySequence) []string {
if current == nil || desired == nil {
return nil
}
var clauses []string
if current.Start != desired.Start {
clauses = append(clauses, "SET START WITH "+strconv.FormatInt(desired.Start, 10))
}
if current.Increment != desired.Increment {
clauses = append(clauses, "SET INCREMENT BY "+strconv.FormatInt(desired.Increment, 10))
}
if current.Min != desired.Min {
clauses = append(clauses, "SET MINVALUE "+strconv.FormatInt(desired.Min, 10))
}
if current.Max != desired.Max {
clauses = append(clauses, "SET MAXVALUE "+strconv.FormatInt(desired.Max, 10))
}
if current.Cache != desired.Cache {
clauses = append(clauses, "SET CACHE "+strconv.FormatInt(desired.Cache, 10))
}
if current.Cycle != desired.Cycle {
if desired.Cycle {
clauses = append(clauses, "SET CYCLE")
} else {
clauses = append(clauses, "SET NO CYCLE")
}
}
return clauses
}
func identityKind(id model.ColumnIdentity) string {
if id.IsGeneratedAlways() {
return "ALWAYS"
}
return "BY DEFAULT"
}
// isSerialType reports whether the type name corresponds to a Postgres serial
// pseudo-type. catalog/columns.go renders these explicitly as "serial" /
// "bigserial" / "smallserial". A desired serial column carries no Default,
// so callers that need to drop the underlying nextval() default before an
// ALTER must check the type name to detect this case.
func isSerialType(typeName string) bool {
_, ok := serialBaseTypes[typeName]
return ok
}
// alterTypeName renders a type name for SET DATA TYPE. PostgreSQL accepts a
// serial pseudo-type only in CREATE TABLE and ADD COLUMN, so naming one in an
// ALTER fails with "type bigserial does not exist".
func alterTypeName(typeName string) string {
if base, ok := serialBaseTypes[typeName]; ok {
return base
}
return typeName
}
// alterSerialSequenceSQL keeps the sequence a serial column owns in step with
// the column's type. serial is an integer column and an integer sequence,
// bigserial a bigint one of each, and ALTER TABLE reaches only the column, so
// widening the column alone leaves the numbers stopping at the old type's
// maximum. PostgreSQL moves the bounds with the type when they are the old
// type's defaults.
//
// Returns "" for a column that owns no sequence, for one whose type is not
// changing, and for a type a sequence cannot hold. It also returns "" when
// neither side is a serial type. The sequence is then managed on its own, and
// the desired schema declares its type.
func alterSerialSequenceSQL(fqtn string, current, desired *model.Column) string {
if current.SerialSequence == nil {
return ""
}
if !isSerialType(current.TypeName) && !isSerialType(desired.TypeName) {
return ""
}
// Only a type change reaches the sequence. Without this the statement
// would go out on every plan for every serial column, since the column
// itself is compared elsewhere.
if equalTypeName(current.TypeName, desired.TypeName, schemaOf(fqtn)) {
return ""
}
base := alterTypeName(desired.TypeName)
if !sequenceTypes[base] {
return ""
}
return "ALTER SEQUENCE " + *current.SerialSequence + " AS " + base + ";"
}
// sequenceTypes lists the types a sequence can be declared AS.
var sequenceTypes = map[string]bool{
"smallint": true,
"integer": true,
"bigint": true,
}
// normalizeCheckExpr normalizes an expression so that semantically equivalent
// definitions compare as equal:
// - Strips casts to text-like types (text, varchar), which pg_get_constraintdef
// adds to anything string-typed and a written definition practically never
// carries.
// - Converts = ANY(ARRAY[...]) to IN (...) and <> ALL(ARRAY[...]) to
// NOT IN (...) (PostgreSQL internal representation), and collapses a
// one-element IN list into the = or <> parse analysis stores it as.
// - Folds NOT (a IS DISTINCT FROM b) into a IS NOT DISTINCT FROM b, the shape
// PostgreSQL rewrites the written operator into when it stores an
// expression.
// - Turns those operators against a bare NULL literal into the IS NULL test
// parse analysis rewrites them to, and inverts such a test under a NOT.
// - Expands BETWEEN, and drops the keyword spelling from LIKE, ILIKE and
// SIMILAR TO, as the grammar and parse analysis do before the expression
// reaches the catalog. desugar.go holds these, the row-constructor
// spelling, and the AND/OR flattening an expanded BETWEEN needs.
// - Drops the schema qualifier from a function call name, which the catalog
// omits whenever the function's schema is on the search_path.
//
// All of them are symmetric: they apply to the catalog side and the written
// side alike. The walk reaches every node kind, so a sub-query inside an RLS
// policy or a view body, and an expression inside a SQL/JSON constructor, get
// the same treatment as a top-level operand.
//
// The one position left alone is a top-level cast on a SELECT target: the cast
// type there decides the resulting view's output column type, so stripping it
// would hide a `SELECT col` -> `SELECT col::text` change.
func normalizeCheckExpr(node *pg_query.Node) *pg_query.Node {
return pgast.Walk(node, pgast.WalkOptions{}, normalizeExprNode)
}
// NormalizeCheckExpr applies the normalization the CHECK comparison applies to
// both sides, such as folding = ANY (ARRAY[...]) into IN (...) and expanding
// BETWEEN, and returns the new root.
func NormalizeCheckExpr(node *pg_query.Node) *pg_query.Node {
return normalizeCheckExpr(node)
}
func normalizeExprNode(ctx pgast.Ctx, node *pg_query.Node) *pg_query.Node {
if tc := node.GetTypeCast(); tc != nil {
if tc.Arg != nil && !ctx.IsSelectTarget() && isTextLikeTypeName(tc.TypeName) {
return tc.Arg
}
}
foldArrayComparison(node)
foldSingleElementIn(node)
if expanded := desugarBetween(node); expanded != nil {
return expanded
}
desugarPatternMatch(node)
normalizeRowFormat(node)
flattenBoolExpr(node)
if folded := foldNotDistinct(node); folded != nil {
return folded
}
if test := nullTestFromDistinct(node); test != nil {
return test
}
if folded := foldNotNullTest(node); folded != nil {
return folded
}
stripFuncSchema(node)
spreadVariadicArray(node)
dropCaseElseNull(node)
return node
}
// lastNamePart drops everything before the final part of a qualified name, so
// schema.f and catalog.schema.f both reduce to f. PostgreSQL accepts the
// three-part form when the catalog names the current database, and pg_query
// parses it, so matching on a two-part name alone would leave it drifting.
//
// parseTriggerDef applies it to an EXECUTE FUNCTION name, and
// normalizeIndexElem to an operator class name; neither is an expression node,
// so neither reaches the walk above.
func lastNamePart(name []*pg_query.Node) []*pg_query.Node {
if len(name) < 2 {
return name
}
return name[len(name)-1:]
}
// stripFuncSchema drops the schema qualifier from a function call name, so
// public.f(v) and f(v) compare equal.
//
// pg_get_constraintdef, pg_get_expr, pg_get_indexdef, pg_get_viewdef and
// pg_get_triggerdef print a call unqualified whenever the function's schema is
// on the search_path, while the desired side keeps what the file wrote. Left
// alone, a qualified call re-emits its constraint, index, policy, trigger,
// default or view on every plan, and a generated expression, which cannot be
// altered in place, fails the run outright.
//
// This is the treatment a view body's table reference already gets from
// stripQualifications, and it carries the same tradeoff: two same-named
// functions in different schemas compare equal, so moving a call from one
// schema to another produces no diff. Telling them apart means reading the
// search_path, which the diff does not thread.
func stripFuncSchema(node *pg_query.Node) {
if fc := node.GetFuncCall(); fc != nil {
fc.Funcname = lastNamePart(fc.Funcname)
}
}
// spreadVariadicArray turns f(x, VARIADIC ARRAY[a, b]) into f(x, a, b).
//
// PostgreSQL stores a call that spreads its arguments over a VARIADIC
// parameter as an array passed with VARIADIC, so pg_get_expr and the other
// deparsers hand back jsonb_extract_path_text(d, VARIADIC ARRAY['a'::text,
// 'b'::text]) for jsonb_extract_path_text(d, 'a', 'b'). Spreading the array on
// both sides makes the two compare equal; the text casts on the elements go
// with the cast stripping above. An empty array and VARIADIC over a column or
// another expression have no spread form, so they are left alone.
//
// This carries the same kind of tradeoff as stripFuncSchema: when a function
// has a non-variadic overload beside the variadic one, such as f(text, text,
// text) and f(text, VARIADIC text[]), the two spellings call different
// functions but compare equal, so switching between them produces no diff.
// Telling them apart means resolving the call against pg_proc, which the diff
// does not do.
func spreadVariadicArray(node *pg_query.Node) {
fc := node.GetFuncCall()
if fc == nil || !fc.FuncVariadic || len(fc.Args) == 0 {
return
}
last := len(fc.Args) - 1
arr := fc.Args[last].GetAArrayExpr()
if arr == nil || len(arr.Elements) == 0 {
return
}
fc.Args = append(fc.Args[:last:last], arr.Elements...)
fc.FuncVariadic = false
}
// dropCaseElseNull removes an ELSE NULL from a CASE, with or without a cast.
//
// PostgreSQL stores a CASE written without ELSE with an ELSE NULL cast to the
// result type, and the deparsers hand that back. The two mean the same, so
// the ELSE is dropped on both sides. The cost is that an ELSE NULL::bigint
// written to widen the result type of an integer CASE compares equal to no
// ELSE at all. Telling those apart needs the type of the THEN results, which
// the diff does not have.
func dropCaseElseNull(node *pg_query.Node) {
ce := node.GetCaseExpr()
if ce == nil || ce.Defresult == nil {
return
}
def := ce.Defresult
if tc := def.GetTypeCast(); tc != nil {
def = tc.Arg
}
if def.GetAConst().GetIsnull() {
ce.Defresult = nil
}
}
// foldNotDistinct turns NOT (a IS DISTINCT FROM b) into the equivalent
// a IS NOT DISTINCT FROM b, and returns nil for anything else.
//
// PostgreSQL stores the written IS NOT DISTINCT FROM as that negation, so
// pg_get_constraintdef and pg_get_expr hand back the NOT form either way.
// Folding towards the operator keeps the generated DDL in the spelling the
// author used.
//
// PostgreSQL does not collapse the double negation a written NOT (a IS NOT
// DISTINCT FROM b) leaves behind, and neither does this: the walk visits
// children first, so only the inner negation folds, which is what the written
// form parses to anyway.
func foldNotDistinct(node *pg_query.Node) *pg_query.Node {
be := node.GetBoolExpr()
if be == nil || be.Boolop != pg_query.BoolExprType_NOT_EXPR || len(be.Args) != 1 {
return nil
}
inner := be.Args[0].GetAExpr()
if inner == nil || inner.Kind != pg_query.A_Expr_Kind_AEXPR_DISTINCT {
return nil
}
inner.Kind = pg_query.A_Expr_Kind_AEXPR_NOT_DISTINCT
return be.Args[0]
}
// nullTestFromDistinct turns a IS NOT DISTINCT FROM NULL into a IS NULL and
// a IS DISTINCT FROM NULL into a IS NOT NULL, and returns nil for anything
// else.
//
// Parse analysis rewrites the comparison before the expression is stored, so
// pg_get_constraintdef and pg_get_expr hand back the test whichever way the
// definition was written. The fold above cannot reach it: the stored form is
// another node, not a negation. A negation the definition wrote around the
// test is left to foldNotNullTest below.
//
// Only a bare NULL literal is rewritten, which is what PostgreSQL does with
// one. A cast literal is left alone, and so is a row value, which the catalog
// keeps apart from the test; see isRowOperand.
func nullTestFromDistinct(node *pg_query.Node) *pg_query.Node {
ae := node.GetAExpr()
if ae == nil {
return nil
}
var testType pg_query.NullTestType
switch ae.Kind {
case pg_query.A_Expr_Kind_AEXPR_NOT_DISTINCT:
testType = pg_query.NullTestType_IS_NULL
case pg_query.A_Expr_Kind_AEXPR_DISTINCT:
testType = pg_query.NullTestType_IS_NOT_NULL
default:
return nil
}
arg := ae.Lexpr
if isNullConst(arg) {
arg = ae.Rexpr
} else if !isNullConst(ae.Rexpr) {
return nil
}
if isNullConst(arg) || isRowOperand(arg) {
return nil
}
return &pg_query.Node{
Node: &pg_query.Node_NullTest{
NullTest: &pg_query.NullTest{Arg: arg, Nulltesttype: testType},
},
}
}
// foldNotNullTest turns NOT (a IS NOT NULL) into a IS NULL and NOT (a IS NULL)
// into a IS NOT NULL, and returns nil for anything else.
//
// The two sides can differ by a NOT wrapper. PostgreSQL rewrites the
// comparison inside a negation the definition wrote and keeps the negation, so
// NOT (a IS DISTINCT FROM NULL) is stored as NOT (a IS NOT NULL). A cast
// literal is stored that way too, and the cast strip above then leaves the
// wrapper behind, while the same definition written without the cast folds
// straight to a IS NULL. Inverting the test lands both sides on one shape.
//
// A row value is left alone: IS NULL on a row asks whether every field is null
// and IS NOT NULL whether none is, so neither is the other's negation.
func foldNotNullTest(node *pg_query.Node) *pg_query.Node {
be := node.GetBoolExpr()
if be == nil || be.Boolop != pg_query.BoolExprType_NOT_EXPR || len(be.Args) != 1 {
return nil
}
nt := be.Args[0].GetNullTest()
if nt == nil || isRowOperand(nt.Arg) {
return nil
}
if nt.Nulltesttype == pg_query.NullTestType_IS_NULL {
nt.Nulltesttype = pg_query.NullTestType_IS_NOT_NULL
} else {
nt.Nulltesttype = pg_query.NullTestType_IS_NULL
}
return be.Args[0]
}
// isNullConst reports whether the node is a bare NULL literal.
func isNullConst(node *pg_query.Node) bool {
c := node.GetAConst()
return c != nil && c.Isnull
}
// isRowOperand reports whether the node is a row value the parse tree shows: a
// row constructor, or the whole-row reference a table name written as tbl.*
// produces.
//
// PostgreSQL rewrites the operator on a row value like on any other operand:
// what it stores is the scalar test, whatever the argument's type. The type
// check lives in the deparse, which prints IS NULL for a row test or a scalar
// argument and the operator otherwise, to keep the scalar meaning. So the
// catalog keeps the two spellings apart on a row-typed argument, a rewritten
// operator printing as the operator and a written row test as IS NULL, and
// folding them together here would invent an equality it never shows.
//
// A column whose type is composite is a plain ColumnRef, so it does not count
// and the rewrite fires on it. Both sides normalize alike, so nothing drifts;
// the cost is that p IS NULL and p IS NOT DISTINCT FROM NULL compare equal on
// such a column, and a change between the two is dropped from the plan.
func isRowOperand(node *pg_query.Node) bool {
return node.GetRowExpr() != nil ||
slices.ContainsFunc(node.GetColumnRef().GetFields(), func(f *pg_query.Node) bool { return f.GetAStar() != nil })
}
// isTextLikeTypeName returns true if the TypeName refers to a text-like type
// (text, text[], varchar, character varying, or their array forms).
// pg_get_constraintdef adds these casts on expressions that are already
// text-typed, so stripping them avoids false diffs.
func isTextLikeTypeName(tn *pg_query.TypeName) bool {
if tn == nil {
return false
}
for _, n := range tn.Names {
if s := n.GetString_(); s != nil {
switch s.Sval {
case "text", "varchar":
return true
}
}
}
return false
}
// isTextTypeName reports whether the TypeName is a bare text, the way the
// catalog prints the built-in type, and not an array of it.
func isTextTypeName(tn *pg_query.TypeName) bool {
return tn != nil && len(tn.ArrayBounds) == 0 && len(tn.Names) == 1 &&
tn.Names[0].GetString_().GetSval() == "text"
}
// isUntypedConst reports whether the node is a string or NULL constant, the
// constants PostgreSQL leaves typed unknown until their context resolves them.
func isUntypedConst(node *pg_query.Node) bool {
ac := node.GetAConst()
return ac != nil && (ac.Isnull || ac.GetSval() != nil)
}
// isNumericTypeName returns true if the TypeName refers to a built-in
// Postgres numeric scalar. pg_query canonicalises integer / bigint /
// smallint to int4 / int8 / int2 and double precision / real to float8 /
// float4 respectively, so both forms are accepted, but only the unqualified
// keyword (`integer`) and the `pg_catalog`-qualified canonical form
// (`pg_catalog.int4`) match; a user-defined type that happens to be named
// e.g. `myapp.int4` does not gate the Sval->numeric coercion below, so a
// stripped cast on a custom-type Sval is left as-is rather than rewritten
// to a built-in numeric A_Const. (The strip itself is unconditional; that
// is the asymmetric rule from #201; but coercion is narrowed to the
// genuinely numeric built-ins.)
func isNumericTypeName(tn *pg_query.TypeName) bool {
if tn == nil || len(tn.Names) == 0 {
return false
}
var name string
switch len(tn.Names) {
case 1:
s := tn.Names[0].GetString_()
if s == nil {
return false
}
name = s.Sval
case 2:
schema := tn.Names[0].GetString_()
s := tn.Names[1].GetString_()
if schema == nil || s == nil || schema.Sval != "pg_catalog" {
return false
}
name = s.Sval
default:
return false
}
switch name {
case "int2", "int4", "int8", "smallint", "integer", "bigint",
"numeric", "decimal", "float4", "float8", "real":
return true
}
return false
}
// desiredIsNumericAConst reports whether a desired-side node is a bare
// numeric A_Const (Ival or Fval). Used to gate the Sval->numeric coercion
// in the asymmetric cast-strip path so that desired-side quoted literals
// (`'0'`, `'1'`) keep matching the cast-stripped Sval as before.
func desiredIsNumericAConst(n *pg_query.Node) bool {
ac := n.GetAConst()
return ac.GetIval() != nil || ac.GetFval() != nil
}
// peerIsNumericAtTopLevel reports whether peer is (or would become after a
// symmetric top-level TypeCast strip) a numeric A_Const. Used by
// equalDefault's two-step symmetric strip: the first call passes the
// not-yet-stripped peer, so we have to look through a numeric-typed
// top-level TypeCast to decide whether the peer will end up numeric.
// Otherwise `'0'::integer` vs `0::integer` would fail to coerce the
// current Sval and diff `'0'` against `0`.
func peerIsNumericAtTopLevel(n *pg_query.Node) bool {
if desiredIsNumericAConst(n) {
return true
}
tc := n.GetTypeCast()
if tc == nil || tc.Arg == nil || !isNumericTypeName(tc.TypeName) {
return false
}
if desiredIsNumericAConst(tc.Arg) {
return true
}
if sv := tc.Arg.GetAConst().GetSval(); sv != nil {
return numericAConstFromString(sv.Sval) != nil
}
return false
}
// numericAConstFromString converts a numeric-valued string into an A_Const
// node. pg_get_constraintdef emits negative numeric literals as
// `'-40'::integer` (to dodge the unary-minus precedence trap that `-40::int`
// would hit), while user-written `-40` parses to A_Const{Ival -40}. After
// alignCurrentCasts strips the TypeCast wrapper, the bare A_Const{Sval "-40"}
// that survives would still deparse to `'-40'` and diff against `-40`. This
// helper produces the canonical Ival / Fval form so the two compare equal.
// Returns nil for non-numeric strings; callers leave the original node alone
// in that case.
func numericAConstFromString(s string) *pg_query.Node {
if n, err := strconv.ParseInt(s, 10, 32); err == nil {
return &pg_query.Node{
Node: &pg_query.Node_AConst{
AConst: &pg_query.A_Const{
Val: &pg_query.A_Const_Ival{
Ival: &pg_query.Integer{Ival: int32(n)},
},
},
},
}
}
// ParseFloat returns ErrRange (with +/-Inf as value) for syntactically
// valid numerics that exceed float64; but PG's `numeric` carries those
// just fine and pg_query's Fval is just a string, so accept ErrRange as
// "lexically a number, store the original string verbatim".
if _, err := strconv.ParseFloat(s, 64); err == nil || errors.Is(err, strconv.ErrRange) {
return &pg_query.Node{
Node: &pg_query.Node_AConst{
AConst: &pg_query.A_Const{
Val: &pg_query.A_Const_Fval{
Fval: &pg_query.Float{Fval: s},
},
},
},
}
}
return nil
}
// equalConstraintDef compares two constraint definitions by normalizing
// them through pg_query parse/deparse, so that formatting differences
// (e.g. extra parentheses, spacing) do not cause false diffs.
//
// The comparison is asymmetric with respect to type casts: pg_get_constraintdef
// often adds explicit casts (e.g. '00:00:00'::time, '0'::integer) that the
// user did not write in the desired schema. When current has a TypeCast at
// a position where desired has none, the cast is stripped from current so
// the two compare equal. Non-text-like casts present in desired are
// preserved, so an explicit user-written `'0'::integer` that disagrees
// with the DB still surfaces. Text-like casts (`text`, `varchar`) are
// stripped on both sides by normalizeCheckExpr regardless of who wrote
// them, since pg_get_constraintdef adds them to anything string-typed
// and the user-written form practically never includes them.
func equalConstraintDef(current, desired string) bool {
if current == desired {
return true
}
curResult, curCon, parseErrCur := pgast.ParseConstraintDefStrict(current)
desResult, desCon, parseErrDes := pgast.ParseConstraintDefStrict(desired)
if parseErrCur != nil || parseErrDes != nil {
return false
}
curCon.RawExpr = normalizeCheckExpr(curCon.RawExpr)
desCon.RawExpr = normalizeCheckExpr(desCon.RawExpr)
curCon.RawExpr = alignCurrentCasts(desCon.RawExpr, curCon.RawExpr)
normalizeExclusion(curCon)
normalizeExclusion(desCon)
alignExclusionCasts(desCon, curCon)
// The WITH clause of a unique, primary key, or exclusion constraint,
// folded the way a plain index's is.
normalizeStorageParams(curCon.Options)
normalizeStorageParams(desCon.Options)
curStr, deparseErrCur := pg_query.Deparse(curResult)
desStr, deparseErrDes := pg_query.Deparse(desResult)
return deparseErrCur == nil && deparseErrDes == nil && curStr == desStr
}
// normalizeExclusion runs the symmetric normalizations over an exclusion
// constraint's elements and predicate, which sit in Exclusions and WhereClause
// rather than in the RawExpr a CHECK body uses. Each element is an IndexElem,
// so it takes the same canonicalization as an index column. A non-exclusion
// constraint holds neither field and passes through untouched.
func normalizeExclusion(con *pg_query.Constraint) {
for _, ex := range con.Exclusions {
list := ex.GetList()
if list == nil {
continue
}
for _, item := range list.Items {
if ie := item.GetIndexElem(); ie != nil {
normalizeIndexElem(ie)
}
// The operator name list. The catalog prints a visible operator
// bare, so the qualification is dropped from both sides, the way
// stripFuncSchema treats a function name.
if ops := item.GetList(); ops != nil {
ops.Items = lastNamePart(ops.Items)
}
}
}
if con.WhereClause != nil {
con.WhereClause = normalizeCheckExpr(con.WhereClause)
}
}
// alignExclusionCasts strips the casts pg_get_constraintdef adds from the
// current side of an exclusion, pairing each element expression and the
// predicate with its desired counterpart the way a CHECK body is paired.
// Sides whose element counts differ are left alone; the definitions differ
// anyway.
func alignExclusionCasts(desired, current *pg_query.Constraint) {
if len(desired.Exclusions) == len(current.Exclusions) {
for i, ex := range current.Exclusions {
curList := ex.GetList()
desList := desired.Exclusions[i].GetList()
if curList == nil || desList == nil || len(curList.Items) != len(desList.Items) {
continue
}
for j, item := range curList.Items {
curIe := item.GetIndexElem()
desIe := desList.Items[j].GetIndexElem()
if curIe == nil || desIe == nil || curIe.Expr == nil || desIe.Expr == nil {
continue
}
curIe.Expr = alignCurrentCasts(desIe.Expr, curIe.Expr)
}
}
}
if desired.WhereClause != nil && current.WhereClause != nil {
current.WhereClause = alignCurrentCasts(desired.WhereClause, current.WhereClause)
}
}
// alignCurrentCasts walks desired and current in step, stripping TypeCast
// wrappers from current at positions where desired has no matching TypeCast.
// pg_get_constraintdef and pg_get_viewdef spell out casts the written form
// leaves implicit ('00:00:00'::time, '0'::integer, 'lit'::enum_type), and a
// cast the desired side did write is compared as written, so a real
// disagreement still surfaces.
//
// The function mutates the current tree in place and returns the (possibly
// unwrapped) current node so callers can reassign at parent boundaries.
func alignCurrentCasts(desired, current *pg_query.Node) *pg_query.Node {
return pgast.WalkPair(desired, current, alignCastNode)
}
func alignCastNode(ctx pgast.Ctx, desired, current *pg_query.Node) *pg_query.Node {
// A cast on a SELECT target decides the view's output column type, so one
// side carrying it and the other not is a difference rather than something
// to strip. Below a cast matched on both sides the normal rule applies
// again, because the position is then a cast argument rather than a target.
//
// The exception is a bare string or NULL constant. It resolves to text on
// a target, and pg_get_viewdef prints that type ('i'::text), so the cast
// changes nothing there.
if ctx.IsSelectTarget() && desired.GetTypeCast() == nil && current.GetTypeCast() != nil {
if ct := current.GetTypeCast(); isTextTypeName(ct.TypeName) && isUntypedConst(desired) && isUntypedConst(ct.Arg) {
return ct.Arg
}
return current
}
for {
ct := current.GetTypeCast()
if ct == nil || desired.GetTypeCast() != nil {
return current
}
if ct.Arg == nil {
return current
}
arg := ct.Arg
// When stripping a cast on a string literal of a numeric type, coerce
// the surviving A_Const{Sval "<n>"} back to A_Const{Ival/Fval} so it
// matches a user-written bare numeric literal; but only when desired
// at this position is itself a numeric (non-string) A_Const. If the
// user wrote a quoted form (`'0'`), leave the stripped Sval intact so
// it still compares equal to the quoted desired.
if isNumericTypeName(ct.TypeName) && desiredIsNumericAConst(desired) {
if sv := arg.GetAConst().GetSval(); sv != nil {
if numeric := numericAConstFromString(sv.Sval); numeric != nil {
arg = numeric
}
}
}
current = arg
}
}
// definitionChange records how one constraint or foreign key present on both
// sides differs. equalConstraintDef and compareFKDef parse both definitions,
// which is the most expensive thing a table diff does, and the rename, drop and
// add loops below each ask the same question: comparing once and reading the
// answer three times is what keeps a large schema off three parses per object.
type definitionChange struct {
// changed says the object cannot stay as it is, so it is dropped and added
// back; PostgreSQL has no ALTER for a constraint definition.
changed bool
// validateOnly says the definitions match and only the NOT VALID flag
// differs, which VALIDATE CONSTRAINT settles in place.
validateOnly bool
// deferralOnly says the definitions match apart from the deferral clause,
// which ALTER CONSTRAINT settles in place. Foreign keys only: PostgreSQL
// rejects ALTER CONSTRAINT on every other constraint type.
deferralOnly bool
}
// renameConstraintSQL renders the rename a constraint and a foreign key share;
// PostgreSQL spells both ALTER TABLE ... RENAME CONSTRAINT.
func renameConstraintSQL(fqtn, oldName, newName string) string {
return "ALTER TABLE " + fqtn + " RENAME CONSTRAINT " + model.Ident(oldName) + " TO " + model.Ident(newName) + ";"
}
func newDefinitionChange(sameDef, currentValidated, desiredValidated bool) definitionChange {
return definitionChange{
changed: !sameDef || currentValidated != desiredValidated,
validateOnly: sameDef && !currentValidated && desiredValidated,
}
}
// constraintChanges compares every constraint present on both sides once.
func constraintChanges(current, desired *orderedmap.Map[string, *model.Constraint]) map[string]definitionChange {
changes := make(map[string]definitionChange, desired.Len())
for name, desiredCon := range desired.All() {
currentCon, ok := current.GetOk(name)
if !ok {
continue
}
changes[name] = newDefinitionChange(
sameConstraintDef(currentCon, desiredCon),
currentCon.Validated, desiredCon.Validated)
}
return changes
}
// sameConstraintDef says whether the two sides describe the same constraint.
// A desired constraint written ADD CONSTRAINT ... USING INDEX carries no
// column list, only an index name, and the promotion renames that index to
// the constraint's name, so once the constraint exists there is nothing left
// to compare the name to: the catalog renders what it created as a plain
// UNIQUE (col). The form is transitional, like NOT VALID, so an existing
// constraint of the same name, type and deferral satisfies it; the columns
// are pinned down when the file is rewritten the way dump writes it.
func sameConstraintDef(current, desired *model.Constraint) bool {
if desired.IndexName != "" {
return desired.Type == current.Type &&
desired.Deferrable == current.Deferrable &&
desired.Deferred == current.Deferred
}
return equalConstraintDef(current.Definition, desired.Definition)
}
// diffConstraints returns the constraint statements and, apart from them, the
// COMMENT ON statements the constraints need, so the caller can put the
// comments with the table's other comments instead of between the ALTER
// TABLE statements --bulk-alter merges. The DROP CONSTRAINT statements are
// returned apart, so the caller can put them before a column type change: it
// rebuilds the CHECK constraints on the column, and one the new type rejects
// fails it. The ADD of a constraint written USING INDEX is also returned
// apart, so the caller can put it after the CREATE INDEX of its index.
func diffConstraints(fqtn string, current, desired *orderedmap.Map[string, *model.Constraint], dc DropChecker) (dropStmts []string, stmts []string, commentStmts []string, disallowed []string, usingIndexStmts []string, err error) {
dc = normalizeDropChecker(dc)
// Detect renames
current, renamedFrom, err := detectConstraintRenames(fqtn, current, desired)
if err != nil {
return nil, nil, nil, nil, nil, err
}
changes := constraintChanges(current, desired)
// Determine which renamed constraints need recreation instead of just rename
needsRecreation := map[string]bool{}
for name := range renamedFrom {
if ch := changes[name]; ch.changed && !ch.validateOnly {
needsRecreation[name] = true
}
}
// The renames that stand follow the desired schema's order. One whose
// constraint is being recreated is left out, since the ADD below already
// puts the new name in place.
for name := range desired.Keys() {
oldName, renamed := renamedFrom[name]
if !renamed || needsRecreation[name] {
continue
}
stmts = append(stmts, renameConstraintSQL(fqtn, oldName, name))
}
// Drop removed or changed constraints. Pure removals (constraint absent
// from desired) honor the constraint-drop policy; definition changes still
// run DROP+ADD. A NOT VALID -> validated constraint keeps its definition,
// so it is left to VALIDATE CONSTRAINT below.
conAllowed := dc.IsDropAllowed("constraint")
for name := range current.Keys() {
_, ok := desired.GetOk(name)
ch := changes[name]
if ok && (!ch.changed || ch.validateOnly) {
continue
}
dropName := name
if oldName, renamed := renamedFrom[name]; renamed {
dropName = oldName
}
drop := "ALTER TABLE " + fqtn + " DROP CONSTRAINT " + model.Ident(dropName) + ";"
if !ok && !conAllowed {
disallowed = append(disallowed, "-- skipped: "+drop)
continue
}
dropStmts = append(dropStmts, drop)
}
// Add new or changed constraints, or validate an existing NOT VALID one.
for name, desiredCon := range desired.All() {
_, ok := current.GetOk(name)
ch := changes[name]
if ok && !ch.changed {
continue
}
if ch.validateOnly {
stmts = append(stmts, "ALTER TABLE "+fqtn+" VALIDATE CONSTRAINT "+model.Ident(name)+";")
continue
}
sql := "ALTER TABLE " + fqtn + " ADD CONSTRAINT " + model.Ident(name) + " " + desiredCon.Definition
if desiredCon.IndexName != "" {
usingIndexStmts = append(usingIndexStmts, sql+";")
continue
}
if !desiredCon.Validated {
sql += " NOT VALID"
}
stmts = append(stmts, sql+";")
}
// A constraint added above has no comment yet.
for name, desiredCon := range desired.All() {
var currentComment *string
if currentCon, ok := current.GetOk(name); ok {
if ch := changes[name]; !ch.changed || ch.validateOnly {
currentComment = currentCon.Comment
}
}
if !equalPtr(currentComment, desiredCon.Comment) {
commentStmts = append(commentStmts, constraintCommentSQL(fqtn, name, desiredCon.Comment))
}
}
return dropStmts, stmts, commentStmts, disallowed, usingIndexStmts, nil
}
// constraintCommentSQL renders the COMMENT ON a table constraint and a foreign
// key share.
func constraintCommentSQL(fqtn, name string, comment *string) string {
return commentOnSQL("CONSTRAINT "+model.Ident(name)+" ON "+fqtn, comment)
}
// equalIndexDefs compares every index present on both sides once. Like a
// constraint definition, an index definition is compared by parsing both
// sides, and the drop and add loops each ask the same question.
func equalIndexDefs(current, desired *orderedmap.Map[string, *model.Index], columns *orderedmap.Map[string, *model.Column]) map[string]bool {
same := make(map[string]bool, desired.Len())
for name, desiredIdx := range desired.All() {
if currentIdx, ok := current.GetOk(name); ok {
same[name] = equalIndexDef(currentIdx.Definition, desiredIdx.Definition, columns)
}
}
return same
}
// checkPartitionedConcurrently refuses CONCURRENTLY on an index of a
// partitioned table, which PostgreSQL cannot create or drop that way. Leaving
// the keyword out would take the lock the user asked to avoid.
func checkPartitionedConcurrently(idx *model.Index, partitioned bool) error {
if idx.Concurrently && partitioned {
return partitionedConcurrentlyError(idx.Schema, idx.Name, idx.Table)
}
return nil
}
func partitionedConcurrentlyError(schema, name, table string) error {
return fmt.Errorf("index %s: CONCURRENTLY is not supported on partitioned table %s", model.Ident(schema, name), model.Ident(schema, table))
}
type diffIndexesResult struct {
Stmts []string
// PartitionedStmts holds the CREATE INDEX and comment of each new or
// changed index on a partitioned table.
PartitionedStmts []string
DisallowedDropStmts []string
HasConcurrently bool
}
// usingIndexNames collects the index names the desired constraints take over
// via ADD CONSTRAINT ... USING INDEX. The value says whether the constraint
// already exists and so has taken the index over. When it has not, the plan
// adds the constraint, and the index has to exist first.
func usingIndexNames(current, desired *orderedmap.Map[string, *model.Constraint]) map[string]bool {
var names map[string]bool
for name, con := range desired.All() {
if con.IndexName != "" {
if names == nil {
names = map[string]bool{}
}
currentCon, ok := current.GetOk(name)
names[con.IndexName] = ok && sameConstraintDef(currentCon, con)
}
}
return names
}
func diffIndexes(current, desired *orderedmap.Map[string, *model.Index], columns *orderedmap.Map[string, *model.Column], consumed map[string]bool, partitioned bool, dc DropChecker) (*diffIndexesResult, error) {
dc = normalizeDropChecker(dc)
result := &diffIndexesResult{}
// Detect renames
renameStmts, current, err := detectIndexRenames(current, desired)
if err != nil {
return nil, err
}
result.Stmts = append(result.Stmts, renameStmts...)
sameDef := equalIndexDefs(current, desired, columns)
// Drop removed or changed indexes. Pure removals (index absent from
// desired) honor the index-drop policy; definition changes still run
// DROP+CREATE because PostgreSQL has no ALTER INDEX for definitions.
idxAllowed := dc.IsDropAllowed("index")
for name, currentIdx := range current.All() {
desiredIdx, ok := desired.GetOk(name)
if _, taken := consumed[name]; !ok && taken {
// The index is not going away: a desired USING INDEX constraint
// takes it over, so dropping it would pull the index out from
// under the ADD CONSTRAINT.
continue
}
if currentIdx.Attached {
// Dropped with the parent's index, and a DROP of its own fails.
continue
}
if !ok || !sameDef[name] {
// Use CONCURRENTLY when the desired index (when it exists and is
// being changed) has the per-index directive. For pure drops the
// desired entry is absent, so fall back to the current entry; that
// field is normally false because the catalog doesn't carry it,
// but --force-index-concurrently sets it via setConcurrentlyDirectives.
useConcurrently := currentIdx.Concurrently
if ok {
useConcurrently = desiredIdx.Concurrently
}
stmt, err := dropIndexSQL(currentIdx.Schema, name, useConcurrently)
if err != nil {
return nil, fmt.Errorf("drop index %s: %w", model.Ident(currentIdx.Schema, name), err)
}
if !ok && !idxAllowed {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+stmt)
continue
}
// Only a drop the plan runs is refused; a skipped one runs nothing.
if useConcurrently && partitioned {
return nil, partitionedConcurrentlyError(currentIdx.Schema, name, currentIdx.Table)
}
result.Stmts = append(result.Stmts, stmt)
if useConcurrently {
result.HasConcurrently = true
}
}
}
// Add new or changed indexes
for name, desiredIdx := range desired.All() {
currentIdx, ok := current.GetOk(name)
if !ok && consumed[name] {
// An existing USING INDEX constraint owns this index, and an owned
// index is not read as a plain one, so declaring it next to the
// constraint is not a missing index.
continue
}
if ok && sameDef[name] {
// The index stays, so only a comment that differs is emitted.
result.Stmts = append(result.Stmts, indexCommentStmts(currentIdx.Comment, desiredIdx)...)
continue
}
if err := checkPartitionedConcurrently(desiredIdx, partitioned); err != nil {
return nil, err
}
stmt, err := createIndexSQL(desiredIdx.Definition, desiredIdx.Concurrently)
if err != nil {
return nil, fmt.Errorf("create index %s: %w", model.Ident(desiredIdx.Schema, name), err)
}
if desiredIdx.Concurrently {
result.HasConcurrently = true
}
// A recreated index carries no comment, so the desired one is emitted
// against nothing rather than against what the old index had.
stmts := append([]string{stmt}, indexCommentStmts(nil, desiredIdx)...)
if partitioned {
result.PartitionedStmts = append(result.PartitionedStmts, stmts...)
} else {
result.Stmts = append(result.Stmts, stmts...)
}
}
return result, nil
}
// indexCommentStmts returns the COMMENT ON INDEX the index needs to reach the
// desired comment, or nothing when the two already agree.
func indexCommentStmts(currentComment *string, desired *model.Index) []string {
if equalPtr(currentComment, desired.Comment) {
return nil
}
return []string{commentOnSQL("INDEX "+model.Ident(desired.Schema, desired.Name), desired.Comment)}
}
// dropIndexSQL builds a DROP INDEX statement, optionally with CONCURRENTLY
// set via the pg_query AST.
func dropIndexSQL(schema, name string, concurrently bool) (string, error) {
base := "DROP INDEX " + model.Ident(schema, name)
if !concurrently {
return base + ";", nil
}
result, err := pg_query.Parse(base)
if err != nil {
return "", fmt.Errorf("failed to parse drop index statement: %w", err)
}
ds := result.Stmts[0].Stmt.GetDropStmt()
if ds == nil {
return "", fmt.Errorf("expected DropStmt for: %s", base)
}
ds.Concurrent = true
deparsed, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse drop index statement: %w", err)
}
return deparsed + ";", nil
}
// createIndexSQL returns a CREATE INDEX statement with the CONCURRENTLY flag
// set via the pg_query AST when concurrently is true.
func createIndexSQL(def string, concurrently bool) (string, error) {
if !concurrently {
return def + ";", nil
}
result, err := pg_query.Parse(def)
if err != nil {
return "", fmt.Errorf("failed to parse index definition: %w", err)
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return "", fmt.Errorf("expected IndexStmt for: %s", def)
}
is.Concurrent = true
deparsed, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse index definition: %w", err)
}
return deparsed + ";", nil
}
// foreignKeyChanges compares every foreign key present on both sides once.
// schema is the owning table's, which a bare reference may mean and which
// stands in for a key that carries no RefSchema.
func foreignKeyChanges(current, desired *orderedmap.Map[string, *model.ForeignKey], schema string) map[string]definitionChange {
changes := make(map[string]definitionChange, desired.Len())
for name, desiredFk := range desired.All() {
currentFk, ok := current.GetOk(name)
if !ok {
continue
}
// A partition's copy of the parent's key changes with the parent and
// takes no statement of its own. PostgreSQL rejects a DROP or an
// ALTER CONSTRAINT on a copy, and every statement the parent's key
// takes reaches it: the parent's DROP takes the copy with it, its ADD
// puts a new copy back, and its ALTER CONSTRAINT and VALIDATE
// CONSTRAINT recurse. A rename is the exception the diff still emits,
// since a copy carries a name of its own.
if currentFk.Inherited {
changes[name] = definitionChange{}
continue
}
sameDef, deferralOnly := compareFKDef(currentFk.Definition, desiredFk.Definition, fkRefSchema(currentFk, schema), fkRefSchema(desiredFk, schema), schema)
change := newDefinitionChange(sameDef, currentFk.Validated, desiredFk.Validated)
switch {
case !deferralOnly:
case currentFk.Validated && !desiredFk.Validated:
// Nothing takes the validated flag away, so the key is added back
// and the ADD carries the deferral clause with it.
default:
change.deferralOnly = true
// The key stays, so a NOT VALID one the desired schema validates
// takes VALIDATE CONSTRAINT next to the ALTER.
change.validateOnly = !currentFk.Validated && desiredFk.Validated
}
changes[name] = change
}
return changes
}
// alterFKDeferralSQL renders the ALTER CONSTRAINT that moves a foreign key to
// the desired deferral mode. The clause is spelled the way
// pg_get_constraintdef prints it, and NOT DEFERRABLE clears both flags.
func alterFKDeferralSQL(fqtn, name string, deferrable, deferred bool) string {
clause := "NOT DEFERRABLE"
switch {
case deferrable && deferred:
clause = "DEFERRABLE INITIALLY DEFERRED"
case deferrable:
clause = "DEFERRABLE"
}
return "ALTER TABLE " + fqtn + " ALTER CONSTRAINT " + model.Ident(name) + " " + clause + ";"
}
// diffForeignKeys returns (dropStmts, addStmts, disallowed, error).
// Rename statements are included in addStmts (they depend on table renames being done first).
// Pure FK removals (FK absent from desired while the owning table stays) honor
// --allow-drop=foreign_key; FK drops emitted because the owning table is being
// dropped follow the table policy (handled in DiffTables).
func diffForeignKeys(fqtn, schema string, partitioned bool, current, desired *orderedmap.Map[string, *model.ForeignKey], dc DropChecker) (dropStmts, addStmts, disallowed []string, err error) {
dc = normalizeDropChecker(dc)
// Detect renames (renames go into addStmts since they may depend on table renames)
current, renamedFrom, err := detectForeignKeyRenames(fqtn, current, desired)
if err != nil {
return nil, nil, nil, err
}
changes := foreignKeyChanges(current, desired, schema)
// Determine which renamed FKs need recreation (drop+add) instead of just rename
needsRecreation := map[string]bool{}
for name := range renamedFrom {
if ch := changes[name]; ch.changed && !ch.validateOnly && !ch.deferralOnly {
needsRecreation[name] = true
}
}
// The renames that stand follow the desired schema's order. One whose key
// is being recreated is left out, since the ADD below already puts the new
// name in place.
for name := range desired.Keys() {
oldName, renamed := renamedFrom[name]
if !renamed || needsRecreation[name] {
continue
}
addStmts = append(addStmts, renameConstraintSQL(fqtn, oldName, name))
}
// Drop removed or changed FKs. A NOT VALID -> validated key keeps its
// definition, so it is left to VALIDATE CONSTRAINT below.
fkAllowed := dc.IsDropAllowed("foreign_key")
for name, currentFk := range current.All() {
_, ok := desired.GetOk(name)
ch := changes[name]
if ok && (!ch.changed || ch.validateOnly || ch.deferralOnly) {
continue
}
// A key the desired schema drops altogether reaches this with no entry
// in changes, so the copy is tested here as well as above.
if currentFk.Inherited {
continue
}
dropName := name
if oldName, renamed := renamedFrom[name]; renamed {
dropName = oldName
}
drop := "ALTER TABLE " + fqtn + " DROP CONSTRAINT " + model.Ident(dropName) + ";"
if !ok && !fkAllowed {
disallowed = append(disallowed, "-- skipped: "+drop)
continue
}
dropStmts = append(dropStmts, drop)
}
// Add new or changed FKs, or validate an existing NOT VALID one.
for name, desiredFk := range desired.All() {
_, ok := current.GetOk(name)
ch := changes[name]
if ok && !ch.changed {
continue
}
if ch.deferralOnly || ch.validateOnly {
// The key stays in place. Each statement settles one half, and a
// change that needs both takes both.
if ch.deferralOnly {
addStmts = append(addStmts, alterFKDeferralSQL(fqtn, name, desiredFk.Deferrable, desiredFk.Deferred))
}
if ch.validateOnly {
addStmts = append(addStmts, "ALTER TABLE "+fqtn+" VALIDATE CONSTRAINT "+model.Ident(name)+";")
}
continue
}
addStmts = append(addStmts, desiredFk.SQL(partitioned))
}
// A key added above has no comment yet.
for name, desiredFk := range desired.All() {
var currentComment *string
if currentFk, ok := current.GetOk(name); ok {
if ch := changes[name]; !ch.changed || ch.validateOnly || ch.deferralOnly {
currentComment = currentFk.Comment
}
}
if !equalPtr(currentComment, desiredFk.Comment) {
addStmts = append(addStmts, constraintCommentSQL(fqtn, name, desiredFk.Comment))
}
}
return dropStmts, addStmts, disallowed, nil
}
// withInheritedColumns returns a copy of a partition child whose column set
// also holds the inherited columns the desired definition never declares. Only
// columns carrying an explicit COMMENT ON COLUMN reach the desired side, so
// without the rest a dropped comment would compare against nothing and stay.
func withInheritedColumns(current, desired *model.Table) *model.Table {
cols := orderedmap.New[string, *model.Column]()
for name := range current.Columns.Keys() {
if col, ok := desired.Columns.GetOk(name); ok {
cols.Set(name, col)
} else {
cols.Set(name, &model.Column{Name: name})
}
}
// A comment on a column the table does not have is kept so it still
// reaches PostgreSQL and fails there rather than disappearing here.
for name, col := range desired.Columns.All() {
if _, ok := cols.GetOk(name); !ok {
cols.Set(name, col)
}
}
clone := *desired
clone.Columns = cols
return &clone
}
func diffComments(current, desired *model.Table) []string {
var stmts []string
fqtn := desired.FQTN()
// Table comment
if !equalPtr(current.Comment, desired.Comment) {
stmts = append(stmts, commentOnSQL("TABLE "+fqtn, desired.Comment))
}
// Column comments
for name, desiredCol := range desired.Columns.All() {
var currentComment *string
if currentCol, ok := current.Columns.GetOk(name); ok {
currentComment = currentCol.Comment
}
if !equalPtr(currentComment, desiredCol.Comment) {
colIdent := fqtn + "." + model.Ident(name)
stmts = append(stmts, commentOnSQL("COLUMN "+colIdent, desiredCol.Comment))
}
}
return stmts
}
// commentOnSQL returns COMMENT ON target setting comment, or clearing it when
// comment is nil.
func commentOnSQL(target string, comment *string) string {
if comment == nil {
return "COMMENT ON " + target + " IS NULL;"
}
return "COMMENT ON " + target + " IS " + model.QuoteLiteral(*comment) + ";"
}
func equalPtr[T comparable](a, b *T) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// parseIndexDef parses a CREATE INDEX statement and returns the parse
// result plus the embedded IndexStmt. Callers may mutate the IndexStmt
// (e.g. canonicalise sort/nulls, normalize sub-expressions) before
// re-deparsing the result.
func parseIndexDef(def string) (*pg_query.ParseResult, *pg_query.IndexStmt, error) {
result, err := pg_query.Parse(def)
if err != nil {
return nil, nil, err
}
if len(result.Stmts) == 0 {
return nil, nil, fmt.Errorf("unexpected parse result for index definition: %s", def)
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return nil, nil, fmt.Errorf("unexpected parse result for index definition: %s", def)
}
return result, is, nil
}
// normalizeIndexStmt applies the symmetric normalisations to an IndexStmt
// in place: clears the schema qualification (so unqualified and qualified
// table refs compare equal), canonicalises explicit ASC / NULLS LAST /
// NULLS FIRST defaults, and runs normalizeCheckExpr over each
// IndexElem.Expr (expression-index body) and the partial-index
// WhereClause so symmetric paren / text-cast / `IN`<->`ANY` differences
// don't surface as false diffs.
func normalizeIndexStmt(is *pg_query.IndexStmt) {
if is == nil {
return
}
if is.Relation != nil {
is.Relation.Schemaname = ""
// pg_get_indexdef writes ONLY for every index on a partitioned table,
// however it was created, and never for one on another table.
is.Relation.Inh = true
}
for _, p := range is.IndexParams {
if ie := p.GetIndexElem(); ie != nil {
normalizeIndexElem(ie)
}
}
if is.WhereClause != nil {
is.WhereClause = normalizeCheckExpr(is.WhereClause)
}
normalizeStorageParams(is.Options)
}
// normalizeIndexElem canonicalises one index element in place, for an index
// column and an exclusion element alike. An explicit ASC and the default sort
// order compare equal, so does the NULLS order that is the default for the
// chosen direction, and the expression takes the symmetric expression
// normalizations.
//
// The operator class and the collation lose their schema qualifier, which
// ruleutils (src/backend/utils/adt/ruleutils.c) writes only for a name off the
// search_path, and the class's options take the storage-parameter fold: they
// go through get_reloptions, the function that writes the WITH clause, so a
// value that does not read as an identifier comes back quoted. A file that
// qualified the class or wrote `siglen=32` for the catalog's `siglen='32'`
// re-created the index on every plan. Dropping the qualifier carries the
// tradeoff stripFuncSchema does.
//
// Which class and which collation the element names is left alone. PostgreSQL
// omits the class when it is the default for the column's type, so a file that
// writes it out still drifts; telling that from a real change means a lookup
// the diff does not thread. LIMITATIONS.md covers it. The collation is handled
// by dropColumnCollation, which needs the table's columns.
func normalizeIndexElem(ie *pg_query.IndexElem) {
if ie.Ordering == pg_query.SortByDir_SORTBY_ASC {
ie.Ordering = pg_query.SortByDir_SORTBY_DEFAULT
}
switch ie.Ordering {
case pg_query.SortByDir_SORTBY_DEFAULT:
if ie.NullsOrdering == pg_query.SortByNulls_SORTBY_NULLS_LAST {
ie.NullsOrdering = pg_query.SortByNulls_SORTBY_NULLS_DEFAULT
}
case pg_query.SortByDir_SORTBY_DESC:
if ie.NullsOrdering == pg_query.SortByNulls_SORTBY_NULLS_FIRST {
ie.NullsOrdering = pg_query.SortByNulls_SORTBY_NULLS_DEFAULT
}
}
ie.Collation = lastNamePart(ie.Collation)
ie.Opclass = lastNamePart(ie.Opclass)
normalizeStorageParams(ie.Opclassopts)
if ie.Expr != nil {
ie.Expr = normalizeCheckExpr(ie.Expr)
}
}
// normalizeStorageParams canonicalises an index's WITH clause, and an operator
// class's option list, so the two spellings of one parameter compare equal.
// pg_get_indexdef quotes a value that does not read as an identifier,
// `fillfactor='80'`, while a file writes the number bare, so the two arrive as
// a String and an Integer node. The order is not part of the setting either,
// and the catalog keeps the one the parameters were created in. Without this
// an index carrying a parameter was dropped and recreated on every run.
//
// An integer is folded to its decimal string, and a fractional value to the
// digits the file wrote: a BRIN bloom class takes `false_positive_rate=0.05`,
// which parses as a Float. A bare word, `deduplicate_items=off`, parses as a
// single-name TypeName while the quoted spelling parses as a String, so the
// TypeName is folded to a String too.
func normalizeStorageParams(options []*pg_query.Node) {
for _, o := range options {
de := o.GetDefElem()
if i := de.GetArg().GetInteger(); i != nil {
sval := strconv.FormatInt(int64(i.Ival), 10)
de.Arg = &pg_query.Node{Node: &pg_query.Node_String_{String_: &pg_query.String{Sval: sval}}}
}
if f := de.GetArg().GetFloat(); f != nil {
de.Arg = &pg_query.Node{Node: &pg_query.Node_String_{String_: &pg_query.String{Sval: f.Fval}}}
}
if tn := de.GetArg().GetTypeName(); tn != nil && len(tn.Names) == 1 {
if s := tn.Names[0].GetString_(); s != nil {
de.Arg = &pg_query.Node{Node: &pg_query.Node_String_{String_: &pg_query.String{Sval: s.Sval}}}
}
}
}
// No index storage parameter has a namespace, unlike a table's toast.
// ones, so the name alone orders them.
slices.SortStableFunc(options, func(a, b *pg_query.Node) int {
return cmp.Compare(a.GetDefElem().GetDefname(), b.GetDefElem().GetDefname())
})
}
// dropColumnCollation removes the COLLATE clause from each desired element when
// the clause names the column's own collation. That is the collation the
// column declares, or "default" on a column of the default collation.
// pg_get_indexdef leaves such a clause out. Any other collation is a real
// choice and stays. An expression element has no column and is left alone.
func dropColumnCollation(is *pg_query.IndexStmt, columns *orderedmap.Map[string, *model.Column]) {
if columns == nil {
return
}
for _, p := range is.IndexParams {
ie := p.GetIndexElem()
if ie == nil || ie.Name == "" || len(ie.Collation) == 0 {
continue
}
col, ok := columns.GetOk(ie.Name)
if !ok {
continue
}
if isDefaultCollation(ie.Collation) && hasDefaultCollation(col) ||
col.Collation != nil && equalCollation(col.Collation, new(collationName(ie.Collation))) {
ie.Collation = nil
}
}
}
// collationName writes a COLLATE clause's name in the form a column's
// collation is stored in, so that equalCollation can compare them.
func collationName(name []*pg_query.Node) string {
parts := make([]string, len(name))
for i, n := range name {
parts[i] = model.Ident(n.GetString_().GetSval())
}
return strings.Join(parts, ".")
}
// defaultCollatedTypes are the built-in types whose own collation is the
// database default.
var defaultCollatedTypes = map[string]bool{
"text": true,
"character varying": true,
"character": true,
"bpchar": true,
}
// hasDefaultCollation reports whether a column takes the default collation. A
// column without a COLLATE clause takes its type's collation, which is not
// always the default: name takes "C", and so does a domain declared with
// COLLATE "C". Only the types in defaultCollatedTypes and their arrays are
// known to take the default.
func hasDefaultCollation(col *model.Column) bool {
if col.Collation != nil {
return false
}
base, _, _ := strings.Cut(col.TypeName, "(")
base, _, _ = strings.Cut(base, "[")
return defaultCollatedTypes[base]
}
// isDefaultCollation reports whether a COLLATE name is the built-in default:
// "default" alone or qualified with pg_catalog. A "default" in another schema
// is a collation of its own.
func isDefaultCollation(name []*pg_query.Node) bool {
if name[len(name)-1].GetString_().GetSval() != "default" {
return false
}
return len(name) == 1 || len(name) == 2 && name[0].GetString_().GetSval() == "pg_catalog"
}
// alignIndexCasts asymmetrically strips current-side TypeCast wrappers at
// positions desired doesn't have a cast, inside the partial-index
// WhereClause and each expression-index IndexElem.Expr. Mirrors the
// pattern used by equalConstraintDef / equalDefault / equalSelectExpr.
func alignIndexCasts(desired, current *pg_query.IndexStmt) {
if desired == nil || current == nil {
return
}
if desired.WhereClause != nil && current.WhereClause != nil {
current.WhereClause = alignCurrentCasts(desired.WhereClause, current.WhereClause)
}
if len(desired.IndexParams) != len(current.IndexParams) {
return
}
for i := range desired.IndexParams {
dIe := desired.IndexParams[i].GetIndexElem()
cIe := current.IndexParams[i].GetIndexElem()
if dIe == nil || cIe == nil || dIe.Expr == nil || cIe.Expr == nil {
continue
}
cIe.Expr = alignCurrentCasts(dIe.Expr, cIe.Expr)
}
}
// equalIndexDef compares two CREATE INDEX definitions by parsing both
// sides, canonicalising sort / nulls / schema, normalising sub-expression
// formatting (parens, text-like casts, `IN`<->`= ANY(ARRAY[...])`), and
// stripping any current-only TypeCast in the partial-index WhereClause
// and expression-index IndexElem.Expr (with Sval->numeric coercion when
// the desired side is a bare numeric A_Const); same pipeline as
// equalConstraintDef.
//
// columns are the table's, which dropColumnCollation needs. nil columns fold
// nothing.
func equalIndexDef(current, desired string, columns *orderedmap.Map[string, *model.Column]) bool {
if current == desired {
return true
}
curResult, curIS, parseErrCur := parseIndexDef(current)
desResult, desIS, parseErrDes := parseIndexDef(desired)
if parseErrCur != nil || parseErrDes != nil {
return false
}
dropColumnCollation(desIS, columns)
normalizeIndexStmt(curIS)
normalizeIndexStmt(desIS)
alignIndexCasts(desIS, curIS)
curStr, deparseErrCur := pg_query.Deparse(curResult)
desStr, deparseErrDes := pg_query.Deparse(desResult)
return deparseErrCur == nil && deparseErrDes == nil && curStr == desStr
}
// parseFKDef parses a FK constraint definition string into a pg_query Constraint node.
func parseFKDef(def string) (*pg_query.Constraint, error) {
sql := "ALTER TABLE _t ADD CONSTRAINT _c " + def
result, err := pg_query.Parse(sql)
if err != nil {
return nil, err
}
as := result.Stmts[0].Stmt.GetAlterTableStmt()
if as == nil {
return nil, fmt.Errorf("unexpected parse result for FK definition: %s", def)
}
cmd := as.Cmds[0].GetAlterTableCmd()
if cmd == nil {
return nil, fmt.Errorf("unexpected parse result for FK definition: %s", def)
}
con := cmd.Def.GetConstraint()
if con == nil {
return nil, fmt.Errorf("unexpected parse result for FK definition: %s", def)
}
return con, nil
}
// fkRefSchema returns the schema of the table a foreign key references, or
// schema when the key does not carry one.
func fkRefSchema(fk *model.ForeignKey, schema string) string {
if fk.RefSchema != nil {
return *fk.RefSchema
}
return schema
}
// isBareFKRef reports that a FK constraint node names the referenced table
// without a schema.
func isBareFKRef(con *pg_query.Constraint) bool {
return con.Pktable != nil && con.Pktable.Schemaname == ""
}
// compareFKDef compares two FK constraint definitions by their parse trees, so
// that formatting differences do not cause false diffs. refSchemaA and
// refSchemaB are the schemas each side's referenced table is in, and schema is
// the owning table's.
//
// The catalog leaves the schema out of a reference the search_path reaches.
// When one side leaves it out and the other does not, the bare side matches
// if the other names the bare side's RefSchema, which is exact for the
// catalog, or the owning table's schema, which is what a hand-written bare
// name usually means. When both leave it out, both resolve through the
// search_path and are compared as written: the parser puts a bare name in the
// first target schema, which need not be where the search_path lands.
//
// deferralOnly reports that the deferral clause is the only thing between the
// two, which routes the change to ALTER CONSTRAINT rather than a drop and an
// add that rescans the table.
func compareFKDef(a, b, refSchemaA, refSchemaB, schema string) (equal, deferralOnly bool) {
nodeA, errA := parseFKDef(a)
nodeB, errB := parseFKDef(b)
if errA != nil || errB != nil {
return a == b, false
}
bareA, bareB := isBareFKRef(nodeA), isBareFKRef(nodeB)
if bareA == bareB {
return compareFKNodes(nodeA, nodeB)
}
bare, refSchema := nodeA, refSchemaA
if bareB {
bare, refSchema = nodeB, refSchemaB
}
bare.Pktable.Schemaname = refSchema
if equal, deferralOnly = compareFKNodes(nodeA, nodeB); equal {
return true, false
}
bare.Pktable.Schemaname = schema
equal, ownDeferralOnly := compareFKNodes(nodeA, nodeB)
return equal, !equal && (deferralOnly || ownDeferralOnly)
}
// compareFKNodes is compareFKDef on two parsed definitions.
func compareFKNodes(nodeA, nodeB *pg_query.Constraint) (equal, deferralOnly bool) {
if proto.Equal(nodeA, nodeB) {
return true, false
}
strippedA := proto.Clone(nodeA).(*pg_query.Constraint)
strippedB := proto.Clone(nodeB).(*pg_query.Constraint)
for _, c := range []*pg_query.Constraint{strippedA, strippedB} {
c.Deferrable = false
c.Initdeferred = false
}
return false, proto.Equal(strippedA, strippedB)
}
// serialBaseTypes maps serial type names to their base types.
var serialBaseTypes = map[string]string{
"serial": "integer",
"bigserial": "bigint",
"smallserial": "smallint",
}
// EqualTypeName reports whether two type names name the same type, the way
// the column diff compares them. schema is the schema of the object that
// holds the type.
func EqualTypeName(a, b, schema string) bool {
return equalTypeName(a, b, schema)
}
// equalTypeName compares two type names, treating serial types as equal to their base types.
func equalTypeName(a, b, schema string) bool {
a = foldTypeMod(model.StripTypeSchema(a, schema))
b = foldTypeMod(model.StripTypeSchema(b, schema))
if a == b {
return true
}
return alterTypeName(a) == alterTypeName(b)
}
// foldTypeMod lowercases a type name's modifier, leaving the type name itself
// alone. PostgreSQL downcases an unquoted keyword in a modifier as it parses
// the DDL, while format_type prints the modifier the way the type writes it
// back, which need not be lower case: a column declared
// `geometry(Polygon,4326)` reads back from the catalog with the capital P that
// PostGIS gives it, and the desired side of the same declaration reaches the
// diff as `geometry(polygon,4326)`. The type name is left as it is, since it
// is case-sensitive when quoted.
func foldTypeMod(typeName string) string {
i := strings.Index(typeName, "(")
if i < 0 {
return typeName
}
return typeName[:i] + strings.ToLower(typeName[i:])
}
// schemaOf returns the schema component of a canonical "schema.name"
// identifier, preserving quoting. It returns "" when there is no top-level dot.
func schemaOf(fqtn string) string {
inQuote := false
for i := 0; i < len(fqtn); i++ {
switch fqtn[i] {
case '"':
inQuote = !inQuote
case '.':
if !inQuote {
return fqtn[:i]
}
}
}
return ""
}
// equalDefault compares two default expressions (current, desired) by
// parsing both, normalising AST-level differences introduced by
// pg_get_expr, and comparing the canonical deparsed forms.
//
// The handling has three layers:
// 1. A top-level cast on a literal is stripped on both sides; pg_get_expr
// spells a literal with its type, while users routinely omit it; users
// who *do* write an explicit cast (`DEFAULT 0::integer`) also need to
// match a DB form that PG happened to store natively (`0`), so neither
// side keeps the wrapper. When the stripped Sval parses as a number and
// the peer side is a numeric A_Const, the Sval is coerced to Ival / Fval
// so the two deparse identically. A top-level cast on an expression is
// compared instead, as the comment in the body describes.
// 2. normalizeCheckExpr handles symmetric text-like cast strip, paren
// cleanup, and `= ANY(ARRAY[...])` <-> `IN (...)` on both sides.
// 3. alignCurrentCasts handles the inner asymmetric strip (current has
// `TypeCast` at a position desired doesn't) for expressions like
// `now() - '18 years'::interval` where the cast is nested under an
// A_Expr rather than at the top.
func equalDefault(current, desired *string) bool {
if current == nil || desired == nil {
return current == desired || current == nil && isNullDefault(*desired)
}
if *current == *desired {
return true
}
curResult, curTarget, parseErrCur := pgast.ParseExpr(*current)
desResult, desTarget, parseErrDes := pgast.ParseExpr(*desired)
if parseErrCur != nil || parseErrDes != nil {
return false
}
// The catalog writes a literal with its type, so a cast on a literal is
// stripped. It keeps a cast on an expression only when the cast was
// written and changes something, so such a cast is compared, and one the
// desired side drops is a change. It drops a cast that changes nothing,
// such as now()::timestamptz, so a desired cast it lacks is stripped.
curCast := expressionCast(curTarget.Val)
desCast := expressionCast(desTarget.Val)
switch {
case curCast != nil && desCast != nil:
unqualifyType(curCast.TypeName)
unqualifyType(desCast.TypeName)
case curCast != nil && desTarget.Val.GetTypeCast() == nil:
return false
case desCast != nil && curTarget.Val.GetTypeCast() == nil:
desTarget.Val = desCast.Arg
default:
curTarget.Val = stripDefaultTopLevelCast(curTarget.Val, desTarget.Val)
desTarget.Val = stripDefaultTopLevelCast(desTarget.Val, curTarget.Val)
}
curTarget.Val = normalizeCheckExpr(curTarget.Val)
desTarget.Val = normalizeCheckExpr(desTarget.Val)
curTarget.Val = alignCurrentCasts(desTarget.Val, curTarget.Val)
curStr, deparseErrCur := pg_query.Deparse(curResult)
desStr, deparseErrDes := pg_query.Deparse(desResult)
return deparseErrCur == nil && deparseErrDes == nil && curStr == desStr
}
// isNullDefault reports whether def is a NULL constant, cast or not.
// PostgreSQL stores no default for one when it needs no coercion to the
// column's type, so it matches a column without a default.
func isNullDefault(def string) bool {
_, target, err := pgast.ParseExpr(def)
if err != nil {
return false
}
n := target.Val
if tc := n.GetTypeCast(); tc != nil {
n = tc.Arg
}
return isNullConst(n)
}
// EqualDefault reports whether two column defaults are the same expression,
// the way the column diff compares them.
func EqualDefault(current, desired *string) bool {
return equalDefault(current, desired)
}
// expressionCast returns node's TypeCast when it casts something other than a
// literal, and nil otherwise.
func expressionCast(node *pg_query.Node) *pg_query.TypeCast {
tc := node.GetTypeCast()
if tc == nil || tc.Arg == nil || tc.Arg.GetAConst() != nil {
return nil
}
return tc
}
// unqualifyType drops the schema qualifier from tn, as stripFuncSchema does
// for a function name. The parser qualifies a type spelled as SQL keywords,
// `timestamp with time zone`, with pg_catalog and leaves its internal name,
// `timestamptz`, as written, and pg_get_expr leaves out a schema on the
// search_path. A type moved between two schemas produces no diff.
func unqualifyType(tn *pg_query.TypeName) {
if tn != nil {
tn.Names = lastNamePart(tn.Names)
}
}
// stripDefaultTopLevelCast removes a top-level TypeCast wrapper from node,
// optionally coercing the resulting Sval back to a numeric A_Const when
// the cast is on a numeric type and peer is itself a numeric (non-string)
// A_Const. Used by equalDefault to keep `DEFAULT 0::integer` == `DEFAULT 0`
// even when the value form on each side parses to a different A_Const
// kind (Sval vs Ival).
func stripDefaultTopLevelCast(node, peer *pg_query.Node) *pg_query.Node {
if node == nil {
return node
}
tc := node.GetTypeCast()
if tc == nil || tc.Arg == nil {
return node
}
arg := tc.Arg
if isNumericTypeName(tc.TypeName) && peerIsNumericAtTopLevel(peer) {
if sv := arg.GetAConst().GetSval(); sv != nil {
if numeric := numericAConstFromString(sv.Sval); numeric != nil {
arg = numeric
}
}
}
return arg
}
package diff
import (
"fmt"
"maps"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// diffTriggers emits the statements that bring one relation's triggers to the
// desired state.
//
// A definition change goes through CREATE OR REPLACE TRIGGER, which PostgreSQL
// has carried since 14 and which takes a lighter lock than DROP TRIGGER. There
// is no CREATE OR REPLACE CONSTRAINT TRIGGER, so a change to or from a
// constraint trigger falls back to DROP and CREATE, honoring the trigger-drop
// policy via dc the same as a removal: with the drop denied, the trigger keeps
// its current definition rather than running the CREATE half alone. Either
// way PostgreSQL leaves the new trigger enabled, so a desired state other than
// the default is re-applied after the statement that reset it.
func diffTriggers(
fqtn string,
current, desired *orderedmap.Map[string, *model.Trigger],
dc DropChecker,
) (stmts []string, disallowed []string, err error) {
dc = normalizeDropChecker(dc)
current = triggerMap(current)
desired = triggerMap(desired)
// Detect renames first so the steps below see the renamed trigger under
// its new name in the adjusted current map.
current, renamedFrom, err := detectTriggerRenames(fqtn, current, desired)
if err != nil {
return nil, nil, err
}
// Compare once. The loops below need both whether the definition changed
// and whether a constraint trigger is involved, which PostgreSQL will not
// replace in place.
changed := map[string]bool{}
recreated := map[string]bool{}
for name, des := range desired.All() {
cur, ok := current.GetOk(name)
if !ok {
continue
}
same, err := equalTriggerDef(cur.Definition, des.Definition, des.Schema)
if err != nil {
return nil, nil, err
}
if same {
continue
}
changed[name] = true
if isConstraintTrigger(cur.Definition) || isConstraintTrigger(des.Definition) {
recreated[name] = true
}
}
triggerAllowed := dc.IsDropAllowed("trigger")
// A recreate involving a constraint trigger needs the same drop the
// removal branch below honors: nothing else about the trigger can change
// while the drop that clears the way for it is denied, so it stays
// exactly as it is, the way DiffViews leaves a view alone when its own
// recreate is denied.
recreateDenied := map[string]bool{}
if !triggerAllowed {
maps.Copy(recreateDenied, recreated)
}
// A trigger that is both renamed and recreated skips the RENAME, since the
// CREATE below already puts the new name in place. One whose recreate is
// denied keeps its old name too, recreated[name] being true either way.
for name := range desired.Keys() {
oldName, renamed := renamedFrom[name]
if !renamed || recreated[name] {
continue
}
stmts = append(stmts, renameTriggerSQL(fqtn, oldName, name))
}
// Drop the triggers the desired schema no longer declares, then the ones
// the recreate needs out of the way. A recreate whose RENAME was skipped
// still has to drop the old name.
for name := range current.Keys() {
if _, ok := desired.GetOk(name); !ok {
drop := dropTriggerSQL(fqtn, name)
if !triggerAllowed {
disallowed = append(disallowed, "-- skipped: "+drop)
continue
}
stmts = append(stmts, drop)
continue
}
if recreated[name] {
dropName := name
if oldName, ok := renamedFrom[name]; ok {
dropName = oldName
}
drop := dropTriggerSQL(fqtn, dropName)
if recreateDenied[name] {
disallowed = append(disallowed, "-- skipped: "+drop)
continue
}
stmts = append(stmts, drop)
}
}
for name, des := range desired.All() {
if recreateDenied[name] {
continue
}
cur, ok := current.GetOk(name)
if !ok || recreated[name] {
stmts = append(stmts, des.SQL())
if s := des.StateSQL(); s != "" {
stmts = append(stmts, s)
}
continue
}
if !changed[name] {
if cur.State.Action() != des.State.Action() {
stmts = append(stmts, alterTriggerStateSQL(fqtn, des))
}
continue
}
replace, err := replaceTriggerSQL(des.Definition)
if err != nil {
return nil, nil, err
}
stmts = append(stmts, replace)
if s := des.StateSQL(); s != "" {
stmts = append(stmts, s)
}
}
// CREATE OR REPLACE TRIGGER keeps the comment. A trigger created or
// recreated above has none.
for name, des := range desired.All() {
if recreateDenied[name] {
continue
}
var currentComment *string
if cur, ok := current.GetOk(name); ok && !recreated[name] {
currentComment = cur.Comment
}
if !equalPtr(currentComment, des.Comment) {
stmts = append(stmts, commentOnSQL("TRIGGER "+model.Ident(name)+" ON "+fqtn, des.Comment))
}
}
return stmts, disallowed, nil
}
// detectTriggerRenames reads the -- pista:renamed-from directives on the
// desired triggers and returns the current map rekeyed under the new names,
// plus the new name to old name pairs the caller renders as ALTER TRIGGER.
func detectTriggerRenames(
fqtn string,
current, desired *orderedmap.Map[string, *model.Trigger],
) (*orderedmap.Map[string, *model.Trigger], map[string]string, error) {
adjusted := current.Clone()
renamedFrom := map[string]string{}
for newName, des := range desired.All() {
if des.RenameFrom == nil {
continue
}
oldName := *des.RenameFrom
if oldName == newName {
continue
}
old, ok := adjusted.GetOk(oldName)
if !ok {
// The rename has already been applied on an earlier run, so the
// directive can stay in the file until the next cleanup.
if _, exists := adjusted.GetOk(newName); exists {
continue
}
return nil, nil, fmt.Errorf("rename source trigger %s not found on %s", model.Ident(oldName), fqtn)
}
if _, exists := adjusted.GetOk(newName); exists {
return nil, nil, fmt.Errorf("cannot rename trigger %s to %s on %s: destination already exists", model.Ident(oldName), model.Ident(newName), fqtn)
}
// ALTER TRIGGER ... RENAME changes the name pg_get_triggerdef writes,
// so carry the new name into the definition too. Without it the
// comparison below reads the old name as a definition change and adds
// a redundant CREATE OR REPLACE.
def, err := renameTriggerDef(old.Definition, newName)
if err != nil {
return nil, nil, err
}
renamedFrom[newName] = oldName
adjusted.Delete(oldName)
renamed := *old
renamed.Name = newName
renamed.Definition = def
adjusted.Set(newName, &renamed)
}
return adjusted, renamedFrom, nil
}
// triggerMap returns m, or an empty map when the caller built its model
// without one.
func triggerMap(m *orderedmap.Map[string, *model.Trigger]) *orderedmap.Map[string, *model.Trigger] {
if m == nil {
return orderedmap.New[string, *model.Trigger]()
}
return m
}
func dropTriggerSQL(fqtn, name string) string {
return "DROP TRIGGER " + model.Ident(name) + " ON " + fqtn + ";"
}
func renameTriggerSQL(fqtn, oldName, newName string) string {
return "ALTER TRIGGER " + model.Ident(oldName) + " ON " + fqtn + " RENAME TO " + model.Ident(newName) + ";"
}
func alterTriggerStateSQL(fqtn string, trg *model.Trigger) string {
return model.TriggerStateSQL(fqtn, trg.Name, trg.State)
}
// parseCreateTrigStmt parses a definition that must hold a single CREATE
// TRIGGER statement.
func parseCreateTrigStmt(def string) (*pg_query.ParseResult, *pg_query.CreateTrigStmt, error) {
result, err := pg_query.Parse(def)
if err != nil {
return nil, nil, err
}
if len(result.Stmts) != 1 {
return nil, nil, fmt.Errorf("unexpected parse result for trigger definition: %s", def)
}
ct := result.Stmts[0].Stmt.GetCreateTrigStmt()
if ct == nil {
return nil, nil, fmt.Errorf("unexpected parse result for trigger definition: %s", def)
}
return result, ct, nil
}
// parseTriggerDef parses a CREATE TRIGGER statement and takes out the wording
// that says nothing about the trigger's state. An implicit relation schema is
// filled in from the owning relation, and OR REPLACE is cleared.
//
// The EXECUTE FUNCTION name goes through lastNamePart, since pg_get_triggerdef
// omits the schema once the function's schema is on the search_path. The WHEN
// expression goes through the normalization constraint CHECK bodies use, since
// pg_get_triggerdef adds casts there the way pg_get_constraintdef does.
//
// It returns the whole result so the caller can deparse it back.
func parseTriggerDef(def, schema string) (*pg_query.ParseResult, error) {
result, ct, err := parseCreateTrigStmt(def)
if err != nil {
return nil, err
}
if ct.Relation != nil && ct.Relation.Schemaname == "" {
ct.Relation.Schemaname = schema
}
ct.Replace = false
ct.Funcname = lastNamePart(ct.Funcname)
if ct.WhenClause != nil {
ct.WhenClause = normalizeCheckExpr(ct.WhenClause)
}
return result, nil
}
// equalTriggerDef compares two CREATE TRIGGER definitions by deparsing their
// normalized parse trees. pg_get_triggerdef and the deparser disagree on
// wording that carries no meaning (the AS in REFERENCING, an explicit FOR EACH
// STATEMENT), so the definitions cannot be compared as they arrive, and the
// trees themselves carry the byte offset of every token, which differs as soon
// as the two spellings differ in length. schema fills in a relation the
// catalog wrote bare because search_path reached it.
func equalTriggerDef(current, desired, schema string) (bool, error) {
if current == desired {
return true, nil
}
curResult, err := parseTriggerDef(current, schema)
if err != nil {
return false, fmt.Errorf("failed to parse the current trigger definition: %w", err)
}
desResult, err := parseTriggerDef(desired, schema)
if err != nil {
return false, fmt.Errorf("failed to parse the desired trigger definition: %w", err)
}
cur, err := pg_query.Deparse(curResult)
if err != nil {
return false, fmt.Errorf("failed to deparse the current trigger definition: %w", err)
}
des, err := pg_query.Deparse(desResult)
if err != nil {
return false, fmt.Errorf("failed to deparse the desired trigger definition: %w", err)
}
return cur == des, nil
}
// isConstraintTrigger reports whether a definition is a CREATE CONSTRAINT
// TRIGGER. PostgreSQL has no OR REPLACE for one, so a change to or from one
// needs a DROP in between.
func isConstraintTrigger(def string) bool {
_, ct, err := parseCreateTrigStmt(def)
if err != nil {
return false
}
return ct.Isconstraint
}
// renameTriggerDef rewrites a CREATE TRIGGER definition to carry a new trigger
// name, the way the catalog reports it after ALTER TRIGGER ... RENAME.
func renameTriggerDef(def, newName string) (string, error) {
result, ct, err := parseCreateTrigStmt(def)
if err != nil {
return "", fmt.Errorf("failed to parse the current trigger definition: %w", err)
}
ct.Trigname = newName
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse the current trigger definition: %w", err)
}
return sql, nil
}
// replaceTriggerSQL rewrites a CREATE TRIGGER definition into the
// CREATE OR REPLACE TRIGGER that updates the trigger in place.
func replaceTriggerSQL(def string) (string, error) {
result, ct, err := parseCreateTrigStmt(def)
if err != nil {
return "", fmt.Errorf("failed to parse the desired trigger definition: %w", err)
}
ct.Replace = true
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse the desired trigger definition: %w", err)
}
return sql + ";", nil
}
package diff
import (
"fmt"
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// equalViewDef compares two view definitions by parsing each side once,
// applying every available view normalization in place, then deparsing and
// comparing the resulting strings. proto.Equal cannot be used here because
// parse trees include source location information that differs when the
// same query has different formatting.
//
// Normalizations applied:
// - schema/column qualification stripping (pg_get_viewdef adds
// table-qualified columns and omits the default schema, parsed SQL is
// the opposite); a column keeps its prefix when FROM has more than one
// relation,
// - output name stripping on every SELECT of a set operation but the
// leftmost, where pg_get_viewdef repeats the leftmost names,
// - symmetric expression normalization via normalizeCheckExpr:
// paren / text-like cast stripping and `= ANY(ARRAY[...])` -> `IN (...)`,
// - asymmetric current-only cast alignment via alignCurrentCasts: strips
// TypeCasts (notably 'lit'::enum_type) the catalog added but the user
// didn't write. Top-level casts at the target list are preserved on
// both sides since they affect the resulting view's column type.
//
// All of them walk the whole statement, so a nested SELECT, a join qualifier,
// a DISTINCT ON element, a named WINDOW or a sub-query gets the same
// treatment as the target list without any of them being enumerated here.
//
// The caller convention is equalViewDef(current, desired): current is the
// pg_get_viewdef form (from the catalog), desired is the user SQL. The
// asymmetric normalizations rely on that ordering.
func equalViewDef(current, desired string) bool {
if current == desired {
return true
}
curResult, errCur := pg_query.Parse("CREATE VIEW _v AS " + current)
desResult, errDes := pg_query.Parse("CREATE VIEW _v AS " + desired)
if errCur != nil || errDes != nil {
return false
}
for _, stmt := range curResult.Stmts {
stripQualifications(stmt.Stmt)
stripSetOpBranchNames(stmt.Stmt)
stmt.Stmt = normalizeCheckExpr(stmt.Stmt)
}
for _, stmt := range desResult.Stmts {
stripQualifications(stmt.Stmt)
stripSetOpBranchNames(stmt.Stmt)
stmt.Stmt = normalizeCheckExpr(stmt.Stmt)
}
if len(curResult.Stmts) == 1 && len(desResult.Stmts) == 1 {
curResult.Stmts[0].Stmt = alignCurrentCasts(desResult.Stmts[0].Stmt, curResult.Stmts[0].Stmt)
}
curStr, errCur := pg_query.Deparse(curResult)
desStr, errDes := pg_query.Deparse(desResult)
return errCur == nil && errDes == nil && curStr == desStr
}
// canCreateOrReplaceView reports whether a view definition change can be
// expressed as CREATE OR REPLACE VIEW. PostgreSQL requires the new query to
// produce columns with the same names in the same order as the existing
// view; only appending new columns at the end is allowed. Removing,
// renaming, reordering, or replacing a column makes CREATE OR REPLACE fail
// with `cannot drop columns from view` (or a similar error), so the caller
// must use DROP + CREATE in that case.
//
// desiredNames is the column list the desired view keeps on its CREATE, which
// names its leading output columns in place of the query.
//
// Returns false when either side's output columns can't be determined
// statically (SELECT *, target-list expressions without an alias, parse
// error). That conservatively routes uncertain cases through DROP +
// CREATE, which always works at the cost of briefly dropping privileges /
// dependent views; strictly better than leaving the bug in place.
//
// Known limitation: type-only changes on a same-named column (e.g.
// `SELECT n FROM t` -> `SELECT n::bigint AS n FROM t`) are reported as
// CREATE-OR-REPLACE-able because the names line up, but PostgreSQL still
// rejects them with `cannot change data type of view column`. pg_query
// doesn't perform type inference, so we can't detect this statically;
// users who hit it can resolve manually by adjusting the source DDL or
// dropping the view in a pre-step.
func canCreateOrReplaceView(current, desired string, desiredNames []string) bool {
curCols, curOK := viewOutputColumns(current)
desCols, desOK := viewOutputColumns(desired)
if !curOK || !desOK || len(desiredNames) > len(desCols) {
return false
}
// The column list of CREATE VIEW v (x, y) names the leading columns.
copy(desCols, desiredNames)
return len(desCols) >= len(curCols) && slices.Equal(desCols[:len(curCols)], curCols)
}
// viewOutputColumns parses a view definition's SELECT body and returns the
// ordered list of output column names. Returns ok=false when any column
// can't be named statically (SELECT *, t.*, or an expression target
// without an alias) or when parsing fails.
func viewOutputColumns(definition string) (cols []string, ok bool) {
def := strings.TrimSpace(definition)
def = strings.TrimSuffix(def, ";")
result, err := pg_query.Parse("CREATE VIEW _v AS " + def)
if err != nil || len(result.Stmts) != 1 {
return nil, false
}
vs := result.Stmts[0].Stmt.GetViewStmt()
if vs == nil || vs.Query == nil {
return nil, false
}
return selectOutputColumns(vs.Query)
}
// selectOutputColumns walks a SelectStmt and returns the ordered names of
// its top-level target list. For UNION / INTERSECT / EXCEPT it recurses
// into the left arm, since PostgreSQL inherits set-operation result names
// from the first SELECT.
func selectOutputColumns(node *pg_query.Node) ([]string, bool) {
ss := node.GetSelectStmt()
if ss == nil {
return nil, false
}
if ss.Op != pg_query.SetOperation_SETOP_NONE {
if ss.Larg == nil {
return nil, false
}
return selectOutputColumns(&pg_query.Node{Node: &pg_query.Node_SelectStmt{SelectStmt: ss.Larg}})
}
if len(ss.TargetList) == 0 {
return nil, false
}
names := make([]string, 0, len(ss.TargetList))
for _, t := range ss.TargetList {
rt := t.GetResTarget()
if rt == nil {
return nil, false
}
if rt.Name != "" {
names = append(names, rt.Name)
continue
}
if rt.Val == nil {
return nil, false
}
cr := rt.Val.GetColumnRef()
if cr == nil || len(cr.Fields) == 0 {
return nil, false
}
last := cr.Fields[len(cr.Fields)-1]
if last.GetAStar() != nil {
return nil, false
}
s := last.GetString_()
if s == nil {
return nil, false
}
names = append(names, s.Sval)
}
return names, true
}
// stripQualifications removes the schema from every RangeVar, and the table
// prefix from a column in a SELECT whose FROM has one relation. pg_get_viewdef
// omits the schema when it is on the search path and qualifies a column with
// its table, and a written view definition tends to do the opposite, so both
// sides are reduced to the bare form before they are compared.
//
// The prefix is removed only where it does not change which column is read.
// With one relation in FROM, users.id and id are the same column. With two or
// more, u.id and o.id are different columns, so the prefix stays.
// pg_get_viewdef writes the prefix there anyway. Inside a sub-query, a prefix
// that names an outer relation stays too.
//
// The walk reaches every node kind, which is what the older enumeration could
// not do: a reference sitting inside COALESCE, CASE, GREATEST, a subscript, a
// row or array constructor, IS TRUE, COLLATE, an XML or SQL/JSON expression,
// an aggregate ORDER BY or FILTER, an OVER clause, DISTINCT ON, GROUPING SETS
// or a sub-query nested under any of them kept its prefix and re-emitted
// CREATE OR REPLACE VIEW on every plan.
func stripQualifications(node *pg_query.Node) {
pgast.Walk(node, pgast.WalkOptions{}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
if rv := n.GetRangeVar(); rv != nil {
rv.Schemaname = ""
return n
}
if ss := n.GetSelectStmt(); ss != nil {
stripColumnPrefixes(ss)
}
return n
})
}
// stripColumnPrefixes removes the prefix from the columns of one SELECT when
// its FROM has one relation and the prefix names it. It does not go into a
// sub-query or CTE; the walk in stripQualifications calls it for those. The
// branches of a set operation are SelectStmts, not Nodes, so the walk does not
// reach them, and this function handles them itself.
func stripColumnPrefixes(ss *pg_query.SelectStmt) {
if ss.Op != pg_query.SetOperation_SETOP_NONE {
stripColumnPrefixes(ss.Larg)
stripColumnPrefixes(ss.Rarg)
return
}
if len(ss.FromClause) != 1 {
return
}
name := fromItemName(ss.FromClause[0])
if name == "" {
return
}
root := &pg_query.Node{Node: &pg_query.Node_SelectStmt{SelectStmt: ss}}
pgast.Walk(root, pgast.WalkOptions{SkipSubqueries: true}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
// "table.column" -> "column". Only when both parts are plain
// identifiers, so table.* keeps its prefix.
if cr := n.GetColumnRef(); cr != nil && len(cr.Fields) == 2 &&
cr.Fields[0].GetString_().GetSval() == name && cr.Fields[1].GetString_() != nil {
cr.Fields = cr.Fields[1:]
}
return n
})
}
// fromItemName returns the name that a column prefix uses for a FROM item:
// its alias, or the table or function name when it has no alias. It returns
// "" for a join, which has more than one relation.
func fromItemName(item *pg_query.Node) string {
switch {
case item.GetRangeVar() != nil:
rv := item.GetRangeVar()
if rv.Alias != nil {
return rv.Alias.Aliasname
}
return rv.Relname
case item.GetRangeTableSample() != nil:
return fromItemName(item.GetRangeTableSample().Relation)
case item.GetRangeSubselect() != nil:
return item.GetRangeSubselect().GetAlias().GetAliasname()
case item.GetRangeFunction() != nil:
rf := item.GetRangeFunction()
if rf.Alias != nil {
return rf.Alias.Aliasname
}
if len(rf.Functions) == 1 {
if items := rf.Functions[0].GetList().GetItems(); len(items) > 0 {
if fc := items[0].GetFuncCall(); fc != nil {
if s := fc.Funcname[len(fc.Funcname)-1].GetString_(); s != nil {
return s.Sval
}
}
}
}
}
return ""
}
// stripSetOpBranchNames removes the target names from every SELECT of a
// UNION / INTERSECT / EXCEPT other than the leftmost one and one with its own
// ORDER BY. The output column
// names come from the leftmost SELECT alone, and pg_get_viewdef writes them
// onto the later SELECTs as well (`SELECT other.qty AS id`), so a name there
// means nothing and is dropped on both sides.
//
// The arms of a set operation are SelectStmts rather than Nodes, so the walk
// visits the outermost SelectStmt of each set operation only, and
// clearSetOpNames goes down its arms. A set operation in a sub-query or CTE
// is a Node of its own and gets the same treatment.
func stripSetOpBranchNames(node *pg_query.Node) {
pgast.Walk(node, pgast.WalkOptions{}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
if ss := n.GetSelectStmt(); ss != nil && ss.Op != pg_query.SetOperation_SETOP_NONE {
clearSetOpNames(ss, true)
}
return n
})
}
func clearSetOpNames(ss *pg_query.SelectStmt, leftmost bool) {
// A branch's own ORDER BY can sort by its output names, so they count
// there. On a nested set operation those are the names of its leftmost
// SELECT.
if ss.Op != pg_query.SetOperation_SETOP_NONE {
clearSetOpNames(ss.Larg, leftmost || len(ss.SortClause) > 0)
clearSetOpNames(ss.Rarg, false)
return
}
if leftmost || len(ss.SortClause) > 0 {
return
}
for _, t := range ss.TargetList {
if rt := t.GetResTarget(); rt != nil {
rt.Name = ""
}
}
}
// ViewDiffResult separates view DROP and CREATE/MODIFY statements.
// Drops should run before table changes, creates after.
type ViewDiffResult struct {
DropStmts []string // DROP VIEW / DROP MATERIALIZED VIEW (should run before table changes)
CreateStmts []string // ALTER VIEW RENAME, CREATE OR REPLACE VIEW, CREATE MATERIALIZED VIEW, indexes, comments (should run after table changes)
DisallowedDropStmts []string // DROP VIEW / DROP MATERIALIZED VIEW / DROP INDEX (on matview) suppressed by DropChecker, with "-- skipped: " prefix
HasConcurrently bool // true if any index operation uses CONCURRENTLY
// DroppedViews names every view and materialized view DropStmts drops,
// including the ones dropped to be created again. A suppressed drop is
// left out, since nothing runs. PostgreSQL refuses to drop a relation
// another object reads, so the caller checks these against the catalog;
// see checkViewDependents.
DroppedViews []string
}
func DiffViews(current, desired *orderedmap.Map[string, *model.View], dc DropChecker) (*ViewDiffResult, error) {
dc = normalizeDropChecker(dc)
result := &ViewDiffResult{}
// Detect renames
renameStmts, current, renamedFrom, err := detectViewRenames(current, desired)
if err != nil {
return nil, err
}
// Track views that are recreated (DROP+CREATE) so comments can be re-applied.
recreated := make(map[string]bool)
// Track views whose recreation was suppressed by --allow-drop. For these we
// skip the executable comment diff too, so the output reflects "nothing
// executable" rather than emitting half of the intended change.
recreateDenied := make(map[string]bool)
// A renamed view that ALSO needs DROP+CREATE (e.g. its column list is
// changing) must not go through the ALTER ... RENAME path: that
// statement is sequenced with CreateStmts, after DropStmts, so by
// the time it runs the executable DROP has already failed against
// the old name. Track these so the rename statement is dropped
// and the DROP statement uses the old name instead.
needsRecreateRenamed := map[string]bool{}
for newKey := range renamedFrom {
desiredView, dok := desired.GetOk(newKey)
currentView, cok := current.GetOk(newKey)
if !dok || !cok {
continue
}
// detectViewRenames rejects a rename that switches between VIEW and
// MATERIALIZED VIEW, so this cannot be reached. Kept for the day that
// rename is allowed, when a type switch has to take the recreate path
// rather than ALTER ... RENAME.
// if currentView.Materialized != desiredView.Materialized {
// needsRecreateRenamed[newKey] = true
// continue
// }
if !equalViewDef(currentView.Definition, desiredView.Definition) {
if desiredView.Materialized || !canCreateOrReplaceView(currentView.Definition, desiredView.Definition, desiredView.ColumnNames) {
needsRecreateRenamed[newKey] = true
}
}
}
for _, stmt := range renameStmts {
skip := false
for newKey := range needsRecreateRenamed {
oldKey := renamedFrom[newKey]
desiredView, _ := desired.GetOk(newKey)
needle := oldKey + " RENAME TO " + model.Ident(desiredView.Name) + ";"
if strings.Contains(stmt, needle) {
skip = true
break
}
}
if !skip {
result.CreateStmts = append(result.CreateStmts, stmt)
}
}
viewAllowed := dc.IsDropAllowed("view")
// New or modified views (CREATE OR REPLACE / recreate for materialized)
for k, desiredView := range desired.All() {
currentView, ok := current.GetOk(k)
if !ok {
// New view
result.CreateStmts = append(result.CreateStmts, desiredView.SQL())
result.CreateStmts = append(result.CreateStmts, viewTriggerStmts(desiredView)...)
// Add indexes for new materialized views
if err := createMatViewIndexes(result, k, desiredView); err != nil {
return nil, err
}
} else if !equalViewDef(currentView.Definition, desiredView.Definition) || currentView.Materialized != desiredView.Materialized {
// Materialized views always need DROP+CREATE; VIEW<->MATVIEW
// switches do too. Regular view definition changes prefer
// CREATE OR REPLACE; but PostgreSQL rejects it when the new
// query removes, renames, or reorders an existing output
// column, so detect that case and fall back to DROP+CREATE.
needsDropCreate := desiredView.Materialized || currentView.Materialized != desiredView.Materialized ||
!canCreateOrReplaceView(currentView.Definition, desiredView.Definition, desiredView.ColumnNames)
if needsDropCreate {
// When the view was also renamed, drop the old name;
// the database still has it because the ALTER RENAME
// was suppressed above. Computed before the drop-policy
// branch so the skipped-DROP comment matches the
// relation that actually exists.
dropName := k
if oldKey, renamed := renamedFrom[k]; renamed {
dropName = oldKey
}
drop := "DROP " + currentView.ObjType() + " " + dropName + ";"
// Materialized views or type changes (VIEW <-> MATERIALIZED VIEW)
// require DROP and recreate. Only proceed if drops are allowed;
// otherwise emit a commented DROP for visibility (no CREATE,
// since recreation requires the drop).
if viewAllowed {
result.DropStmts = append(result.DropStmts, drop)
result.DroppedViews = append(result.DroppedViews, dropName)
result.CreateStmts = append(result.CreateStmts, desiredView.SQL())
// DROP VIEW takes the view's triggers with it, so the
// recreate has to put them back.
result.CreateStmts = append(result.CreateStmts, viewTriggerStmts(desiredView)...)
if err := createMatViewIndexes(result, k, desiredView); err != nil {
return nil, err
}
recreated[k] = true
} else {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+drop)
recreateDenied[k] = true
}
} else {
// Regular view: CREATE OR REPLACE
result.CreateStmts = append(result.CreateStmts, desiredView.SQL())
}
} else {
// Definition unchanged. What sits beside it is diffed on its own:
// a materialized view's indexes, a plain view's check option, and
// the storage parameters of either. A replaced or recreated
// definition carries all of that on its own statement, which
// replaces the options as a whole, so only these branches need an
// ALTER.
if desiredView.Materialized {
idxResult, err := diffIndexes(viewIndexes(currentView), viewIndexes(desiredView), nil, nil, false, dc)
if err != nil {
return nil, err
}
result.CreateStmts = append(result.CreateStmts, idxResult.Stmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, idxResult.DisallowedDropStmts...)
if idxResult.HasConcurrently {
result.HasConcurrently = true
}
} else if currentView.CheckOption != desiredView.CheckOption {
result.CreateStmts = append(result.CreateStmts, model.SetCheckOptionSQL(k, desiredView.CheckOption))
}
result.CreateStmts = append(result.CreateStmts, diffViewStorageParams(k, currentView, desiredView)...)
}
// A view that stayed in place, whether its definition was replaced or
// left alone, kept its triggers, so they are diffed. The branches that
// created the view already emitted them, and a denied recreate left
// the view as it was.
if ok && !recreated[k] && !recreateDenied[k] {
trgStmts, trgDisallowed, err := diffTriggers(k, currentView.Triggers, desiredView.Triggers, dc)
if err != nil {
return nil, err
}
result.CreateStmts = append(result.CreateStmts, trgStmts...)
result.DisallowedDropStmts = append(result.DisallowedDropStmts, trgDisallowed...)
}
}
// Dropped views. When the view-drop policy disallows it, emit a commented DROP.
for k, v := range current.All() {
if _, ok := desired.GetOk(k); !ok {
drop := "DROP " + v.ObjType() + " " + k + ";"
if viewAllowed {
result.DropStmts = append(result.DropStmts, drop)
result.DroppedViews = append(result.DroppedViews, k)
} else {
result.DisallowedDropStmts = append(result.DisallowedDropStmts, "-- skipped: "+drop)
}
}
}
// Comment changes
for k, desiredView := range desired.All() {
currentView, ok := current.GetOk(k)
// If the recreation was blocked by --allow-drop (type change or
// matview definition change), the object on disk still matches the
// pre-recreation shape, so skip comment diff to keep the output
// consistent with "nothing executable was emitted for this view".
if recreateDenied[k] {
continue
}
var currentComment *string
if ok && !recreated[k] {
// Preserve current comment for diffing only if the view was not
// recreated. Recreated views lose their comment in PostgreSQL,
// so currentComment stays nil to ensure re-application.
currentComment = currentView.Comment
}
if !equalPtr(currentComment, desiredView.Comment) {
result.CreateStmts = append(result.CreateStmts, commentOnSQL(desiredView.ObjType()+" "+k, desiredView.Comment))
}
var currentColumnComments *orderedmap.Map[string, string]
if ok && !recreated[k] {
currentColumnComments = currentView.ColumnComments
}
result.CreateStmts = append(result.CreateStmts, viewColumnCommentStmts(k, currentColumnComments, desiredView.ColumnComments)...)
}
return result, nil
}
// viewColumnCommentStmts sets each column comment that is new or changed, then
// clears each one desired no longer has. A nil map means no comments.
func viewColumnCommentStmts(fqvn string, current, desired *orderedmap.Map[string, string]) []string {
var stmts []string
if desired != nil {
for col, comment := range desired.All() {
if current != nil {
if c, ok := current.GetOk(col); ok && c == comment {
continue
}
}
stmts = append(stmts, commentOnSQL("COLUMN "+fqvn+"."+model.Ident(col), &comment))
}
}
if current != nil {
for col := range current.Keys() {
if desired != nil {
if _, ok := desired.GetOk(col); ok {
continue
}
}
stmts = append(stmts, commentOnSQL("COLUMN "+fqvn+"."+model.Ident(col), nil))
}
}
return stmts
}
// viewTriggerStmts renders the CREATE TRIGGER statements for a view that is
// about to be created, each followed by its comment. A materialized view
// cannot carry a trigger, so the map is empty there.
func viewTriggerStmts(v *model.View) []string {
if v.Triggers == nil {
return nil
}
var stmts []string
for _, trg := range v.Triggers.CollectValues() {
stmts = append(stmts, trg.SQL())
if s := trg.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
return stmts
}
// viewIndexes returns the indexes of a view, or an empty map when it has none.
func viewIndexes(v *model.View) *orderedmap.Map[string, *model.Index] {
if v.Indexes == nil {
return orderedmap.New[string, *model.Index]()
}
return v.Indexes
}
// createMatViewIndexes adds the CREATE INDEX and comment of every index on a
// materialized view that is created or recreated.
func createMatViewIndexes(result *ViewDiffResult, key string, view *model.View) error {
if !view.Materialized || view.Indexes == nil {
return nil
}
for _, idx := range view.Indexes.CollectValues() {
stmt, err := createIndexSQL(idx.Definition, idx.Concurrently)
if err != nil {
return fmt.Errorf("create index %s on %s: %w", model.Ident(idx.Schema, idx.Name), key, err)
}
result.CreateStmts = append(result.CreateStmts, stmt)
if idx.Concurrently {
result.HasConcurrently = true
}
result.CreateStmts = append(result.CreateStmts, indexCommentStmts(nil, idx)...)
}
return nil
}
package pistachio
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/jackc/pgx/v5"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/diff"
"github.com/winebarrel/pistachio/dsql"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
"github.com/winebarrel/pistachio/toposort"
)
// diffAllOptions holds the common options for diffAll.
type diffAllOptions struct {
FilterOptions
DropPolicy
Desired *desiredInput
DisableIndexConcurrently bool
ForceIndexConcurrently bool
BulkAlter bool
AssumeValidated bool
EvaluateLiterals bool
// StateHash asks for the hash of the current side the diff read. Only a
// run that writes a plan file needs it, so every other run pays nothing
// for the extra encoding.
StateHash bool
// printLiterals is set by diffAll when EvaluateLiterals is on. Diff has
// no server to ask, so it leaves this nil.
printLiterals literalPrinter
}
// desiredInput is what a run reads from the file system: the parsed desired
// schema and the pre-SQL that goes with it.
type desiredInput struct {
schema *parser.ParseResult
preSQL string
concurrentlyPreSQL string
}
// loadDesiredInput reads and parses every file a diff needs. Plan and Apply
// call it before they connect, so a missing file, a syntax error or a
// duplicate name is reported without a database, and an exclusive apply does
// not take its lock before it can read its own input.
func (client *Client) loadDesiredInput(
files []string,
preSQL, preSQLFile string,
concurrentlyPreSQL, concurrentlyPreSQLFile string,
) (*desiredInput, error) {
// Not wrapped: the parser's message already names the file.
var schema *parser.ParseResult
var err error
if client.Engine.isDSQL() {
schema, err = parseStrippingAsync(files, client.Schemas[0])
} else {
schema, err = parser.ParseSQLFilesWithSchema(files, client.Schemas[0])
}
if err != nil {
return nil, err
}
resolvedPreSQL, err := resolvePreSQL(preSQL, preSQLFile)
if err != nil {
return nil, err
}
resolvedConcurrentlyPreSQL, err := resolveConcurrentlyPreSQL(concurrentlyPreSQL, concurrentlyPreSQLFile)
if err != nil {
return nil, err
}
return &desiredInput{
schema: schema,
preSQL: resolvedPreSQL,
concurrentlyPreSQL: resolvedConcurrentlyPreSQL,
}, nil
}
// diffAllResult holds the result of diffAll.
type diffAllResult struct {
Stmts []string
DisallowedDrops []string
Ignored []string
PreSQL string
ConcurrentlyPreSQL string
Count ObjectCount
ExecuteStmts []*parser.ExecuteStmt
HasConcurrentlyIndex bool
// CurrentTables is every table the catalog read, before the filters, and
// DesiredTables and DesiredDomains the desired side after them. --explain
// reads the current side for the tables the statements touch, including a
// partition a filter left out, and the desired side for the rename
// directives and the domain constraints.
CurrentTables *orderedmap.Map[string, *model.Table]
DesiredTables *orderedmap.Map[string, *model.Table]
DesiredDomains *orderedmap.Map[string, *model.Domain]
// DroppedViews names the views and materialized views the statements
// drop, for the dependent check diffAll makes against the catalog. Diff
// reads no catalog and leaves it alone.
DroppedViews []string
// RetypedColumns lists the columns the statements retype, for the same
// check.
RetypedColumns []diff.RetypedColumn
// DroppedConstraints (<table>.<constraint>), DroppedIndexes and
// DroppedForeignKeys name what the statements drop, for the same check.
DroppedConstraints []droppedObject
DroppedIndexes []droppedObject
DroppedForeignKeys []string
// RecreatedRoutines lists the current routines the statements drop and
// create again.
RecreatedRoutines []*model.Routine
// DroppedAtomicRoutines names the routines with a SQL-standard body the
// statements drop, in the form a dependent is named. They are dropped
// with the views, so they block none of the checks either.
DroppedAtomicRoutines []string
// StateHash fingerprints the current side the statements were computed
// against. Empty unless the run asked for it.
StateHash string
// IgnoredObjects names what the desired schema marked -- pista:ignore, by
// the key the current side holds it under. Ignored is the same list as the
// comments the output carries; the plan file records these so apply-from
// can drop them from its read before it hashes it.
IgnoredObjects []string
// InvalidIndexes warns about each managed index whose build failed or
// has not finished.
InvalidIndexes []string
}
// ignoredObjectComments renders the -- ignored: line of each name. plan writes
// them from the diff and apply-from from the plan file, so both spell it the
// same way.
func ignoredObjectComments(names []string) []string {
comments := make([]string, len(names))
for i, name := range names {
comments[i] = "-- ignored: " + name
}
return comments
}
// schemaObjects holds one side of a diff: one map per object kind. On the
// current side diffAll reads it from the system catalogs and Diff fills it
// from a parsed schema file; the desired side comes from the desired schema,
// filtered.
//
// The two sides have the same shape, so a call that takes them as maps takes
// fourteen of them, seven pairs a reader counts out by position. The struct
// names each side instead.
type schemaObjects struct {
Tables *orderedmap.Map[string, *model.Table]
Views *orderedmap.Map[string, *model.View]
Enums *orderedmap.Map[string, *model.Enum]
Domains *orderedmap.Map[string, *model.Domain]
CompositeTypes *orderedmap.Map[string, *model.CompositeType]
Sequences *orderedmap.Map[string, *model.Sequence]
Routines *orderedmap.Map[string, *model.Routine]
}
// objectDiffs holds what one diff produced, one result per object kind.
type objectDiffs struct {
Tables *diff.TableDiffResult
Views *diff.ViewDiffResult
Enums *diff.EnumDiffResult
Domains *diff.DomainDiffResult
CompositeTypes *diff.CompositeTypeDiffResult
Sequences *diff.SequenceDiffResult
Routines *diff.RoutineDiffResult
}
// diffAll performs the common catalog fetch, parse, diff, and statement
// ordering logic shared by Plan and Apply.
func (client *Client) diffAll(ctx context.Context, conn *pgx.Conn, options *diffAllOptions) (*diffAllResult, error) {
cat, err := catalog.NewCatalog(conn, client.Schemas)
if err != nil {
return nil, fmt.Errorf("failed to create catalog: %w", err)
}
current, err := readCurrent(ctx, cat, &options.FilterOptions)
if err != nil {
return nil, err
}
if options.EvaluateLiterals {
options.printLiterals = func(exprs []string) map[string]string {
return printLiterals(ctx, conn, exprs)
}
}
result, err := client.diffObjects(current, options)
if err != nil {
return nil, err
}
// Only a plan that drops a view pays for the dependent read, so the
// common run makes no extra query.
if len(result.DroppedViews) > 0 {
if err := checkViewDependents(ctx, cat, result.DroppedViews, result.earlyDrops()); err != nil {
return nil, err
}
}
if len(result.RetypedColumns) > 0 {
if err := checkColumnDependents(ctx, cat, result.RetypedColumns, result.earlyDrops()); err != nil {
return nil, err
}
}
if len(result.DroppedConstraints) > 0 || len(result.DroppedIndexes) > 0 {
if err := checkKeyDependents(ctx, cat, result); err != nil {
return nil, err
}
}
if len(result.RecreatedRoutines) > 0 {
if err := checkRoutineDependents(ctx, cat, result.RecreatedRoutines, result.earlyDrops()); err != nil {
return nil, err
}
}
return result, nil
}
// readCurrent reads every object kind the diff compares out of the system
// catalogs. apply-from reads the same way for its state hash, so the two see
// one schema rather than two readings of it.
func readCurrent(ctx context.Context, cat *catalog.Catalog, filter *FilterOptions) (*schemaObjects, error) {
current := &schemaObjects{}
var err error
current.Tables, err = cat.Tables(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch tables: %w", err)
}
current.Views, err = cat.Views(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch views: %w", err)
}
current.Enums, err = cat.Enums(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch enums: %w", err)
}
current.Domains, err = cat.Domains(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch domains: %w", err)
}
current.CompositeTypes, err = cat.CompositeTypes(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch composite types: %w", err)
}
current.Sequences, err = cat.Sequences(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch sequences: %w", err)
}
// pg_proc is read only when --manage-routine asked for it. Skipping the
// query keeps the extra round trip off every other run.
current.Routines = orderedmap.New[string, *model.Routine]()
if filter.ManageRoutine {
current.Routines, err = cat.Routines(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch routines: %w", err)
}
}
return current, nil
}
// count reports how many objects of each kind a run inspected, for the line
// plan and apply write above their output. The routine slot is left out
// unless routines are managed, so the line reads as it did before they were.
func (o *schemaObjects) count(schemas []string, manageRoutine bool) ObjectCount {
count := ObjectCount{
Schemas: schemas,
Tables: o.Tables.Len(),
Views: o.Views.Len(),
Enums: o.Enums.Len(),
Domains: o.Domains.Len(),
CompositeTypes: o.CompositeTypes.Len(),
Sequences: o.Sequences.Len(),
}
if manageRoutine {
n := o.Routines.Len()
count.Routines = &n
}
return count
}
// diffObjects diffs the desired schema against an already-loaded current side
// and orders the statements. diffAll hands it the catalog's view of the
// database; Diff hands it a parsed schema file.
func (client *Client) diffObjects(current *schemaObjects, options *diffAllOptions) (*diffAllResult, error) {
currentTables := current.Tables
desired := options.Desired.schema
filterDesiredBySchemas(desired, client.Schemas, client.SchemaMap)
narrowed := options.currentSide(current)
filteredTables := narrowed.Tables
filteredViews := narrowed.Views
filteredEnums := narrowed.Enums
filteredDomains := narrowed.Domains
filteredCompositeTypes := narrowed.CompositeTypes
filteredSequences := narrowed.Sequences
filteredRoutines := narrowed.Routines
desiredEnums := options.filterEnums(client.reverseRemapEnumSchemas(desired.Enums))
desiredDomains := options.filterDomains(client.reverseRemapDomainSchemas(desired.Domains))
desiredCompositeTypes := options.filterCompositeTypes(client.reverseRemapCompositeTypeSchemas(desired.CompositeTypes))
desiredTables := options.filterTables(client.reverseRemapTableSchemas(desired.Tables))
desiredViews := options.filterViews(client.reverseRemapViewSchemas(desired.Views))
desiredSequences := options.filterSequences(client.reverseRemapSequenceSchemas(desired.Sequences))
desiredRoutines := options.filterRoutines(client.reverseRemapRoutineSchemas(desired.Routines))
// Objects marked -- pista:ignore are unmanaged: drop them from both the
// desired and current sides so no create, alter, or drop is generated.
// Their FQNs are surfaced as -- ignored: comments.
ignored := slices.Concat(
removeIgnored(desiredTables, filteredTables, func(t *model.Table) bool { return t.Ignore }),
removeIgnored(desiredViews, filteredViews, func(v *model.View) bool { return v.Ignore }),
removeIgnored(desiredEnums, filteredEnums, func(e *model.Enum) bool { return e.Ignore }),
removeIgnored(desiredDomains, filteredDomains, func(d *model.Domain) bool { return d.Ignore }),
removeIgnored(desiredCompositeTypes, filteredCompositeTypes, func(ct *model.CompositeType) bool { return ct.Ignore }),
removeIgnored(desiredSequences, filteredSequences, func(s *model.Sequence) bool { return s.Ignore }),
removeIgnored(desiredRoutines, filteredRoutines, func(r *model.Routine) bool { return r.Ignore }),
)
slices.Sort(ignored)
// Hashed here: after removeIgnored, so an object the desired schema
// ignores is out of it, and before the transforms below, which reach into
// the current side too (--assume-validated, --force-index-concurrently).
// The plan file records the ignored names, and apply-from drops them from
// its own read before hashing, so the two see the same objects.
var currentStateHash string
if options.StateHash {
var err error
if currentStateHash, err = narrowed.stateHash(); err != nil {
return nil, err
}
}
count := narrowed.count(client.Schemas, options.ManageRoutine)
// The current side is cleared by currentSide, above, so that the state
// hash reads what the diff compares.
if !options.ManageStorageParam {
clearStorageParams(desiredTables)
clearMatViewStorageParams(desiredViews)
}
switch {
case options.DisableIndexConcurrently:
// The current side is cleared too. The catalog never sets the flag,
// so this is a no-op for plan and apply, but Diff parses the current
// side from a file, where a -- pista:concurrently would otherwise
// reach a pure drop.
setConcurrentlyDirectives(filteredTables, filteredViews, false)
setConcurrentlyDirectives(desiredTables, desiredViews, false)
case options.ForceIndexConcurrently:
// The current side is set too so that a pure DROP INDEX (index absent
// from desired) picks up the flag, since catalog-derived indexes don't
// carry the directive.
setConcurrentlyDirectives(filteredTables, filteredViews, true)
setConcurrentlyDirectives(desiredTables, desiredViews, true)
}
if options.AssumeValidated {
assumeValidatedConstraints(filteredTables, desiredTables, filteredDomains, desiredDomains)
}
// After the state hash, which apply-from takes over a read it does not
// normalize.
if client.Engine.isDSQL() {
dsql.NormalizeCurrent(filteredTables)
}
// This runs after the state hash, which must depend on the database only.
// A sequence it adds back for a drop goes through the same filters and
// ignore list as the rest.
ignoredSet := nameSet(ignored)
admit := func(seqs *orderedmap.Map[string, *model.Sequence]) *orderedmap.Map[string, *model.Sequence] {
return options.filterSequences(seqs).Filter(func(k string, _ *model.Sequence) bool { return !ignoredSet[k] })
}
var detached map[columnRef]bool
filteredSequences, desiredSequences, detached = reconcileOwnedSequences(filteredSequences, desiredSequences, filteredTables, desiredTables, admit)
filteredTables = withoutSerialSequence(filteredTables, detached)
// The Diff* functions below read the current side with renamed types and
// sequences carried into their references. orderStatements keeps the
// catalog's names, which the drop order of the current side is built on.
diffTables, diffDomains, diffCompositeTypes := filteredTables, filteredDomains, filteredCompositeTypes
refs := &referenceRenames{
types: slices.Concat(
collectRenames(filteredEnums, desiredEnums, func(e *model.Enum) (string, string, *string) { return e.Schema, e.Name, e.RenameFrom }),
collectRenames(filteredDomains, desiredDomains, func(d *model.Domain) (string, string, *string) { return d.Schema, d.Name, d.RenameFrom }),
collectRenames(filteredCompositeTypes, desiredCompositeTypes, func(ct *model.CompositeType) (string, string, *string) {
return ct.Schema, ct.Name, ct.RenameFrom
}),
),
sequences: collectRenames(filteredSequences, desiredSequences, func(s *model.Sequence) (string, string, *string) { return s.Schema, s.Name, s.RenameFrom }),
searchPath: splitSearchPath(client.searchPath()),
}
if len(refs.types) > 0 || len(refs.sequences) > 0 {
diffTables = refs.applyTables(filteredTables)
diffDomains = refs.applyDomains(filteredDomains)
diffCompositeTypes = refs.applyCompositeTypes(filteredCompositeTypes)
}
if options.printLiterals != nil {
diffTables, diffDomains = evaluateLiterals(options.printLiterals, diffTables, desiredTables, diffDomains, desiredDomains)
}
enumDiff, err := diff.DiffEnums(filteredEnums, desiredEnums, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff enums: %w", err)
}
sequenceDiff, err := diff.DiffSequences(filteredSequences, desiredSequences, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff sequences: %w", err)
}
domainDiff, err := diff.DiffDomains(diffDomains, desiredDomains, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff domains: %w", err)
}
compositeTypeDiff, err := diff.DiffCompositeTypes(diffCompositeTypes, desiredCompositeTypes, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff composite types: %w", err)
}
tableDiff, err := diff.DiffTables(diffTables, desiredTables, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff tables: %w", err)
}
// Read before --bulk-alter merges the statements. A check or a not-null
// constraint has nothing depending on it, so its drop is left out.
tableRenames, indexRenames := renamedTablesAndIndexes(diffTables, desiredTables)
droppedConstraintNames, droppedIndexNames := droppedKeys(tableDiff.Stmts, func(table, name string) bool {
keep := true
if t, ok := diffTables.GetOk(renamedFrom(tableRenames, table)); ok {
if con, ok := t.Constraints.GetOk(name); ok {
keep = !con.Type.IsCheckConstraint() && !con.Type.IsNotNullConstraint()
}
}
return keep
})
var droppedConstraints, droppedIndexes []droppedObject
for _, k := range droppedConstraintNames {
table, name := splitConstraintKey(k)
droppedConstraints = append(droppedConstraints, droppedObject{Name: k, Current: renamedFrom(tableRenames, table) + "." + name})
}
for _, k := range droppedIndexNames {
droppedIndexes = append(droppedIndexes, droppedObject{Name: k, Current: renamedFrom(indexRenames, k)})
}
droppedForeignKeys, _ := droppedKeys(tableDiff.FKDropStmts, nil)
// --bulk-alter merges every table; the -- pista:bulk-alter directive
// opts in individual tables.
bulkAlterTables := make(map[string]bool)
for _, t := range desiredTables.CollectValues() {
if t.BulkAlter {
bulkAlterTables[t.FQTN()] = true
}
}
if client.Engine.isDSQL() {
if len(bulkAlterTables) > 0 {
return nil, errors.New("-- pista:bulk-alter cannot be used with --engine dsql")
}
tableDiff.Stmts = dsql.Split(tableDiff.Stmts)
}
if options.BulkAlter || len(bulkAlterTables) > 0 {
tableDiff.Stmts = mergeAlterTable(tableDiff.Stmts, func(fqtn string) bool {
return options.BulkAlter || bulkAlterTables[fqtn]
})
}
viewDiff, err := diff.DiffViews(filteredViews, desiredViews, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff views: %w", err)
}
routineDiff, err := diff.DiffRoutines(filteredRoutines, desiredRoutines, &options.DropPolicy)
if err != nil {
return nil, fmt.Errorf("failed to diff routines: %w", err)
}
stmts := orderStatements(
narrowed,
&schemaObjects{
Tables: desiredTables,
Views: desiredViews,
Enums: desiredEnums,
Domains: desiredDomains,
CompositeTypes: desiredCompositeTypes,
Sequences: desiredSequences,
Routines: desiredRoutines,
},
&objectDiffs{
Tables: tableDiff,
Views: viewDiff,
Enums: enumDiff,
Domains: domainDiff,
CompositeTypes: compositeTypeDiff,
Sequences: sequenceDiff,
Routines: routineDiff,
},
)
if client.Engine.isDSQL() {
if stmts, err = dsql.Finish(stmts); err != nil {
return nil, err
}
}
disallowed := slices.Concat(
viewDiff.DisallowedDropStmts,
tableDiff.DisallowedDropStmts,
domainDiff.DisallowedDropStmts,
compositeTypeDiff.DisallowedDropStmts,
enumDiff.DisallowedDropStmts,
sequenceDiff.DisallowedDropStmts,
routineDiff.DisallowedDropStmts,
)
return &diffAllResult{
Stmts: stmts,
DisallowedDrops: disallowed,
Ignored: ignoredObjectComments(ignored),
InvalidIndexes: invalidIndexWarnings(filteredTables, filteredViews),
IgnoredObjects: ignored,
PreSQL: options.Desired.preSQL,
ConcurrentlyPreSQL: options.Desired.concurrentlyPreSQL,
Count: count,
ExecuteStmts: desired.ExecuteStmts,
HasConcurrentlyIndex: tableDiff.HasConcurrently || viewDiff.HasConcurrently,
CurrentTables: currentTables,
DesiredTables: desiredTables,
DesiredDomains: desiredDomains,
DroppedViews: viewDiff.DroppedViews,
RetypedColumns: tableDiff.RetypedColumns,
DroppedConstraints: droppedConstraints,
DroppedIndexes: droppedIndexes,
DroppedForeignKeys: droppedForeignKeys,
RecreatedRoutines: routineDiff.Recreated,
DroppedAtomicRoutines: routineDiff.DroppedAtomic,
StateHash: currentStateHash,
}, nil
}
// checkViewDependents fails the plan when it would drop a view or
// materialized view that another object still reads. PostgreSQL refuses that
// DROP instead of cascading, so without this the plan reads as fine and apply
// fails on it.
//
// Dependents come from the catalog, not from the desired schema, which cannot
// see a view a filter or an unmanaged schema hides.
//
// A dependent the same plan drops is no obstacle: drops run deepest first.
func checkViewDependents(ctx context.Context, cat *catalog.Catalog, dropped []string, skip map[string]bool) error {
dependents, err := cat.ViewDependents(ctx)
if err != nil {
return fmt.Errorf("failed to fetch view dependents: %w", err)
}
return blockedError("cannot drop", dropped, dependents, skip)
}
// earlyDrops names what the plan drops before anything else changes: the
// views, and the routines with a SQL-standard body, which go with them. A
// dependent among them blocks none of the checks.
func (r *diffAllResult) earlyDrops() map[string]bool {
return nameSet(slices.Concat(r.DroppedViews, r.DroppedAtomicRoutines))
}
// checkColumnDependents fails the plan when a column it retypes has a
// dependent that makes PostgreSQL refuse the change. A view the same plan
// drops does not block, since view drops run first.
func checkColumnDependents(ctx context.Context, cat *catalog.Catalog, retyped []diff.RetypedColumn, skip map[string]bool) error {
dependents, err := cat.ColumnDependents(ctx)
if err != nil {
return fmt.Errorf("failed to fetch column dependents: %w", err)
}
// Look up by the catalog's name and report the desired one.
names := make([]string, 0, len(retyped))
byName := make(map[string][]catalog.Dependent, len(retyped))
for _, col := range retyped {
names = append(names, col.Name)
byName[col.Name] = dependents[col.Current]
}
return blockedError("cannot change the type of", names, byName, skip)
}
// checkKeyDependents fails the plan when a key or an index it drops, including
// one it recreates, has a dependent that makes PostgreSQL refuse the drop. A
// foreign key or a view the same plan drops does not block, since those drops
// run first.
func checkKeyDependents(ctx context.Context, cat *catalog.Catalog, result *diffAllResult) error {
constraints, indexes, err := cat.KeyDependents(ctx)
if err != nil {
return fmt.Errorf("failed to fetch key dependents: %w", err)
}
// Look up by the catalog's name and report the desired one.
var targets []string
byTarget := map[string][]catalog.Dependent{}
for _, obj := range result.DroppedConstraints {
table, name := splitConstraintKey(obj.Name)
target := "constraint " + name + " on " + table
targets = append(targets, target)
byTarget[target] = constraints[obj.Current]
}
for _, obj := range result.DroppedIndexes {
target := "index " + obj.Name
targets = append(targets, target)
byTarget[target] = indexes[obj.Current]
}
skip := nameSet(slices.Concat(result.DroppedViews, result.DroppedForeignKeys))
return blockedError("cannot drop", targets, byTarget, skip)
}
// checkRoutineDependents fails the plan when a routine it recreates has a
// dependent, since PostgreSQL refuses the DROP. The recreate runs before the
// table changes, so only what earlyDrops names is skipped.
func checkRoutineDependents(ctx context.Context, cat *catalog.Catalog, recreated []*model.Routine, skip map[string]bool) error {
dependents, err := cat.RoutineDependents(ctx)
if err != nil {
return fmt.Errorf("failed to fetch routine dependents: %w", err)
}
targets := make([]string, 0, len(recreated))
byTarget := make(map[string][]catalog.Dependent, len(recreated))
for _, r := range recreated {
target := strings.ToLower(r.Kind()) + " " + r.Signature()
targets = append(targets, target)
byTarget[target] = dependents[r.OID]
}
return blockedError("cannot drop", targets, byTarget, skip)
}
// droppedObject is a constraint or an index a statement drops. Name is how the
// statement names it, and Current how the catalog does, before any rename in
// the plan.
type droppedObject struct {
Name string
Current string
}
// renamedTablesAndIndexes maps the new name of each table and index the plan
// renames to its current name. An index is named with its schema.
func renamedTablesAndIndexes(current, desired *orderedmap.Map[string, *model.Table]) (tables, indexes map[string]string) {
tables = map[string]string{}
indexes = map[string]string{}
for k, t := range desired.All() {
currentTable := k
if t.RenameFrom != nil {
if _, ok := current.GetOk(*t.RenameFrom); ok {
tables[k] = *t.RenameFrom
currentTable = *t.RenameFrom
}
}
ct, ok := current.GetOk(currentTable)
if !ok {
continue
}
for name, idx := range t.Indexes.All() {
if idx.RenameFrom == nil {
continue
}
if old, ok := ct.Indexes.GetOk(*idx.RenameFrom); ok {
indexes[model.Ident(idx.Schema, name)] = model.Ident(old.Schema, old.Name)
}
}
}
return tables, indexes
}
// renamedFrom returns the current name of a table, an index or a column
// renamed in the plan, or the name itself.
func renamedFrom(renames map[string]string, name string) string {
if old, ok := renames[name]; ok {
return old
}
return name
}
// droppedKeys reads the constraints, as <table>.<constraint>, and the indexes
// the statements drop. keep, when set, decides which constraints to list.
func droppedKeys(stmts []string, keep func(table, name string) bool) (constraints, indexes []string) {
for _, stmt := range stmts {
if !strings.Contains(stmt, " DROP CONSTRAINT ") && !strings.HasPrefix(stmt, "DROP INDEX ") {
continue
}
node := parseOneStmt(stmt)
if node == nil {
continue
}
if as := node.GetAlterTableStmt(); as != nil {
table := rangeVarIdent(as.GetRelation())
for _, cmd := range as.GetCmds() {
c := cmd.GetAlterTableCmd()
if c.GetSubtype() == pg_query.AlterTableType_AT_DropConstraint && (keep == nil || keep(table, c.GetName())) {
constraints = append(constraints, table+"."+model.Ident(c.GetName()))
}
}
}
if ds := node.GetDropStmt(); ds != nil && ds.GetRemoveType() == pg_query.ObjectType_OBJECT_INDEX {
for _, obj := range ds.GetObjects() {
indexes = append(indexes, objectIdent(obj))
}
}
}
return constraints, indexes
}
// splitConstraintKey splits <table>.<constraint> at the last dot outside
// double quotes.
func splitConstraintKey(key string) (table, name string) {
inQuote := false
last := -1
for i, r := range key {
switch {
case r == '"':
inQuote = !inQuote
case r == '.' && !inQuote:
last = i
}
}
return key[:last], key[last+1:]
}
// nameSet turns a list of names into a set.
func nameSet(names []string) map[string]bool {
set := make(map[string]bool, len(names))
for _, k := range names {
set[k] = true
}
return set
}
// blockedError lists the dependents that block each target, leaving out the
// views, foreign keys and routines in skip, and reports every blocked target
// at once. A routine is matched by its full dependent name.
func blockedError(prefix string, targets []string, dependents map[string][]catalog.Dependent, skip map[string]bool) error {
var msgs []string
for _, k := range targets {
var blockers []string
for _, dep := range dependents[k] {
if (dep.Relation != "" && skip[dep.Relation]) || (dep.Constraint != "" && skip[dep.Constraint]) || skip[dep.String()] {
continue
}
blockers = append(blockers, dep.String())
}
if len(blockers) == 0 {
continue
}
verb := "depends"
if len(blockers) > 1 {
verb = "depend"
}
msgs = append(msgs, fmt.Sprintf("%s %s: %s %s on it", prefix, k, strings.Join(blockers, ", "), verb))
}
if len(msgs) > 0 {
return errors.New(strings.Join(msgs, "; "))
}
return nil
}
// reconcileOwnedSequences matches the sequences that columns own on the two
// sides. It returns copies and does not change its arguments.
//
// - A current sequence that the desired schema does not declare is removed
// from the current side, so no DROP SEQUENCE is planned, when:
// the desired schema drops the owning table or column (PostgreSQL drops
// the sequence with it); the desired column is a serial type; or the
// desired column keeps the nextval default or has none. Schemas written
// for earlier versions rely on the last two.
// - A desired sequence is removed when the database reads it as a serial
// column's sequence. This is how pg_dump writes a serial.
// - A current owner is moved through a table or column rename, so the
// rename alone plans no OWNED BY.
// - When a column that takes its default from an owned sequence becomes an
// identity column, the old sequence stays on the current side, or is
// added to it for a serial, so that it is dropped. ADD IDENTITY creates a
// new sequence. This is skipped when the desired schema declares the old
// one. The same goes for a serial column given another default. admit
// applies the sequence filters and the ignore list to what is added.
//
// It also returns the current columns whose sequence the plan detaches or
// moves. Such a column no longer keeps a default the desired schema leaves
// out.
func reconcileOwnedSequences(
current, desired *orderedmap.Map[string, *model.Sequence],
currentTables, desiredTables *orderedmap.Map[string, *model.Table],
admit func(*orderedmap.Map[string, *model.Sequence]) *orderedmap.Map[string, *model.Sequence],
) (*orderedmap.Map[string, *model.Sequence], *orderedmap.Map[string, *model.Sequence], map[columnRef]bool) {
current, desired = current.Clone(), desired.Clone()
// Keyed by the current name, so a renamed sequence is found too.
declared := map[string]*model.Sequence{}
for k, seq := range desired.All() {
declared[k] = seq
if seq.RenameFrom != nil {
declared[*seq.RenameFrom] = seq
}
}
detached := map[columnRef]bool{}
for _, e := range current.Entries() {
k, seq := e.Key, e.Value
if !seq.Owned() {
continue
}
table, col := renamedColumn(desiredTables, seq.OwnerFQTN(), *seq.OwnerColumn)
desiredSeq, ok := declared[k]
if !ok {
if col == nil || model.IsSerialType(col.TypeName) {
current.Delete(k)
continue
}
if drawsFrom(currentTables, seq, col) && !col.Identity.IsIdentityColumn() {
current.Delete(k)
}
continue
}
ownerTable, ownerColumn := *seq.OwnerTable, *seq.OwnerColumn
if col != nil {
ownerTable, ownerColumn = table.Name, col.Name
}
if !desiredSeq.Owned() || *desiredSeq.OwnerTable != ownerTable || *desiredSeq.OwnerColumn != ownerColumn {
detached[columnRef{seq.OwnerFQTN(), *seq.OwnerColumn}] = true
}
if ownerTable != *seq.OwnerTable || ownerColumn != *seq.OwnerColumn {
renamed := *seq
renamed.OwnerTable, renamed.OwnerColumn = &ownerTable, &ownerColumn
current.Set(k, &renamed)
}
}
retired := orderedmap.New[string, *model.Sequence]()
for t := range currentTables.Values() {
for col := range t.Columns.Values() {
// A serial column's sequence has the default name and is not on
// the current side.
if col.SerialSequence == nil || !model.IsSerialType(col.TypeName) {
continue
}
seq := &model.Sequence{Schema: t.Schema, Name: t.Name + "_" + col.Name + "_seq"}
if _, ok := declared[seq.FQN()]; ok {
continue
}
_, desiredCol := renamedColumn(desiredTables, t.FQTN(), col.Name)
if desiredCol == nil {
continue
}
replaced := desiredCol.Default != nil && !diff.EqualDefault(col.Default, desiredCol.Default)
if !desiredCol.Identity.IsIdentityColumn() && !replaced {
continue
}
retired.Set(seq.FQN(), seq)
}
}
for k, seq := range admit(retired).All() {
current.Set(k, seq)
}
for _, e := range desired.Entries() {
k, seq := e.Key, e.Value
if !seq.Owned() {
continue
}
if _, ok := current.GetOk(k); ok {
continue
}
col := currentColumn(currentTables, desiredTables, seq.OwnerFQTN(), *seq.OwnerColumn)
if col != nil && col.SerialSequence != nil && *col.SerialSequence == k {
desired.Delete(k)
}
}
return current, desired, detached
}
// columnRef names a column by its table's qualified name.
type columnRef struct {
table string
column string
}
// currentColumn returns the current column that the desired column
// fqtn.column is, following a table or column rename in the desired schema.
func currentColumn(currentTables, desiredTables *orderedmap.Map[string, *model.Table], fqtn, column string) *model.Column {
if dt, ok := desiredTables.GetOk(fqtn); ok {
if dt.RenameFrom != nil {
if _, ok := currentTables.GetOk(*dt.RenameFrom); ok {
fqtn = *dt.RenameFrom
}
}
if dc, ok := dt.Columns.GetOk(column); ok && dc.RenameFrom != nil {
column = *dc.RenameFrom
}
}
t, ok := currentTables.GetOk(fqtn)
if !ok {
return nil
}
return t.Columns.Get(column)
}
// withoutSerialSequence returns tables with SerialSequence cleared on the given
// columns, copying only the tables it changes.
func withoutSerialSequence(tables *orderedmap.Map[string, *model.Table], cols map[columnRef]bool) *orderedmap.Map[string, *model.Table] {
if len(cols) == 0 {
return tables
}
out := tables.Clone()
for ref := range cols {
t, ok := out.GetOk(ref.table)
if !ok {
continue
}
col, ok := t.Columns.GetOk(ref.column)
if !ok || col.SerialSequence == nil {
continue
}
copiedTable := *t
copiedTable.Columns = t.Columns.Clone()
copiedCol := *col
copiedCol.SerialSequence = nil
copiedTable.Columns.Set(ref.column, &copiedCol)
out.Set(ref.table, &copiedTable)
}
return out
}
// drawsFrom reports whether the current column that owns seq takes its default
// from seq, and the desired column keeps that default. No default in the
// desired column counts as keeping it, as in the column diff.
func drawsFrom(currentTables *orderedmap.Map[string, *model.Table], seq *model.Sequence, desired *model.Column) bool {
t, ok := currentTables.GetOk(seq.OwnerFQTN())
if !ok {
return false
}
col, ok := t.Columns.GetOk(*seq.OwnerColumn)
if !ok || col.SerialSequence == nil || *col.SerialSequence != seq.FQN() {
return false
}
return desired.Default == nil || diff.EqualDefault(col.Default, desired.Default)
}
// renamedColumn returns the desired table and column for the current column
// fqtn.column, following a table or column rename. It returns nil when the
// desired schema has no such column.
func renamedColumn(tables *orderedmap.Map[string, *model.Table], fqtn, column string) (*model.Table, *model.Column) {
// A rename comes first, so that a new object under the old name does not
// take its place. A directive left after the rename was applied names a
// source that is gone, and the name matches instead.
var t *model.Table
for candidate := range tables.Values() {
if candidate.RenameFrom != nil && *candidate.RenameFrom == fqtn {
t = candidate
break
}
}
if t == nil {
var ok bool
if t, ok = tables.GetOk(fqtn); !ok {
return nil, nil
}
}
for col := range t.Columns.Values() {
if col.RenameFrom != nil && *col.RenameFrom == column {
return t, col
}
}
if col, ok := t.Columns.GetOk(column); ok {
return t, col
}
return nil, nil
}
// removeIgnored deletes every entry the ignored predicate matches from the
// desired map and the same key from the current map, so ignored objects
// produce no create, alter, or drop. It returns the removed keys (the object
// FQNs). Keys are collected first to avoid mutating the map while ranging.
func removeIgnored[V any](desired, current *orderedmap.Map[string, V], ignored func(V) bool) []string {
var keys []string
for k, v := range desired.All() {
if ignored(v) {
keys = append(keys, k)
}
}
for _, k := range keys {
desired.Delete(k)
current.Delete(k)
}
return keys
}
// setConcurrentlyDirectives sets the per-index Concurrently flag on every
// table and materialized view index in the given maps, used to implement
// --disable-index-concurrently and --force-index-concurrently.
func setConcurrentlyDirectives(
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
on bool,
) {
for _, t := range tables.CollectValues() {
for _, idx := range t.Indexes.CollectValues() {
idx.Concurrently = on
}
}
for _, v := range views.CollectValues() {
for _, idx := range v.Indexes.CollectValues() {
idx.Concurrently = on
}
}
}
// clearStorageParams drops the storage parameters off every table in the
// given map, used when --manage-storage-param was not passed. Clearing both
// sides is what leaves them unmanaged: no SET or RESET is planned, a WITH
// clause a desired schema writes is not carried into the CREATE TABLE, and
// dump does not write one.
//
// The map is emptied rather than dropped, so a JSON dump writes {} for a table
// whose parameters were not read, the way it writes {} for the routines it did
// not read. The SQL is the same either way: the renderer and the diff both
// take a nil map for an empty one.
func clearStorageParams(tables *orderedmap.Map[string, *model.Table]) {
for _, t := range tables.CollectValues() {
t.StorageParams = orderedmap.New[string, string]()
}
}
// clearMatViewStorageParams does the same for a materialized view, which
// carries the parameters a table carries and is left unmanaged with it. A plain
// view is not touched: it holds only security_barrier and security_invoker,
// which decide what the view means rather than how it is stored.
func clearMatViewStorageParams(views *orderedmap.Map[string, *model.View]) {
for _, v := range views.CollectValues() {
if v.Materialized {
v.StorageParams = orderedmap.New[string, string]()
}
}
}
// assumeValidatedConstraints marks every table constraint, domain constraint,
// and foreign key as validated on both the current and desired sides, used to
// implement --assume-validated. Forcing the validation state to true means NOT
// VALID is never emitted and validation-state differences produce no VALIDATE
// CONSTRAINT, keeping the validated flag out of the diff entirely.
func assumeValidatedConstraints(
currentTables *orderedmap.Map[string, *model.Table],
desiredTables *orderedmap.Map[string, *model.Table],
currentDomains *orderedmap.Map[string, *model.Domain],
desiredDomains *orderedmap.Map[string, *model.Domain],
) {
for _, tables := range []*orderedmap.Map[string, *model.Table]{currentTables, desiredTables} {
for _, t := range tables.CollectValues() {
for _, con := range t.Constraints.CollectValues() {
con.Validated = true
}
for _, fk := range t.ForeignKeys.CollectValues() {
fk.Validated = true
}
}
}
for _, domains := range []*orderedmap.Map[string, *model.Domain]{currentDomains, desiredDomains} {
for _, d := range domains.CollectValues() {
for _, c := range d.Constraints {
c.Validated = true
}
}
}
}
// orderStatements uses topological sort to determine the correct execution
// order for diff statements based on object dependencies.
// Falls back to the default category-based ordering if topological sort fails.
func orderStatements(current, desired *schemaObjects, diffs *objectDiffs) []string {
// Build topological order from desired schema for creates
createOrder, err := toposort.OrderFromSchema(
desired.Enums, desired.Domains, desired.CompositeTypes, desired.Tables, desired.Views, desired.Sequences, desired.Routines,
)
if err != nil {
return fallbackOrder(current, desired, diffs)
}
createPosMap := positions(createOrder)
addIndexPositions(createPosMap, desired.Tables, desired.Views)
// Build topological order from current schema for drops.
// Dropped objects are not in the desired schema, so we need the current
// schema's dependency graph to determine correct drop order.
dropOrder, err := toposort.OrderFromSchema(
current.Enums, current.Domains, current.CompositeTypes, current.Tables, current.Views, current.Sequences, current.Routines,
)
if err != nil {
return fallbackOrder(current, desired, diffs)
}
dropPosMap := positions(dropOrder)
addIndexPositions(dropPosMap, current.Tables, current.Views)
// Phase 1: Creates/modifications in topological order.
// Statements whose owning object cannot be identified (pos < 0) are placed
// before all topo-ordered statements, preserving their original relative order.
var createStmts []taggedStmt
createStmts = append(createStmts, tagStatements(diffs.Enums.Stmts, createPosMap)...)
createStmts = append(createStmts, tagStatements(diffs.Domains.Stmts, createPosMap)...)
createStmts = append(createStmts, tagStatements(diffs.CompositeTypes.Stmts, createPosMap)...)
createStmts = append(createStmts, tagStatements(diffs.Sequences.Stmts, createPosMap)...)
createStmts = append(createStmts, tagStatements(diffs.Routines.Stmts, createPosMap)...)
createStmts = append(createStmts, tagStatements(diffs.Tables.Stmts, createPosMap)...)
// Phase 2: Pre-create drops in reverse topological order.
// View drops must happen before table/column changes (views may depend on
// columns being dropped).
var preDropStmts []taggedStmt
preDropStmts = append(preDropStmts, tagStatements(diffs.Views.DropStmts, dropPosMap)...)
preDropStmts = append(preDropStmts, tagStatements(diffs.Routines.AtomicDropStmts, dropPosMap)...)
// Phase 3: Post-create drops in reverse dependency order.
// Table drops must come after table creates/alters (column type changes may
// remove references to domains/enums). Domain/enum drops come after table
// drops (tables must stop referencing them first).
var postDropStmts []taggedStmt
postDropStmts = append(postDropStmts, tagStatements(diffs.Tables.DropStmts, dropPosMap)...)
postDropStmts = append(postDropStmts, tagStatements(diffs.Sequences.DropStmts, dropPosMap)...)
postDropStmts = append(postDropStmts, tagStatements(diffs.Routines.DropStmts, dropPosMap)...)
postDropStmts = append(postDropStmts, tagStatements(diffs.CompositeTypes.DropStmts, dropPosMap)...)
postDropStmts = append(postDropStmts, tagStatements(diffs.Domains.DropStmts, dropPosMap)...)
postDropStmts = append(postDropStmts, tagStatements(diffs.Enums.DropStmts, dropPosMap)...)
// Phase 4: View creates in topological order. A routine with a
// SQL-standard body goes with them, since PostgreSQL parses the body at
// creation time; its drop goes with the view drops in phase 2.
var viewCreateStmts []taggedStmt
viewCreateStmts = append(viewCreateStmts, tagStatements(diffs.Views.CreateStmts, createPosMap)...)
viewCreateStmts = append(viewCreateStmts, tagStatements(diffs.Routines.AtomicStmts, createPosMap)...)
// Assemble:
// FK drops -> view drops -> creates/alters -> table/domain/enum drops -> FK adds -> view creates -> policy creates
var stmts []string
stmts = append(stmts, diffs.Tables.FKDropStmts...)
stmts = append(stmts, sortedSQL(preDropStmts, true)...)
stmts = append(stmts, diffs.Sequences.DisownStmts...)
stmts = append(stmts, sortedSQL(createStmts, false)...)
stmts = append(stmts, diffs.Tables.PartitionedIndexStmts...)
// The logged <-> unlogged transitions carry their own order, so they are
// appended rather than tagged and sorted. They sit after the creates, which
// puts them after any rename, and before the FK adds, which together with
// the FK drops above leaves exactly the keys that stay in place.
stmts = append(stmts, diffs.Tables.PersistenceStmts...)
stmts = append(stmts, diffs.Sequences.OwnedByStmts...)
stmts = append(stmts, sortedSQL(postDropStmts, true)...)
stmts = append(stmts, diffs.Tables.FKAddStmts...)
stmts = append(stmts, sortedSQL(viewCreateStmts, false)...)
// A policy can read any table or view, so it is created once all exist.
stmts = append(stmts, diffs.Tables.PolicyStmts...)
return stmts
}
// fallbackOrder is the original hardcoded ordering logic used as fallback.
//
// The view statements are still sorted among themselves. A view chain has to
// come apart deepest first and go back together base first, whatever made the
// whole-schema sort fail, and checkViewDependents counts on that when it lets
// a dependent the same plan drops through.
func fallbackOrder(current, desired *schemaObjects, diffs *objectDiffs) []string {
var stmts []string
stmts = append(stmts, diffs.Enums.Stmts...)
stmts = append(stmts, diffs.Domains.Stmts...)
stmts = append(stmts, diffs.CompositeTypes.Stmts...)
stmts = append(stmts, diffs.Sequences.DisownStmts...)
stmts = append(stmts, diffs.Sequences.Stmts...)
stmts = append(stmts, diffs.Routines.AtomicDropStmts...)
stmts = append(stmts, sortViewStmts(diffs.Views.DropStmts, current.Views, true)...)
stmts = append(stmts, diffs.Routines.Stmts...)
stmts = append(stmts, diffs.Tables.FKDropStmts...)
stmts = append(stmts, diffs.Tables.Stmts...)
stmts = append(stmts, diffs.Tables.PartitionedIndexStmts...)
stmts = append(stmts, diffs.Tables.PersistenceStmts...)
stmts = append(stmts, diffs.Sequences.OwnedByStmts...)
stmts = append(stmts, diffs.Tables.DropStmts...)
stmts = append(stmts, diffs.Sequences.DropStmts...)
stmts = append(stmts, diffs.Routines.DropStmts...)
stmts = append(stmts, diffs.CompositeTypes.DropStmts...)
stmts = append(stmts, diffs.Domains.DropStmts...)
stmts = append(stmts, diffs.Enums.DropStmts...)
stmts = append(stmts, diffs.Tables.FKAddStmts...)
stmts = append(stmts, sortViewStmts(diffs.Views.CreateStmts, desired.Views, false)...)
stmts = append(stmts, diffs.Routines.AtomicStmts...)
stmts = append(stmts, diffs.Tables.PolicyStmts...)
return stmts
}
// sortViewStmts orders statements by the dependency order of the views alone,
// reversed for drops. It is the fallback's stand-in for the whole-schema sort,
// which can fail on a cycle the views are not part of.
//
// A sort that fails even so leaves the statements as they were, the way the
// fallback left every statement before.
func sortViewStmts(stmts []string, views *orderedmap.Map[string, *model.View], reverse bool) []string {
if len(stmts) == 0 || views == nil {
return stmts
}
order, err := toposort.OrderViews(views)
if err != nil {
return stmts
}
posMap := positions(order)
addIndexPositions(posMap, orderedmap.New[string, *model.Table](), views)
return sortedSQL(tagStatements(stmts, posMap), reverse)
}
// positions maps each name in a topological order to its index.
func positions(order []string) map[string]int {
posMap := make(map[string]int, len(order))
for i, name := range order {
posMap[name] = i
}
return posMap
}
// addIndexPositions gives every index the position of the relation it sits on.
// COMMENT ON INDEX names the index alone, so without this it would sort as an
// unidentified statement and run before the CREATE INDEX it comments on.
func addIndexPositions(
posMap map[string]int,
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
) {
add := func(key string, indexes *orderedmap.Map[string, *model.Index]) {
pos, ok := posMap[key]
if !ok || indexes == nil {
return
}
for _, idx := range indexes.CollectValues() {
posMap[model.Ident(idx.Schema, idx.Name)] = pos
}
}
for k, t := range tables.All() {
add(k, t.Indexes)
}
for k, v := range views.All() {
add(k, v.Indexes)
}
}
// taggedStmt pairs a SQL statement with a sort position derived from
// the topological order of the object it affects.
type taggedStmt struct {
sql string
pos int
}
// compareTaggedPos compares two tagged statement positions for sorting.
// Unknown positions (pos < 0) are placed before all known positions,
// preserving their original relative order via the stable sort.
// This ensures RENAME and INDEX statements (which can't be mapped to a
// posMap entry) execute before dependent creates/modifications.
func compareTaggedPos(posI, posJ int, reverse bool) bool {
iUnknown := posI < 0
jUnknown := posJ < 0
switch {
case iUnknown && jUnknown:
return false // preserve original order
case iUnknown:
return true // unknown before known
case jUnknown:
return false
default:
if reverse {
return posI > posJ
}
return posI < posJ
}
}
// sortedSQL stable-sorts tagged statements by position, reversed for drops,
// and returns their SQL.
func sortedSQL(tagged []taggedStmt, reverse bool) []string {
sort.SliceStable(tagged, func(i, j int) bool {
return compareTaggedPos(tagged[i].pos, tagged[j].pos, reverse)
})
sorted := make([]string, len(tagged))
for i, ts := range tagged {
sorted[i] = ts.sql
}
return sorted
}
// tagStatements extracts object names from SQL statements and assigns
// topological positions. Statements whose object can't be identified
// get position -1.
func tagStatements(stmts []string, posMap map[string]int) []taggedStmt {
tagged := make([]taggedStmt, len(stmts))
for i, sql := range stmts {
name := extractObjectName(sql)
pos := -1
if p, ok := posMap[name]; ok {
pos = p
}
tagged[i] = taggedStmt{sql: sql, pos: pos}
}
return tagged
}
// extractObjectName names the object a DDL statement belongs to, the way
// model.Ident names it, which is how the position maps are keyed. An empty
// string means no object the order knows about, and tagStatements leaves such
// a statement at position -1.
//
// The name comes from the parse tree, not from a list of statement prefixes.
// A prefix the list leaves out sorts the statement as unknown in silence:
// CREATE UNIQUE INDEX did once, CREATE UNLOGGED SEQUENCE until this.
func extractObjectName(sql string) string {
// A statement PostgreSQL cannot read has no place in the order. These
// are pistachio's own statements, so the run fails on it anyway.
result, err := pg_query.Parse(sql)
if err != nil || len(result.GetStmts()) == 0 {
return ""
}
return stmtObjectName(result.GetStmts()[0].GetStmt())
}
// stmtObjectName names the object one parsed statement belongs to. An index,
// a trigger and a policy belong to the relation they sit on, and a routine to
// its name without the argument list, which is the key the graph gives it.
//
// A rename or a drop that names an index alone stays unplaced, as before:
// both run before the statements that follow them, which is what position -1
// gives them. COMMENT ON INDEX does take the position of the relation the
// index sits on, which addIndexPositions adds.
func stmtObjectName(node *pg_query.Node) string {
switch {
case node.GetCreateStmt() != nil:
return rangeVarIdent(node.GetCreateStmt().GetRelation())
case node.GetCreateSeqStmt() != nil:
return rangeVarIdent(node.GetCreateSeqStmt().GetSequence())
case node.GetAlterSeqStmt() != nil:
return rangeVarIdent(node.GetAlterSeqStmt().GetSequence())
case node.GetViewStmt() != nil:
return rangeVarIdent(node.GetViewStmt().GetView())
case node.GetCreateTableAsStmt() != nil:
return rangeVarIdent(node.GetCreateTableAsStmt().GetInto().GetRel())
case node.GetIndexStmt() != nil:
return rangeVarIdent(node.GetIndexStmt().GetRelation())
case node.GetCreateTrigStmt() != nil:
return rangeVarIdent(node.GetCreateTrigStmt().GetRelation())
case node.GetCreatePolicyStmt() != nil:
return rangeVarIdent(node.GetCreatePolicyStmt().GetTable())
case node.GetAlterPolicyStmt() != nil:
return rangeVarIdent(node.GetAlterPolicyStmt().GetTable())
case node.GetCompositeTypeStmt() != nil:
return rangeVarIdent(node.GetCompositeTypeStmt().GetTypevar())
case node.GetCreateEnumStmt() != nil:
return nameIdent(node.GetCreateEnumStmt().GetTypeName())
case node.GetAlterEnumStmt() != nil:
return nameIdent(node.GetAlterEnumStmt().GetTypeName())
case node.GetCreateDomainStmt() != nil:
return nameIdent(node.GetCreateDomainStmt().GetDomainname())
case node.GetAlterDomainStmt() != nil:
return nameIdent(node.GetAlterDomainStmt().GetTypeName())
case node.GetCreateFunctionStmt() != nil:
return toposort.RoutineNode(nameIdent(node.GetCreateFunctionStmt().GetFuncname()))
case node.GetAlterTableStmt() != nil:
// ALTER TABLE, and the ALTER on a view, a materialized view, a
// sequence or a composite type, all arrive here.
return rangeVarIdent(node.GetAlterTableStmt().GetRelation())
case node.GetRenameStmt() != nil:
return renameObjectName(node.GetRenameStmt())
case node.GetDropStmt() != nil:
return dropObjectName(node.GetDropStmt())
case node.GetCommentStmt() != nil:
return commentObjectName(node.GetCommentStmt())
}
return ""
}
// renameObjectName names the object a RENAME belongs to. A rename on a
// relation, and one on a trigger, a policy, a column or a constraint, carries
// the relation; a rename on a type or a domain carries the name alone.
func renameObjectName(rs *pg_query.RenameStmt) string {
if rs.GetRenameType() == pg_query.ObjectType_OBJECT_INDEX {
return ""
}
if rs.GetRelation() != nil {
return rangeVarIdent(rs.GetRelation())
}
return objectIdent(rs.GetObject())
}
// dropObjectName names the object a DROP belongs to.
func dropObjectName(ds *pg_query.DropStmt) string {
objects := ds.GetObjects()
if len(objects) == 0 {
return ""
}
switch ds.GetRemoveType() {
case pg_query.ObjectType_OBJECT_INDEX:
return ""
case pg_query.ObjectType_OBJECT_FUNCTION, pg_query.ObjectType_OBJECT_PROCEDURE:
return toposort.RoutineNode(objectIdent(objects[0]))
case pg_query.ObjectType_OBJECT_POLICY, pg_query.ObjectType_OBJECT_TRIGGER:
// DROP POLICY p ON t names the policy after the relation.
return ownerIdent(objects[0])
default:
return objectIdent(objects[0])
}
}
// commentObjectName names the object a COMMENT belongs to.
func commentObjectName(cs *pg_query.CommentStmt) string {
switch cs.GetObjtype() {
case pg_query.ObjectType_OBJECT_COLUMN,
pg_query.ObjectType_OBJECT_TABCONSTRAINT,
pg_query.ObjectType_OBJECT_TRIGGER,
pg_query.ObjectType_OBJECT_POLICY:
// COMMENT ON COLUMN names the column after the table or the
// composite type that holds it, and the comment on a constraint, a
// trigger or a policy names the object after its relation.
return ownerIdent(cs.GetObject())
case pg_query.ObjectType_OBJECT_DOMCONSTRAINT:
// The domain arrives as a type name ahead of the constraint's name.
return objectIdent(cs.GetObject().GetList().GetItems()[0])
case pg_query.ObjectType_OBJECT_FUNCTION, pg_query.ObjectType_OBJECT_PROCEDURE:
return toposort.RoutineNode(objectIdent(cs.GetObject()))
default:
return objectIdent(cs.GetObject())
}
}
// objectIdent names the object a DROP, a COMMENT or a RENAME carries. A type
// arrives as a type name, a routine as a name with an argument list, and
// everything else as a list of name parts.
func objectIdent(node *pg_query.Node) string {
switch {
case node.GetTypeName() != nil:
return nameIdent(node.GetTypeName().GetNames())
case node.GetObjectWithArgs() != nil:
return nameIdent(node.GetObjectWithArgs().GetObjname())
case node.GetList() != nil:
return nameIdent(node.GetList().GetItems())
}
return ""
}
// ownerIdent names what holds the object, by dropping the last part of a name
// that ends in the held object's own: a column after its table, a policy
// after the relation it is on.
func ownerIdent(node *pg_query.Node) string {
items := node.GetList().GetItems()
if len(items) < 2 {
return ""
}
return nameIdent(items[:len(items)-1])
}
// rangeVarIdent names a relation the way model.Ident names it.
func rangeVarIdent(rv *pg_query.RangeVar) string {
return model.Ident(rv.GetSchemaname(), rv.GetRelname())
}
// nameIdent joins the parts a qualified name arrives in, each a string node,
// into one model.Ident name.
func nameIdent(names []*pg_query.Node) string {
parts := make([]string, 0, len(names))
for _, n := range names {
parts = append(parts, n.GetString_().GetSval())
}
return model.Ident(parts...)
}
// invalidIndexWarnings returns a warning for each index of the current side
// that is invalid. Such an index matches its definition, so the diff leaves it
// alone, but it is not used. A build that failed leaves it behind, and it has
// to be dropped by hand.
func invalidIndexWarnings(tables *orderedmap.Map[string, *model.Table], views *orderedmap.Map[string, *model.View]) []string {
var warnings []string
add := func(indexes *orderedmap.Map[string, *model.Index]) {
for _, idx := range indexes.CollectValues() {
if idx.Invalid {
warnings = append(warnings, "-- Warning: index "+model.Ident(idx.Schema, idx.Name)+" is invalid")
}
}
}
for _, t := range tables.CollectValues() {
add(t.Indexes)
}
for _, v := range views.CollectValues() {
add(v.Indexes)
}
return warnings
}
// Package document builds the JSON document that pista parse and pista dump
// --json write. Each type embeds a model type and adds fields that repeat part
// of another field in a form that needs no SQL parsing.
//
// The fields live here and not in the model. The model is what plan and apply
// read, and the state hash that a plan file records is taken over the model as
// JSON, so a field added to it would change the hash of an unchanged database.
package document
import (
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
// Document is the whole JSON document. Tables and Views replace the fields of
// the same name in ParseResult.
type Document struct {
*parser.ParseResult
Tables *orderedmap.Map[string, *Table] `json:"tables"`
Views *orderedmap.Map[string, *View] `json:"views"`
}
// Table is a table with its columns, constraints, indexes and foreign keys
// replaced by the types of this package. Columns is an array in the column
// order, as the model writes it.
type Table struct {
*model.Table
Columns []*Column `json:"columns"`
Constraints *orderedmap.Map[string, *Constraint] `json:"constraints"`
Indexes *orderedmap.Map[string, *Index] `json:"indexes"`
ForeignKeys *orderedmap.Map[string, *ForeignKey] `json:"foreign_keys"`
}
// View is a view with its indexes replaced by the type of this package.
type View struct {
*model.View
Indexes *orderedmap.Map[string, *Index] `json:"indexes"`
}
// Column adds BaseType, the column type without its modifier and array
// marker, with a domain replaced by the type it is based on and a serial type
// by its integer type. IsArray reports an array type.
type Column struct {
*model.Column
BaseType string `json:"base_type"`
IsArray bool `json:"is_array"`
}
// Constraint adds AutoNamed, which reports a constraint that the files
// declare without a name. The catalog does not record it, so it is false in a
// document built from the catalog.
type Constraint struct {
*model.Constraint
AutoNamed bool `json:"auto_named"`
}
// Index adds the parts of the index definition. Columns holds the key column
// names, with nil for an expression. Keys holds each key element as SQL, with
// its expression, collation, operator class and sort order. Partial reports a
// WHERE clause, and Where holds its condition. AutoNamed is as in Constraint.
type Index struct {
*model.Index
Columns []*string `json:"columns"`
Keys []string `json:"keys"`
Include []string `json:"include"`
Unique bool `json:"unique"`
Method string `json:"method"`
Partial bool `json:"partial"`
Where *string `json:"where"`
AutoNamed bool `json:"auto_named"`
}
// ForeignKey adds the parts of the key definition. OnDelete and OnUpdate hold
// the action in lower case, "no action" when the key specifies none. Match is
// simple or full. AutoNamed is as in Constraint.
type ForeignKey struct {
*model.ForeignKey
RefColumns []string `json:"ref_columns"`
OnDelete string `json:"on_delete"`
OnUpdate string `json:"on_update"`
Match string `json:"match"`
AutoNamed bool `json:"auto_named"`
}
// New builds the document for a parse result. Apart from AutoNamed, it reads
// only the model, so a document built from the catalog gets the same values
// as one built from files.
//
// An unqualified domain name is looked up in schemas and then in public, the
// search path that plan, apply and dump set.
//
// A definition that pg_query cannot read, such as a PostgreSQL 18 foreign key
// with NOT ENFORCED, leaves the added fields of that index or key empty rather
// than failing the document.
func New(r *parser.ParseResult, schemas []string) *Document {
if !slices.Contains(schemas, "public") {
schemas = append(slices.Clip(schemas), "public")
}
domains := r.Domains
if domains == nil {
domains = orderedmap.New[string, *model.Domain]()
}
doc := &Document{ParseResult: r}
if r.Tables != nil {
doc.Tables = orderedmap.New[string, *Table]()
for key, t := range r.Tables.All() {
doc.Tables.Set(key, newTable(t, domains, schemas, r.AutoNamed))
}
}
if r.Views != nil {
doc.Views = orderedmap.New[string, *View]()
for key, v := range r.Views.All() {
doc.Views.Set(key, &View{View: v, Indexes: newIndexes(v.Indexes, key, r.AutoNamed)})
}
}
return doc
}
func newTable(t *model.Table, domains *orderedmap.Map[string, *model.Domain], schemas []string, autoNamed map[parser.AutoNamedObject]bool) *Table {
fqtn := t.FQTN()
table := &Table{Table: t, Indexes: newIndexes(t.Indexes, fqtn, autoNamed)}
if t.Constraints != nil {
table.Constraints = orderedmap.New[string, *Constraint]()
for key, c := range t.Constraints.All() {
table.Constraints.Set(key, &Constraint{Constraint: c, AutoNamed: autoNamed[parser.AutoNamedObject{Table: fqtn, Name: c.Name}]})
}
}
if t.Columns != nil {
table.Columns = []*Column{}
for col := range t.Columns.Values() {
c := &Column{Column: col}
c.BaseType, c.IsArray = baseType(col.TypeName, domains, schemas)
table.Columns = append(table.Columns, c)
}
}
if t.ForeignKeys != nil {
table.ForeignKeys = orderedmap.New[string, *ForeignKey]()
for key, fk := range t.ForeignKeys.All() {
f := newForeignKey(fk)
f.AutoNamed = autoNamed[parser.AutoNamedObject{Table: fqtn, Name: fk.Name}]
table.ForeignKeys.Set(key, f)
}
}
return table
}
// baseType returns the type name without its modifier and array marker, with
// a domain replaced by the type it is based on and a serial type by its
// integer type. It also reports whether the type is an array.
func baseType(typeName string, domains *orderedmap.Map[string, *model.Domain], schemas []string) (string, bool) {
isArray := false
seen := map[string]bool{}
for {
name, array := stripTypeSuffixes(typeName)
isArray = isArray || array
switch name {
case "smallserial":
return "smallint", isArray
case "serial":
return "integer", isArray
case "bigserial":
return "bigint", isArray
}
d := lookupDomain(name, domains, schemas)
if d == nil || seen[name] {
return name, isArray
}
seen[name] = true
typeName = d.BaseType
}
}
// stripTypeSuffixes removes the modifiers and the array marker from a type
// name, and reports whether there was an array marker. Text inside double
// quotes is kept. "timestamp(3) without time zone" becomes
// "timestamp without time zone".
func stripTypeSuffixes(typeName string) (string, bool) {
var b strings.Builder
quoted := false
depth := 0
for i := 0; i < len(typeName); i++ {
c := typeName[i]
switch {
case c == '"' && depth == 0:
quoted = !quoted
b.WriteByte(c)
case quoted:
b.WriteByte(c)
case c == '(':
depth++
case c == ')' && depth > 0:
depth--
case depth > 0:
case c == '[':
return strings.TrimSpace(b.String()), true
default:
b.WriteByte(c)
}
}
return strings.TrimSpace(b.String()), false
}
// lookupDomain returns the domain that a type name refers to, or nil. An
// unqualified name is looked up in each schema in turn.
func lookupDomain(name string, domains *orderedmap.Map[string, *model.Domain], schemas []string) *model.Domain {
if d, ok := domains.GetOk(name); ok {
return d
}
for _, s := range schemas {
if d, ok := domains.GetOk(model.Ident(s) + "." + name); ok {
return d
}
}
return nil
}
// newIndexes wraps the indexes of the table or materialized view fqtn.
func newIndexes(indexes *orderedmap.Map[string, *model.Index], fqtn string, autoNamed map[parser.AutoNamedObject]bool) *orderedmap.Map[string, *Index] {
if indexes == nil {
return nil
}
m := orderedmap.New[string, *Index]()
for key, idx := range indexes.All() {
index := newIndex(idx)
index.AutoNamed = autoNamed[parser.AutoNamedObject{Index: true, Table: fqtn, Name: idx.Name}]
m.Set(key, index)
}
return m
}
func newIndex(idx *model.Index) *Index {
index := &Index{Index: idx, Columns: []*string{}, Keys: []string{}, Include: []string{}}
result, err := pg_query.Parse(idx.Definition)
if err != nil || len(result.Stmts) != 1 {
return index
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return index
}
for _, p := range is.IndexParams {
var name *string
if ie := p.GetIndexElem(); ie != nil && ie.Name != "" {
name = &ie.Name
}
index.Columns = append(index.Columns, name)
index.Keys = append(index.Keys, deparseIndexElem(result.Version, is.AccessMethod, p))
}
for _, p := range is.IndexIncludingParams {
if ie := p.GetIndexElem(); ie != nil {
index.Include = append(index.Include, ie.Name)
}
}
index.Unique = is.Unique
index.Method = is.AccessMethod
index.Partial = is.WhereClause != nil
if is.WhereClause != nil {
where := deparseWhere(result.Version, is.WhereClause)
index.Where = &where
}
return index
}
// deparseIndexElem returns one key element as SQL. It deparses an index on
// that element alone and takes the text inside the parentheses.
func deparseIndexElem(version int32, method string, elem *pg_query.Node) string {
stmt := &pg_query.IndexStmt{
Relation: &pg_query.RangeVar{Relname: "t", Inh: true},
AccessMethod: method,
IndexParams: []*pg_query.Node{elem},
}
sql := deparse(version, &pg_query.Node{Node: &pg_query.Node_IndexStmt{IndexStmt: stmt}})
_, after, _ := strings.Cut(sql, " (")
return strings.TrimSuffix(after, ")")
}
// deparseWhere returns the condition of a WHERE clause as SQL.
func deparseWhere(version int32, where *pg_query.Node) string {
stmt := &pg_query.SelectStmt{WhereClause: where}
sql := deparse(version, &pg_query.Node{Node: &pg_query.Node_SelectStmt{SelectStmt: stmt}})
return strings.TrimPrefix(sql, "SELECT WHERE ")
}
// deparse writes one statement. The nodes come from a tree that pg_query has
// just read, so it does not fail.
func deparse(version int32, stmt *pg_query.Node) string {
sql, _ := pg_query.Deparse(&pg_query.ParseResult{Version: version, Stmts: []*pg_query.RawStmt{{Stmt: stmt}}})
return sql
}
var fkActions = map[string]string{
"a": "no action",
"r": "restrict",
"c": "cascade",
"n": "set null",
"d": "set default",
}
var fkMatchTypes = map[string]string{
"s": "simple",
"f": "full",
}
func newForeignKey(fk *model.ForeignKey) *ForeignKey {
key := &ForeignKey{ForeignKey: fk, RefColumns: []string{}}
// The definition is only the constraint clause, so parse it inside an
// ALTER TABLE.
result, err := pg_query.Parse("ALTER TABLE t ADD CONSTRAINT c " + fk.Definition)
if err != nil || len(result.Stmts) != 1 {
return key
}
cmds := result.Stmts[0].Stmt.GetAlterTableStmt().GetCmds()
if len(cmds) != 1 {
return key
}
con := cmds[0].GetAlterTableCmd().GetDef().GetConstraint()
if con == nil || con.Contype != pg_query.ConstrType_CONSTR_FOREIGN {
return key
}
for _, n := range con.PkAttrs {
if s := n.GetString_(); s != nil {
key.RefColumns = append(key.RefColumns, s.Sval)
}
}
key.OnDelete = fkActions[con.FkDelAction]
key.OnUpdate = fkActions[con.FkUpdAction]
key.Match = fkMatchTypes[con.FkMatchtype]
return key
}
package pistachio
import "slices"
// DropPolicy controls which object types are allowed to be dropped.
// If AllowDrop is empty, no drops are allowed (safe default).
// If AllowDrop contains "all", all drops are allowed.
// Otherwise, only the listed object types are allowed to be dropped.
type DropPolicy struct {
AllowDrop []string `enum:"all,table,view,enum,domain,composite_type,sequence,routine,column,constraint,foreign_key,index,policy,trigger" env:"PISTA_ALLOW_DROP" help:"Allow dropping these object types (repeatable; 'all' allows everything)."`
}
func (p *DropPolicy) IsDropAllowed(objectType string) bool {
if p == nil {
return false
}
return slices.Contains(p.AllowDrop, "all") || slices.Contains(p.AllowDrop, objectType)
}
// Package dsql holds what --engine dsql changes, for Amazon Aurora DSQL.
//
// The rest of pistachio stays PostgreSQL's: the current side is read and the
// diff is computed as for any server, and the functions here bring the
// catalog's values to the form the desired side uses and rewrite the
// statements the diff writes into the form DSQL runs. Each is called from a
// single branch on the engine, so the package comes out by deleting it and
// the branches the compiler then reports.
package dsql
import (
"context"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// btreeMethod is the access method DSQL reports for every index. DSQL has
// one, and the desired side calls it btree.
const btreeMethod = "btree_index"
// NormalizeCurrent brings the values DSQL reports to the form the desired
// side uses. DSQL reports every index's access method as btree_index, adds
// every non-key column to a primary key's index as INCLUDE, and stores every
// column compressed with lz4. None of them is something the schema says.
//
// A definition that does not parse is left as it is. The catalog writes
// definitions that parse, so that is not reached by a real read.
func NormalizeCurrent(tables *orderedmap.Map[string, *model.Table]) {
for _, t := range tables.CollectValues() {
for _, col := range t.Columns.CollectValues() {
col.Compression = ""
}
for _, con := range t.Constraints.CollectValues() {
if !con.Type.IsPrimaryKeyConstraint() {
continue
}
result, pk, err := pgast.ParseConstraintDefStrict(con.Definition)
if err != nil {
continue
}
pk.Including = nil
if def, err := pgast.DeparseConstraintDef(result); err == nil {
con.Definition = def
}
}
for _, idx := range t.Indexes.CollectValues() {
result, err := pg_query.Parse(idx.Definition)
if err != nil {
continue
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is.AccessMethod != btreeMethod {
continue
}
is.AccessMethod = "btree"
if def, err := pg_query.Deparse(result); err == nil {
idx.Definition = def
}
}
}
}
// Split rewrites the two changes DSQL takes only in another form. It runs
// before the statements are ordered, so the statements it adds are placed
// with the rest.
//
// A column added with a DEFAULT becomes ADD COLUMN and SET DEFAULT: DSQL
// rejects ADD COLUMN with a constraint. The rows already in the table keep a
// NULL, since DSQL has no DDL that fills them.
//
// A unique constraint added to a table becomes CREATE UNIQUE INDEX and ADD
// CONSTRAINT ... USING INDEX: DSQL takes no other ADD CONSTRAINT. Exec waits
// for the index between the two.
//
// A statement that does not parse is left as it is, for Finish to report.
func Split(stmts []string) []string {
var out []string
for _, stmt := range stmts {
result, err := pg_query.Parse(stmt)
if err != nil {
out = append(out, stmt)
continue
}
as := result.Stmts[0].Stmt.GetAlterTableStmt()
if as == nil || len(as.Cmds) != 1 {
out = append(out, stmt)
continue
}
cmd := as.Cmds[0].GetAlterTableCmd()
table := relationName(as.Relation)
switch cmd.Subtype {
case pg_query.AlterTableType_AT_AddColumn:
split, ok := splitAddColumnDefault(stmt, table, cmd.Def.GetColumnDef())
if !ok {
out = append(out, stmt)
continue
}
out = append(out, split...)
case pg_query.AlterTableType_AT_AddConstraint:
con := cmd.Def.GetConstraint()
if con.Contype != pg_query.ConstrType_CONSTR_UNIQUE || con.Indexname != "" {
out = append(out, stmt)
continue
}
out = append(out, splitAddUnique(as.Relation, con)...)
default:
out = append(out, stmt)
}
}
return out
}
// splitAddColumnDefault cuts the DEFAULT out of an ADD COLUMN. A column that
// is also NOT NULL is left for Finish to refuse, and so is one with no
// DEFAULT. The DEFAULT is the last clause of the column the diff writes when
// there is no NOT NULL after it, so it runs to the end of the statement.
func splitAddColumnDefault(stmt, table string, col *pg_query.ColumnDef) ([]string, bool) {
var def *pg_query.Constraint
for _, c := range col.Constraints {
con := c.GetConstraint()
switch con.Contype {
case pg_query.ConstrType_CONSTR_DEFAULT:
def = con
case pg_query.ConstrType_CONSTR_NOTNULL:
return nil, false
}
}
if def == nil {
return nil, false
}
body := strings.TrimSuffix(strings.TrimRight(stmt, " \n"), ";")
expr := strings.TrimSpace(strings.TrimPrefix(body[def.Location:], "DEFAULT"))
add := strings.TrimRight(body[:def.Location], " ") + ";"
setDefault := "ALTER TABLE " + table + " ALTER COLUMN " + model.Ident(col.Colname) + " SET DEFAULT " + expr + ";"
return []string{add, setDefault}, true
}
// splitAddUnique writes the unique index and the constraint that takes it
// over, both under the constraint's name.
func splitAddUnique(rel *pg_query.RangeVar, con *pg_query.Constraint) []string {
params := make([]*pg_query.Node, 0, len(con.Keys))
for _, k := range con.Keys {
params = append(params, indexElem(k.GetString_().Sval))
}
including := make([]*pg_query.Node, 0, len(con.Including))
for _, k := range con.Including {
including = append(including, indexElem(k.GetString_().Sval))
}
index := deparse(&pg_query.Node{Node: &pg_query.Node_IndexStmt{IndexStmt: &pg_query.IndexStmt{
Idxname: con.Conname,
Relation: rel,
IndexParams: params,
IndexIncludingParams: including,
Options: con.Options,
Unique: true,
NullsNotDistinct: con.NullsNotDistinct,
}}})
constraint := deparse(&pg_query.Node{Node: &pg_query.Node_AlterTableStmt{AlterTableStmt: &pg_query.AlterTableStmt{
Relation: rel,
Objtype: pg_query.ObjectType_OBJECT_TABLE,
Cmds: []*pg_query.Node{{Node: &pg_query.Node_AlterTableCmd{AlterTableCmd: &pg_query.AlterTableCmd{
Subtype: pg_query.AlterTableType_AT_AddConstraint,
Def: &pg_query.Node{Node: &pg_query.Node_Constraint{Constraint: &pg_query.Constraint{
Contype: pg_query.ConstrType_CONSTR_UNIQUE,
Conname: con.Conname,
Indexname: con.Conname,
Deferrable: con.Deferrable,
Initdeferred: con.Initdeferred,
}}},
}}}},
}}})
return []string{index + ";", constraint + ";"}
}
func indexElem(name string) *pg_query.Node {
return &pg_query.Node{Node: &pg_query.Node_IndexElem{IndexElem: &pg_query.IndexElem{
Name: name,
Ordering: pg_query.SortByDir_SORTBY_DEFAULT,
NullsOrdering: pg_query.SortByNulls_SORTBY_NULLS_DEFAULT,
}}}
}
// deparse writes a statement built here. Deparse fails only on a tree it
// does not know, which these are not, so the error is not returned.
func deparse(stmt *pg_query.Node) string {
sql, _ := pg_query.Deparse(&pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{Stmt: stmt}},
})
return sql
}
// relationName writes the table a statement names. model.Ident leaves an
// empty schema out.
func relationName(rel *pg_query.RangeVar) string {
return model.Ident(rel.Schemaname, rel.Relname)
}
// Finish rewrites the ordered statements into the form DSQL runs, and
// refuses a statement DSQL has no form of. It runs on the final list rather
// than on the table diff alone: the statements of one table diff are spread
// over several lists that only the ordering joins.
//
// It refuses CONCURRENTLY, which DSQL has no form of for CREATE INDEX or DROP
// INDEX, and the column and constraint changes DSQL takes only in CREATE
// TABLE. It drops USING btree, since DSQL rejects USING and has the one
// method, and leaves another method for DSQL to refuse. It writes CACHE 1 on
// an identity column, since DSQL rejects one without a cache size.
//
// A check or foreign key added to a table becomes ADD CONSTRAINT ... NOT
// VALID and VALIDATE CONSTRAINT: DSQL takes the constraint only NOT VALID,
// and validates it only in a job. The two stay next to each other, so this
// is done here rather than in Split.
//
// ASYNC goes in last: PostgreSQL's grammar has no such word, so nothing can
// parse the statement after it.
func Finish(stmts []string) ([]string, error) {
out := make([]string, 0, len(stmts))
for _, stmt := range stmts {
result, err := pg_query.Parse(stmt)
if err != nil {
return nil, fmt.Errorf("dsql: failed to parse %q: %w", stmt, err)
}
node := result.Stmts[0].Stmt
if reason := refusal(node); reason != "" {
return nil, fmt.Errorf("--engine dsql: DSQL does not support %s: %s", reason, stmt)
}
if is := node.GetIndexStmt(); is != nil {
if is.AccessMethod == "btree" {
is.AccessMethod = ""
}
def, err := pg_query.Deparse(result)
if err != nil {
return nil, fmt.Errorf("dsql: failed to deparse %q: %w", stmt, err)
}
out = append(out, addAsync(def)+";")
continue
}
if as := node.GetAlterTableStmt(); as != nil && len(as.Cmds) == 1 {
cmd := as.Cmds[0].GetAlterTableCmd()
switch cmd.Subtype {
case pg_query.AlterTableType_AT_ValidateConstraint:
out = append(out, validateAsync(as.Relation, cmd.Name))
continue
case pg_query.AlterTableType_AT_AddConstraint:
if con := cmd.Def.GetConstraint(); needsValidation(con) {
body := strings.TrimSuffix(strings.TrimRight(stmt, " \n"), ";")
out = append(out, body+" NOT VALID;", validateAsync(as.Relation, con.Conname))
continue
}
}
}
out = append(out, AddIdentityCache(stmt))
}
return out, nil
}
// refusal names what DSQL cannot run in a statement, or returns an empty
// string.
func refusal(node *pg_query.Node) string {
if is := node.GetIndexStmt(); is != nil && is.Concurrent {
return "CREATE INDEX CONCURRENTLY"
}
if ds := node.GetDropStmt(); ds != nil && ds.Concurrent {
return "DROP INDEX CONCURRENTLY"
}
as := node.GetAlterTableStmt()
if as == nil {
return ""
}
for _, c := range as.Cmds {
cmd := c.GetAlterTableCmd()
switch cmd.Subtype {
case pg_query.AlterTableType_AT_SetNotNull:
return "SET NOT NULL"
case pg_query.AlterTableType_AT_AlterColumnType:
return "a column type change"
case pg_query.AlterTableType_AT_AddColumn:
for _, n := range cmd.Def.GetColumnDef().Constraints {
if n.GetConstraint().Contype == pg_query.ConstrType_CONSTR_NOTNULL {
return "ADD COLUMN with NOT NULL"
}
}
case pg_query.AlterTableType_AT_AddConstraint:
if cmd.Def.GetConstraint().Contype == pg_query.ConstrType_CONSTR_PRIMARY {
return "ADD CONSTRAINT ... PRIMARY KEY"
}
}
}
return ""
}
// needsValidation reports a check or foreign key added validated, which DSQL
// adds only NOT VALID.
func needsValidation(con *pg_query.Constraint) bool {
switch con.Contype {
case pg_query.ConstrType_CONSTR_CHECK, pg_query.ConstrType_CONSTR_FOREIGN:
return !con.SkipValidation
}
return false
}
// validateAsync writes the VALIDATE CONSTRAINT that DSQL runs as a job.
func validateAsync(rel *pg_query.RangeVar, name string) string {
return "ALTER TABLE ASYNC " + relationName(rel) + " VALIDATE CONSTRAINT " + model.Ident(name) + ";"
}
// addAsync writes ASYNC after CREATE [UNIQUE] INDEX. The statement is one
// deparse wrote, so it starts with those words.
func addAsync(def string) string {
return strings.Replace(def, " INDEX ", " INDEX ASYNC ", 1)
}
// AddIdentityCache writes CACHE 1 on each identity column that names no
// cache size. The model leaves a cache of 1 out as the default, and DSQL
// rejects an identity column without one. sql may hold several statements
// and comments, as a dump does; the text is changed where the identity
// clause sits and nowhere else, so the layout stays as it was. SQL that does
// not parse is returned as it is.
func AddIdentityCache(sql string) string {
result, err := pg_query.Parse(sql)
if err != nil {
return sql
}
var locations []int32
for _, raw := range result.Stmts {
for _, con := range identityConstraints(raw.Stmt) {
hasCache := slices.ContainsFunc(con.Options, func(n *pg_query.Node) bool {
return n.GetDefElem().GetDefname() == "cache"
})
if !hasCache {
locations = append(locations, con.Location)
}
}
}
// From the end, so an insertion does not move the ones before it.
slices.Sort(locations)
for _, loc := range slices.Backward(locations) {
sql = insertCache(sql, int(loc))
}
return sql
}
// identityConstraints returns the identity clauses of a statement: those of
// the columns in CREATE TABLE and ADD COLUMN, and ADD GENERATED.
func identityConstraints(node *pg_query.Node) []*pg_query.Constraint {
var cols []*pg_query.ColumnDef
var cons []*pg_query.Constraint
if cs := node.GetCreateStmt(); cs != nil {
for _, elt := range cs.TableElts {
if col := elt.GetColumnDef(); col != nil {
cols = append(cols, col)
}
}
}
if as := node.GetAlterTableStmt(); as != nil {
for _, c := range as.Cmds {
cmd := c.GetAlterTableCmd()
switch cmd.Subtype {
case pg_query.AlterTableType_AT_AddColumn:
cols = append(cols, cmd.Def.GetColumnDef())
case pg_query.AlterTableType_AT_AddIdentity:
cons = append(cons, cmd.Def.GetConstraint())
}
}
}
for _, col := range cols {
for _, n := range col.Constraints {
if con := n.GetConstraint(); con.Contype == pg_query.ConstrType_CONSTR_IDENTITY {
cons = append(cons, con)
}
}
}
return cons
}
// insertCache adds CACHE 1 to the identity clause that starts at loc: inside
// its parenthesized options when it has them, as the options otherwise.
func insertCache(sql string, loc int) string {
const word = "IDENTITY"
end := loc + strings.Index(sql[loc:], word) + len(word)
rest := sql[end:]
if strings.HasPrefix(rest, " (") {
close := end + strings.Index(rest, ")")
return sql[:close] + " CACHE 1" + sql[close:]
}
return sql[:end] + " (CACHE 1)" + rest
}
// UnwaitedUniqueIndex reports a unique constraint that takes over an index
// the same statements build with ASYNC. Without the wait the index is not
// valid yet when the constraint takes it over, so --dsql-no-wait-job
// cannot run them. An index that already exists needs no wait. A statement
// that does not parse has nothing to report.
func UnwaitedUniqueIndex(stmts []string) (string, bool) {
built := map[string]bool{}
for _, stmt := range stmts {
result, err := pg_query.Parse(strings.Replace(stmt, " INDEX ASYNC ", " INDEX ", 1))
if err != nil {
continue
}
if is := result.Stmts[0].Stmt.GetIndexStmt(); is != nil {
built[is.Idxname] = is.Unique
continue
}
as := result.Stmts[0].Stmt.GetAlterTableStmt()
if as == nil {
continue
}
for _, c := range as.Cmds {
con := c.GetAlterTableCmd().GetDef().GetConstraint()
if con != nil && built[con.Indexname] {
return con.Indexname, true
}
}
}
return "", false
}
// Conn is the part of a connection Exec uses.
type Conn interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// Exec runs one statement. A job is waited for: CREATE INDEX ASYNC and ALTER
// TABLE ASYNC ... VALIDATE CONSTRAINT return the job they start, and
// sys.wait_for_job returns whether the job succeeded. A build that fails
// leaves the index invalid, and a validation that fails leaves the
// constraint NOT VALID, so the apply stops there.
func Exec(ctx context.Context, conn Conn, stmt string) error {
if !isAsync(stmt) {
_, err := conn.Exec(ctx, stmt)
return err
}
var jobID string
if err := conn.QueryRow(ctx, stmt).Scan(&jobID); err != nil {
return err
}
var succeeded bool
if err := conn.QueryRow(ctx, "CALL sys.wait_for_job("+model.QuoteLiteral(jobID)+")").Scan(&succeeded); err != nil {
return fmt.Errorf("failed to wait for job %s: %w", jobID, err)
}
if !succeeded {
return fmt.Errorf("job %s failed: %s", jobID, stmt)
}
return nil
}
// isAsync reports a statement Finish wrote ASYNC in.
func isAsync(stmt string) bool {
return slices.ContainsFunc([]string{"CREATE INDEX ASYNC ", "CREATE UNIQUE INDEX ASYNC ", "ALTER TABLE ASYNC "}, func(prefix string) bool {
return strings.HasPrefix(stmt, prefix)
})
}
// StripAsync blanks out the ASYNC of each CREATE [UNIQUE] INDEX ASYNC, for a
// schema written for DSQL: PostgreSQL's grammar has no such word, so nothing
// can parse the statement with it. Only an unquoted async right after INDEX
// goes, which is how DSQL reads it too: it takes CREATE INDEX async ON t as
// an index with no name, and an index named async has to be quoted. The word
// is replaced with spaces of the same length, so a position in an error
// still points at the right place. SQL that does not scan is returned as it
// is, for the parser to report.
func StripAsync(sql string) string {
result, err := pg_query.Scan(sql)
if err != nil {
return sql
}
var code []*pg_query.ScanToken
for _, t := range result.Tokens {
if t.Token != pg_query.Token_SQL_COMMENT && t.Token != pg_query.Token_C_COMMENT {
code = append(code, t)
}
}
b := []byte(sql)
for i, t := range code {
if t.Token != pg_query.Token_IDENT || i < 2 || code[i-1].Token != pg_query.Token_INDEX {
continue
}
create := code[i-2]
if create.Token == pg_query.Token_UNIQUE && i >= 3 {
create = code[i-3]
}
if create.Token != pg_query.Token_CREATE || !strings.EqualFold(sql[t.Start:t.End], "async") {
continue
}
for j := t.Start; j < t.End; j++ {
b[j] = ' '
}
}
return string(b)
}
package pistachio
import (
"errors"
"fmt"
"github.com/winebarrel/pistachio/dsql"
"github.com/winebarrel/pistachio/parser"
)
// Engine is the server a run targets. postgres is PostgreSQL itself. dsql is
// Amazon Aurora DSQL: the current side is read the same way and the diff is
// PostgreSQL's, and the dsql package brings the two to the form DSQL takes.
type Engine string
const (
EnginePostgres Engine = "postgres"
EngineDSQL Engine = "dsql"
)
// isDSQL is the one place the engine is compared, so a caller never spells
// the value. An empty engine is postgres: a library caller that builds the
// options by hand, and a plan file written before the engine was recorded.
func (e Engine) isDSQL() bool {
return e == EngineDSQL
}
// validate refuses an engine the CLI would not have accepted. Only a plan file
// edited by hand reaches it.
func (e Engine) validate() error {
switch e {
case "", EnginePostgres, EngineDSQL:
return nil
}
return fmt.Errorf("unknown engine %q", e)
}
// dsqlRefused is an option DSQL has no use for, and whether it was given.
type dsqlRefused struct {
flag string
set bool
}
// refuseWithDSQL returns an error naming the first option given under
// --engine dsql. kong's xor tag cannot say this: it looks at whether a flag
// was given, not at its value, so it would refuse --engine postgres too.
func refuseWithDSQL(engine Engine, opts ...dsqlRefused) error {
if !engine.isDSQL() {
return nil
}
for _, o := range opts {
if o.set {
return fmt.Errorf("--%s cannot be used with --engine dsql", o.flag)
}
}
return nil
}
// planRefused lists the plan options DSQL has no use for. --explain reads the
// size estimates in pg_class, which DSQL does not keep. --bulk-alter saves the
// lock and the rewrite a combined ALTER TABLE spares, and DSQL has neither.
// DSQL has no CONCURRENTLY.
func planRefused(o *PlanOptions) []dsqlRefused {
return []dsqlRefused{
{"explain", o.Explain},
{"bulk-alter", o.BulkAlter},
{"force-index-concurrently", o.ForceIndexConcurrently},
{"disable-index-concurrently", o.DisableIndexConcurrently},
{"concurrently-pre-sql", o.ConcurrentlyPreSQL != ""},
{"concurrently-pre-sql-file", o.ConcurrentlyPreSQLFile != ""},
}
}
// execRefused lists the ExecOptions DSQL cannot run. DSQL takes one DDL
// statement per transaction and has no advisory lock. It refuses SET
// lock_timeout and SET statement_timeout, and with no locks there is no lock
// timeout to retry. --lock-retry-delay has a default, so it is always set and
// cannot be told apart from one given; without --lock-retries it does nothing.
func execRefused(o *ExecOptions) []dsqlRefused {
return []dsqlRefused{
{"with-tx", o.WithTx},
{"try-tx", o.TryTx},
{"exclusive", o.Exclusive},
{"exclusive-wait", o.ExclusiveWait != nil},
{"lock-timeout", o.LockTimeout != nil},
{"lock-retries", o.LockRetries > 0},
{"statement-timeout", o.StatementTimeout != nil},
}
}
// requireDSQL refuses --dsql-no-wait-job without --engine dsql. The
// flag is DSQL's, and does nothing anywhere else.
func requireDSQL(engine Engine, o *ExecOptions) error {
if o.DSQLNoWaitJob && !engine.isDSQL() {
return errors.New("--dsql-no-wait-job requires --engine dsql")
}
return nil
}
// ValidatePlanEngine refuses the plan options --engine dsql cannot use.
func ValidatePlanEngine(engine Engine, o *PlanOptions) error {
return refuseWithDSQL(engine, planRefused(o)...)
}
// ValidateApplyEngine refuses the apply options --engine dsql cannot use.
func ValidateApplyEngine(engine Engine, o *ApplyOptions) error {
if err := requireDSQL(engine, &o.ExecOptions); err != nil {
return err
}
return refuseWithDSQL(engine, append(planRefused(&PlanOptions{
BulkAlter: o.BulkAlter,
ForceIndexConcurrently: o.ForceIndexConcurrently,
DisableIndexConcurrently: o.DisableIndexConcurrently,
ConcurrentlyPreSQL: o.ConcurrentlyPreSQL,
ConcurrentlyPreSQLFile: o.ConcurrentlyPreSQLFile,
}), execRefused(&o.ExecOptions)...)...)
}
// ValidateApplyFromEngine refuses the apply-from options the plan file's
// engine cannot use. The engine is the plan file's, so the file is read here
// as well as where it is applied.
func ValidateApplyFromEngine(o *ApplyFromOptions) error {
plan, err := readPlanFile(o.PlanFile)
if err != nil {
return err
}
engine := plan.Scope.Engine
if err := engine.validate(); err != nil {
return fmt.Errorf("plan file %s: %w", o.PlanFile, err)
}
if err := requireDSQL(engine, &o.ExecOptions); err != nil {
return err
}
return refuseWithDSQL(engine, execRefused(&o.ExecOptions)...)
}
// ValidateDumpEngine refuses the dump options --engine dsql cannot use.
func ValidateDumpEngine(engine Engine, o *DumpOptions) error {
return refuseWithDSQL(engine, dsqlRefused{"explain", o.Explain})
}
// parseStrippingAsync parses the desired schema files with the ASYNC of each
// CREATE INDEX ASYNC blanked out. ASYNC is how DSQL builds an index, not part
// of its definition, and the plan adds it to every index it creates.
func parseStrippingAsync(files []string, defaultSchema string) (*parser.ParseResult, error) {
sources, err := parser.ReadSQLFiles(files)
if err != nil {
return nil, err
}
for i := range sources {
sources[i].SQL = dsql.StripAsync(sources[i].SQL)
}
return parser.ParseSQLSourcesWithSchema(sources, defaultSchema)
}
package pistachio
import (
"context"
"errors"
"fmt"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/diff"
"github.com/winebarrel/pistachio/dsql"
"github.com/winebarrel/pistachio/format"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
type DumpOptions struct {
Split string `type:"path" xor:"json-split" help:"Output each table/view/enum/domain/composite type/sequence/routine as a separate file in the specified directory."`
OmitSchema bool `help:"Omit schema name from the dump output."`
NoReadOnly bool `env:"PISTA_NO_READ_ONLY" help:"Open the database connection read-write. By default dump uses a read-only connection."`
NoFormat bool `xor:"json-no-format" env:"PISTA_NO_FORMAT" help:"Write the dump as the model renders it, without the layout pista fmt applies."`
// JSON writes the dump as JSON rather than SQL, in the shape `pista parse`
// writes, so the same schema describes both. The flags that lay SQL out have nothing to
// change in it, so kong refuses them alongside it.
JSON bool `xor:"json-split,json-no-format,json-explain" env:"PISTA_DUMP_JSON" help:"Write the dump as JSON instead of SQL."`
// Explain writes the size estimate of each table, materialized view and
// index into the comment above it. The JSON carries no comment to hold it.
Explain bool `xor:"json-explain" env:"PISTA_DUMP_EXPLAIN" help:"Comment each table, materialized view and index with its size estimate from pg_class."`
OmitPartitionChildIndex bool `xor:"partition-child" env:"PISTA_DUMP_OMIT_PARTITION_CHILD_INDEX" help:"Omit a partition's copy of its parent's index. PostgreSQL creates it again when the dump is loaded."`
}
type DumpResult struct {
Tables *orderedmap.Map[string, *model.Table]
Views *orderedmap.Map[string, *model.View]
Enums *orderedmap.Map[string, *model.Enum]
Domains *orderedmap.Map[string, *model.Domain]
CompositeTypes *orderedmap.Map[string, *model.CompositeType]
Sequences *orderedmap.Map[string, *model.Sequence]
Routines *orderedmap.Map[string, *model.Routine]
OmitSchema bool
NoFormat bool
// DSQL writes CACHE 1 on an identity column, which DSQL requires and the
// model leaves out as the default.
DSQL bool
Count ObjectCount
}
// tableSQL adds what --engine dsql needs to the rendered tables.
func (r *DumpResult) tableSQL(sql string) string {
if !r.DSQL {
return sql
}
return dsql.AddIdentityCache(sql)
}
// stripRelationSchemaPrefix removes the schema qualification from the relation
// an index or a trigger targets, for --omit-schema. Both definitions read
// "... ON <schema>.<rel> ...".
func stripRelationSchemaPrefix(definition, fqrn, relName string) string {
return strings.ReplaceAll(definition, " ON "+fqrn+" ", " ON "+relName+" ")
}
func (r *DumpResult) tables() *orderedmap.Map[string, *model.Table] {
if r.Tables == nil {
return orderedmap.New[string, *model.Table]()
}
if !r.OmitSchema {
return r.Tables
}
tables := orderedmap.New[string, *model.Table]()
for _, t := range r.Tables.CollectValues() {
copied := *t
fqtn := t.FQTN()
tableName := model.Ident(t.Name)
copied.Schema = ""
// Strip the schema only when the parent is in the one being dumped.
// --omit-schema allows a single schema, but the parent may sit outside
// it, and such a reference has to keep its schema to resolve.
if copied.PartitionOf != nil {
parent := strings.TrimPrefix(*copied.PartitionOf, model.Ident(t.Schema)+".")
copied.PartitionOf = &parent
}
if copied.ForeignKeys.Len() > 0 {
fks := orderedmap.New[string, *model.ForeignKey]()
for _, fk := range copied.ForeignKeys.CollectValues() {
fkCopied := *fk
fkCopied.Schema = ""
fks.Set(fk.Name, &fkCopied)
}
copied.ForeignKeys = fks
}
if copied.Indexes.Len() > 0 {
idxs := orderedmap.New[string, *model.Index]()
for _, idx := range copied.Indexes.CollectValues() {
idxCopied := *idx
idxCopied.Schema = ""
idxCopied.Definition = stripRelationSchemaPrefix(idx.Definition, fqtn, tableName)
idxs.Set(idx.Name, &idxCopied)
}
copied.Indexes = idxs
}
if copied.Policies != nil && copied.Policies.Len() > 0 {
policies := orderedmap.New[string, *model.Policy]()
for _, p := range copied.Policies.CollectValues() {
pCopied := *p
pCopied.Schema = ""
policies.Set(p.Name, &pCopied)
}
copied.Policies = policies
}
copied.Triggers = stripTriggerSchemas(copied.Triggers, fqtn, tableName)
tables.Set(tableName, &copied)
}
return tables
}
func (r *DumpResult) views() *orderedmap.Map[string, *model.View] {
if r.Views == nil {
return orderedmap.New[string, *model.View]()
}
if !r.OmitSchema {
return r.Views
}
views := orderedmap.New[string, *model.View]()
for _, v := range r.Views.CollectValues() {
copied := *v
fqvn := v.FQVN()
viewName := model.Ident(v.Name)
copied.Schema = ""
if copied.Indexes != nil && copied.Indexes.Len() > 0 {
idxs := orderedmap.New[string, *model.Index]()
for _, idx := range copied.Indexes.CollectValues() {
idxCopied := *idx
idxCopied.Schema = ""
idxCopied.Definition = stripRelationSchemaPrefix(idx.Definition, fqvn, viewName)
idxs.Set(idx.Name, &idxCopied)
}
copied.Indexes = idxs
}
copied.Triggers = stripTriggerSchemas(copied.Triggers, fqvn, viewName)
views.Set(viewName, &copied)
}
return views
}
// stripTriggerSchemas drops the schema from a relation's triggers, for
// --omit-schema. The trigger keeps whatever schema the definition gives the
// function it calls, which may sit outside the schema being dumped.
func stripTriggerSchemas(
triggers *orderedmap.Map[string, *model.Trigger],
fqrn, relName string,
) *orderedmap.Map[string, *model.Trigger] {
if triggers == nil || triggers.Len() == 0 {
return triggers
}
out := orderedmap.New[string, *model.Trigger]()
for _, trg := range triggers.CollectValues() {
copied := *trg
copied.Schema = ""
copied.Definition = stripRelationSchemaPrefix(trg.Definition, fqrn, relName)
out.Set(trg.Name, &copied)
}
return out
}
func (r *DumpResult) enums() *orderedmap.Map[string, *model.Enum] {
if r.Enums == nil {
return orderedmap.New[string, *model.Enum]()
}
if !r.OmitSchema {
return r.Enums
}
enums := orderedmap.New[string, *model.Enum]()
for _, e := range r.Enums.CollectValues() {
copied := *e
copied.Schema = ""
enums.Set(model.Ident(e.Name), &copied)
}
return enums
}
func (r *DumpResult) domains() *orderedmap.Map[string, *model.Domain] {
if r.Domains == nil {
return orderedmap.New[string, *model.Domain]()
}
if !r.OmitSchema {
return r.Domains
}
domains := orderedmap.New[string, *model.Domain]()
for _, d := range r.Domains.CollectValues() {
copied := *d
copied.Schema = ""
domains.Set(model.Ident(d.Name), &copied)
}
return domains
}
func (r *DumpResult) compositeTypes() *orderedmap.Map[string, *model.CompositeType] {
if r.CompositeTypes == nil {
return orderedmap.New[string, *model.CompositeType]()
}
if !r.OmitSchema {
return r.CompositeTypes
}
compositeTypes := orderedmap.New[string, *model.CompositeType]()
for _, ct := range r.CompositeTypes.CollectValues() {
copied := *ct
copied.Schema = ""
compositeTypes.Set(model.Ident(ct.Name), &copied)
}
return compositeTypes
}
func (r *DumpResult) sequences() *orderedmap.Map[string, *model.Sequence] {
if r.Sequences == nil {
return orderedmap.New[string, *model.Sequence]()
}
if !r.OmitSchema {
return r.Sequences
}
sequences := orderedmap.New[string, *model.Sequence]()
for _, s := range r.Sequences.CollectValues() {
copied := *s
copied.Schema = ""
sequences.Set(model.Ident(s.Name), &copied)
}
return sequences
}
// routines returns the routines with the schema stripped when --omit-schema is
// set.
func (r *DumpResult) routines() *orderedmap.Map[string, *model.Routine] {
if r.Routines == nil {
return orderedmap.New[string, *model.Routine]()
}
if !r.OmitSchema {
return r.Routines
}
routines := orderedmap.New[string, *model.Routine]()
for _, rt := range r.Routines.CollectValues() {
copied := *rt
copied.Schema = ""
routines.Set(copied.FQRN(), &copied)
}
return routines
}
// Document returns what the dump holds in the shape `pista parse` writes, so
// one JSON Schema describes both. The accessors carry --omit-schema, and a
// database holds no `-- pista:execute` statements, so that half is empty.
func (r *DumpResult) Document() *parser.ParseResult {
return &parser.ParseResult{
Tables: r.tables(),
Views: r.views(),
Enums: r.enums(),
Domains: r.domains(),
CompositeTypes: r.compositeTypes(),
Sequences: r.sequences(),
Routines: r.routines(),
}
}
// String formats enums, domains, composite types, sequences, routines, tables,
// and views into canonical SQL output for dump.
// Order: enums -> domains -> composite types -> sequences -> routines ->
// tables -> sequence ownership -> views -> routines with a SQL-standard body
// (enums/domains/composite types first since later objects may depend on them;
// sequences before tables since column defaults may reference them; OWNED BY
// after the tables since it names a column; a SQL-standard body last since
// PostgreSQL parses it at creation time and it may read the tables and views).
func (r *DumpResult) String() string {
enums, domains, compositeTypes, sequences := r.enums(), r.domains(), r.compositeTypes(), r.sequences()
tables, views := r.tables(), r.views()
var parts []string
if enums.Len() > 0 {
parts = append(parts, model.EnumsToSQL(enums))
}
if domains.Len() > 0 {
parts = append(parts, model.DomainsToSQL(domains))
}
if compositeTypes.Len() > 0 {
parts = append(parts, model.CompositeTypesToSQL(compositeTypes))
}
if sequences.Len() > 0 {
parts = append(parts, model.SequencesToSQL(sequences))
}
plain, atomic := splitAtomicRoutines(r.routines())
if plain.Len() > 0 {
parts = append(parts, model.RoutinesToSQL(plain))
}
if tables.Len() > 0 {
parts = append(parts, r.tableSQL(model.TablesToSQL(tables)))
}
if ownedBy := model.SequencesOwnedBySQL(sequences); ownedBy != "" {
parts = append(parts, ownedBy)
}
if views.Len() > 0 {
parts = append(parts, model.ViewsToSQL(views))
}
if atomic.Len() > 0 {
parts = append(parts, model.RoutinesToSQL(atomic))
}
return strings.TrimSuffix(r.formatSQL(strings.Join(parts, "\n\n")), "\n")
}
// splitAtomicRoutines separates the routines with a SQL-standard body from
// the rest, keeping the order of each.
func splitAtomicRoutines(routines *orderedmap.Map[string, *model.Routine]) (plain, atomic *orderedmap.Map[string, *model.Routine]) {
plain = routines.Filter(func(_ string, r *model.Routine) bool { return !r.Atomic() })
atomic = routines.Filter(func(_ string, r *model.Routine) bool { return r.Atomic() })
return plain, atomic
}
// formatSQL lays the dump out with the formatter pista fmt uses, so the two
// agree on where the lines break and how far they are indented. --no-format
// leaves the model's own rendering alone.
//
// A model read from a catalog renders SQL that parses, so the error path is
// not reached by a real dump. A model built by hand can render something that
// does not parse, an empty sequence type for one, and that is returned as it
// was rather than failing the dump.
func (r *DumpResult) formatSQL(sql string) string {
if r.NoFormat {
return sql
}
out, err := format.Format(sql)
if err != nil {
return sql
}
return out
}
func (r *DumpResult) Files() map[string]string {
files := make(map[string]string)
seen := make(map[string]bool)
add := func(schema, objName, sql string) {
name := uniqueFileName(seen, toFileName(schema, objName))
files[name] = r.formatSQL(sql + "\n")
seen[strings.ToLower(name)] = true
}
for _, e := range r.enums().CollectValues() {
add(e.Schema, e.Name, model.EnumToSQL(e))
}
for _, d := range r.domains().CollectValues() {
add(d.Schema, d.Name, model.DomainToSQL(d))
}
for _, ct := range r.compositeTypes().CollectValues() {
add(ct.Schema, ct.Name, model.CompositeTypeToSQL(ct))
}
for _, s := range r.sequences().CollectValues() {
// OWNED BY goes in the sequence's file. The parser does not require
// the table to come first.
sql := model.SequenceToSQL(s)
if ownedBy := s.OwnedBySQL(); ownedBy != "" {
sql += "\n" + ownedBy
}
add(s.Schema, s.Name, sql)
}
for _, rt := range r.routines().CollectValues() {
// Overloads share a schema-qualified name, so uniqueFileName gives
// the second one a _2 suffix, the same as any other collision.
add(rt.Schema, rt.Name, model.RoutineToSQL(rt))
}
for _, t := range r.tables().CollectValues() {
add(t.Schema, t.Name, r.tableSQL(model.TableToSQL(t)))
}
for _, v := range r.views().CollectValues() {
add(v.Schema, v.Name, model.ViewToSQL(v))
}
return files
}
func uniqueFileName(seen map[string]bool, name string) string {
if !seen[strings.ToLower(name)] {
return name
}
ext := ".sql"
base := strings.TrimSuffix(name, ext)
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s_%d%s", base, i, ext)
if !seen[strings.ToLower(candidate)] {
return candidate
}
}
}
var fileNameReplacer = strings.NewReplacer(
`"`, "",
" ", "_",
)
func toFileName(schema, name string) string {
base := name
if schema != "" {
base = schema + "." + name
}
return fileNameReplacer.Replace(base) + ".sql"
}
// omitPartitionChildIndexes leaves out each index of a partition that is a copy
// of its parent's index, for --omit-partition-child-index. Loading the dump
// creates the copy again, with the partition or with the parent's index,
// whichever comes later. An index with a comment is kept, since the comment
// needs it.
//
// Every partition is checked before any index is left out, so a partition of a
// partition still finds the index it copies.
func omitPartitionChildIndexes(tables *orderedmap.Map[string, *model.Table]) {
kept := map[*model.Table]*orderedmap.Map[string, *model.Index]{}
for t := range tables.Values() {
if !t.IsPartitionChild() {
continue
}
// A parent in a schema the dump does not read is not seen, so the
// partition's indexes are written.
parent, ok := tables.GetOk(*t.PartitionOf)
if !ok {
continue
}
kept[t] = t.Indexes.Filter(func(_ string, idx *model.Index) bool {
if !idx.Attached || idx.Comment != nil {
return true
}
for parentIdx := range parent.Indexes.Values() {
if diff.IsPartitionIndexCopy(idx, parentIdx) {
return false
}
}
return true
})
}
for t, indexes := range kept {
t.Indexes = indexes
}
}
func (client *Client) Dump(ctx context.Context, options *DumpOptions) (*DumpResult, error) {
if err := client.validateSchemas(); err != nil {
return nil, err
}
if options.OmitSchema && len(client.Schemas) > 1 {
return nil, errors.New("--omit-schema cannot be used with multiple schemas")
}
conn, err := client.connect(ctx, !options.NoReadOnly)
if err != nil {
return nil, err
}
defer conn.Close(ctx) //nolint:errcheck
cat, err := catalog.NewCatalog(conn, client.Schemas)
if err != nil {
return nil, fmt.Errorf("failed to create catalog: %w", err)
}
current, err := readCurrent(ctx, cat, &client.FilterOptions)
if err != nil {
return nil, err
}
if options.OmitPartitionChildIndex {
omitPartitionChildIndexes(current.Tables)
}
if client.Engine.isDSQL() {
dsql.NormalizeCurrent(current.Tables)
}
// The estimates are keyed by the names the catalog read, so they are
// attached before a --schema-map remap renames the schemas.
if options.Explain {
if err := explainDump(ctx, cat, current.Tables, current.Views); err != nil {
return nil, fmt.Errorf("failed to explain dump: %w", err)
}
}
dumped := client.currentSide(&schemaObjects{
Tables: client.remapTableSchemas(current.Tables),
Views: client.remapViewSchemas(current.Views),
Enums: client.remapEnumSchemas(current.Enums),
Domains: client.remapDomainSchemas(current.Domains),
CompositeTypes: client.remapCompositeTypeSchemas(current.CompositeTypes),
Sequences: client.remapSequenceSchemas(current.Sequences),
Routines: client.remapRoutineSchemas(current.Routines),
})
return &DumpResult{
Tables: dumped.Tables,
Views: dumped.Views,
Enums: dumped.Enums,
Domains: dumped.Domains,
CompositeTypes: dumped.CompositeTypes,
Sequences: dumped.Sequences,
Routines: dumped.Routines,
OmitSchema: options.OmitSchema,
NoFormat: options.NoFormat,
DSQL: client.Engine.isDSQL(),
Count: dumped.count(client.Schemas, client.ManageRoutine),
}, nil
}
package pistachio
import (
"context"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/model"
)
// explainDump attaches the size estimate dump --explain prints to every
// table, materialized view and index. The numbers are the ones plan --explain
// prints, read from pg_class in two queries, so the dump reads no table.
//
// A partitioned table sums its partitions, at any depth, and says how many
// there are. The sum is taken over every table the catalog read, so a
// partition a filter leaves out of the dump still counts toward its parent,
// and a partition in a schema the catalog did not read counts toward nothing.
// An INHERITS parent is its own rows alone, since its children are dumped next
// to it with sizes of their own.
func explainDump(
ctx context.Context,
cat *catalog.Catalog,
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
) error {
stats, err := cat.TableStats(ctx)
if err != nil {
return err
}
indexSizes, err := cat.IndexSizes(ctx)
if err != nil {
return err
}
partitions := map[string][]*model.Table{}
for _, t := range tables.CollectValues() {
if t.IsPartitionChild() {
partitions[*t.PartitionOf] = append(partitions[*t.PartitionOf], t)
}
}
var descendants func(t *model.Table) []*model.Table
descendants = func(t *model.Table) []*model.Table {
var out []*model.Table
for _, c := range partitions[t.FQTN()] {
out = append(out, c)
out = append(out, descendants(c)...)
}
return out
}
setIndexSizes := func(indexes *orderedmap.Map[string, *model.Index]) {
for _, idx := range indexes.CollectValues() {
if bytes, ok := indexSizes[model.Ident(idx.Schema, idx.Name)]; ok {
idx.Size = sizePretty(bytes)
}
}
}
for _, t := range tables.CollectValues() {
relations := []*model.Table{t}
if t.Partitioned {
relations = append(relations, descendants(t)...)
}
details := sizeDetails(stats, relations)
if t.Partitioned {
details = append(details, pluralize(len(relations)-1, "partition"))
}
t.Size = strings.Join(details, ", ")
setIndexSizes(t.Indexes)
}
for _, v := range views.CollectValues() {
if !v.Materialized {
continue
}
v.Size = strings.Join(sizeDetails(stats, []*model.Table{{Schema: v.Schema, Name: v.Name}}), ", ")
setIndexSizes(v.Indexes)
}
return nil
}
package pistachio
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/alecthomas/kong"
"github.com/jackc/pgx/v5"
)
// exclusiveLockClassID is the first key of the two-key session-level advisory
// lock that makes exclusive apply runs mutually exclusive ("Pist" in ASCII).
// The second key is hashtext(current_database()): the advisory lock namespace
// is cluster-wide, so hashing the database name scopes the exclusion to one
// database.
const exclusiveLockClassID = 0x50697374
// exclusivePollInterval is how long a wait sleeps between attempts. The runs
// it waits for take minutes, so a second of latency costs nothing, and one
// statement a second costs the database nothing.
const exclusivePollInterval = time.Second
// UnsignedDuration is a time.Duration that rejects a negative value at
// parse time, so a flag, env var or config key using it cannot be set below
// zero.
type UnsignedDuration time.Duration
// Decode reads the value kong hands over. The command line and an environment
// variable hand over text. The config file hands over what YAML read, so a
// bare 0, the value that waits without limit, arrives as a number and is read
// as its text.
func (d *UnsignedDuration) Decode(ctx *kong.DecodeContext) error {
token, err := ctx.Scan.PopValue("duration")
if err != nil {
return err
}
return d.UnmarshalText([]byte(fmt.Sprint(token.Value)))
}
func (d *UnsignedDuration) UnmarshalText(text []byte) error {
v, err := time.ParseDuration(string(text))
if err != nil {
return err
}
if v < 0 {
return fmt.Errorf("must not be negative: %s", v)
}
*d = UnsignedDuration(v)
return nil
}
// tryExclusive makes one non-blocking attempt at the exclusion.
func tryExclusive(ctx context.Context, conn *pgx.Conn) (bool, error) {
var acquired bool
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1, hashtext(current_database()))", exclusiveLockClassID).Scan(&acquired); err != nil {
return false, fmt.Errorf("failed to acquire the apply exclusion: %w", err)
}
return acquired, nil
}
// acquireExclusive makes this apply run mutually exclusive with other
// exclusive apply runs on the same database. It runs right after connecting,
// before the catalog is read, so the diff cannot be computed against a state
// another exclusive apply is still changing.
//
// The lock is session-level on purpose: it is unaffected by transaction
// boundaries, so it behaves the same with --with-tx, without it, and across
// CONCURRENTLY index DDL, and it is released when the connection closes,
// including on a crash.
//
// With wait == nil a held lock is an immediate error. Otherwise the attempt is
// retried on a timer until the holder is gone, bounded by *wait through a
// context deadline (0 waits without limit); a server-side setting like
// lock_timeout is deliberately not used, so nothing leaks into the session the
// DDL then runs on.
//
// The wait retries rather than blocking in pg_advisory_lock because a blocked
// statement holds a snapshot while it waits, and the apply it waits for may run
// CREATE INDEX CONCURRENTLY, whose build waits for every backend that holds
// one. That closes a cycle, and PostgreSQL breaks a cycle by killing a session
// in it, the waiter being the one it picks. Advisory locks sit in the same lock
// manager as table locks, so the detector sees it. Between attempts this
// session is idle and holds no snapshot, so there is no cycle.
func acquireExclusive(ctx context.Context, conn *pgx.Conn, wait *UnsignedDuration, w io.Writer) error {
acquired, err := tryExclusive(ctx, conn)
if err != nil {
return err
}
if acquired {
return nil
}
if wait == nil {
return errors.New("another exclusive apply is running (--exclusive-wait waits for it)")
}
fmt.Fprintln(w, "-- Waiting for another exclusive apply to finish") //nolint:errcheck
waitCtx := ctx
if *wait > 0 {
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(ctx, time.Duration(*wait))
defer cancel()
}
ticker := time.NewTicker(exclusivePollInterval)
defer ticker.Stop()
for {
select {
case <-waitCtx.Done():
// The caller's own context going away, Ctrl-C for example, is not
// the wait running out.
if ctx.Err() != nil {
return fmt.Errorf("failed to acquire the apply exclusion: %w", ctx.Err())
}
return fmt.Errorf("another exclusive apply did not finish within %s", time.Duration(*wait))
case <-ticker.C:
}
// ctx, not waitCtx: an attempt is a single fast statement, and letting
// the wait deadline cancel it mid-flight would report the query rather
// than the wait.
acquired, err := tryExclusive(ctx, conn)
if err != nil {
return err
}
if acquired {
return nil
}
}
}
package pistachio
import (
"context"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"time"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// --explain puts a comment before every planned statement that reads or
// rewrites a table that already holds data, so a plan pasted into a review
// says which of its lines cost time in proportion to the table. Two facts are
// read off each statement, whether it scans or rewrites the table and what
// the lock it takes stops, and the table's size is added from pg_class. The
// size is an estimate the server keeps rather than a count, so the date it
// was last written is printed next to it:
//
// -- rewrite, blocks reads and writes: public.events (~120,000,000 rows, 9629 MB, as of 2026-09-08, 5 indexes rebuilt)
// ALTER TABLE public.events ALTER COLUMN amount SET DATA TYPE numeric(12,2);
//
// A statement that only changes the catalog takes no comment, whatever lock
// it holds for the instant it runs. Neither does one on a table this plan
// creates, since that table is empty.
//
// The classification is a fixed table drawn from the ALTER TABLE reference
// and tablecmds.c, applied to the parse tree of the emitted statement, and
// the diff is not involved. What the table cannot decide on its own is asked
// of the server in two small catalog reads: whether a column type change is a
// binary-coercible relabel or a conversion, and whether a default calls a
// volatile function. Both run only when the plan holds such a statement.
// touchKind says how much of the table a statement reads or writes.
type touchKind int
const (
touchNone touchKind = iota
// touchScan reads every row once: a constraint validation, an index
// build, a NOT NULL check.
touchScan
// touchMayRewrite is a rewrite or a catalog-only change that only the
// server can tell apart, for diff, which has no server to ask.
touchMayRewrite
// touchRewrite copies the table into a new file and rebuilds every index
// on it.
touchRewrite
)
// blockKind says what the statement's lock stops on the table for as long as
// the statement runs. It is the lock level folded to what a reader or a writer
// of the table sees.
type blockKind int
const (
// blockNothing is SHARE UPDATE EXCLUSIVE or weaker: reads and writes go
// on, only another DDL waits.
blockNothing blockKind = iota
// blockWrites is SHARE, SHARE ROW EXCLUSIVE or EXCLUSIVE.
blockWrites
// blockAll is ACCESS EXCLUSIVE.
blockAll
)
func (k touchKind) String() string {
switch k {
case touchScan:
return "scan"
case touchMayRewrite:
return "may rewrite"
case touchRewrite:
return "rewrite"
}
return ""
}
func (k blockKind) String() string {
switch k {
case blockWrites:
return "blocks writes"
case blockAll:
return "blocks reads and writes"
}
return "blocks nothing"
}
// explainTarget is one table a statement touches. Rebuilt is the number of
// indexes the statement builds again, Descendants the partitions or INHERITS
// children the statement recurses into, with DescendantKind naming which.
type explainTarget struct {
key string
rebuilt int
descendants int
descendantKind string
}
// explainEffect is the classification of one statement.
type explainEffect struct {
touch touchKind
block blockKind
targets []explainTarget
}
// merge folds another effect in, for the several actions of one ALTER TABLE:
// the heaviest touch and the strongest lock win, and a target already listed
// keeps the larger of the two rebuild counts.
func (e *explainEffect) merge(o explainEffect) {
e.touch = max(e.touch, o.touch)
e.block = max(e.block, o.block)
for _, t := range o.targets {
e.addTarget(t)
}
}
func (e *explainEffect) addTarget(t explainTarget) {
for i := range e.targets {
if e.targets[i].key == t.key {
e.targets[i].rebuilt = max(e.targets[i].rebuilt, t.rebuilt)
return
}
}
e.targets = append(e.targets, t)
}
// explainer holds what the classification reads: the current schema, the
// size estimates and the two server answers, plus the rename directives that
// map a name the plan uses back to the table the catalog read.
type explainer struct {
schemas []string
current *orderedmap.Map[string, *model.Table]
// children maps a parent's key to its partitions or INHERITS children.
children map[string][]*model.Table
// tableAlias maps a table's new name to its current one.
tableAlias map[string]string
// columnAlias maps, per new table name, a column's new name to its
// current one.
columnAlias map[string]map[string]string
// domains is the desired side, read for ADD COLUMN: a column of a domain
// that carries a constraint is verified by a rewrite.
domains *orderedmap.Map[string, *model.Domain]
// offline is set for diff, which has no server: no size is shown, and a
// statement only the server can place reads as may rewrite.
offline bool
stats map[string]catalog.TableStat
types map[catalog.TypeChange]catalog.TypeChangeInfo
volatile map[string]bool
}
// explainStmts returns the statements with the comment prepended to each one
// that scans or rewrites a table.
func (client *Client) explainStmts(
ctx context.Context,
cat *catalog.Catalog,
stmts []string,
current, desired *orderedmap.Map[string, *model.Table],
domains *orderedmap.Map[string, *model.Domain],
) ([]string, error) {
ex := newExplainer(client.Schemas, current, desired, domains)
parsed := parseStmts(stmts)
var changes []catalog.TypeChange
var funcs []string
seenChange := map[catalog.TypeChange]bool{}
seenFunc := map[string]bool{}
for _, node := range parsed {
if node == nil {
continue
}
for _, ch := range ex.typeChangesOf(node) {
if !seenChange[ch] {
seenChange[ch] = true
changes = append(changes, ch)
}
}
for _, f := range defaultFuncsOf(node) {
if !seenFunc[f] {
seenFunc[f] = true
funcs = append(funcs, f)
}
}
}
// The type and volatility answers decide what a statement does to the
// rows, so they come before the classification. Each is skipped when the
// plan holds nothing that needs it.
var err error
if ex.types, err = cat.TypeChanges(ctx, changes); err != nil {
return nil, err
}
if ex.volatile, err = cat.VolatileFunctions(ctx, funcs); err != nil {
return nil, err
}
effects, sized := ex.classifyAll(parsed)
// The sizes are read only once a statement is known to want one, so a plan
// that creates and drops alone touches pg_class no more than plan does
// without the flag.
if sized {
if ex.stats, err = cat.TableStats(ctx); err != nil {
return nil, err
}
}
return ex.annotate(stmts, effects), nil
}
// explainStmtsOffline is explainStmts without a server, for diff. No size is
// shown, and a statement only the server can place reads as may rewrite.
func (client *Client) explainStmtsOffline(
stmts []string,
current, desired *orderedmap.Map[string, *model.Table],
domains *orderedmap.Map[string, *model.Domain],
) []string {
ex := newExplainer(client.Schemas, current, desired, domains)
ex.offline = true
effects, _ := ex.classifyAll(parseStmts(stmts))
return ex.annotate(stmts, effects)
}
// newExplainer indexes the current tables by parent and the desired rename
// directives by new name.
func newExplainer(
schemas []string,
current, desired *orderedmap.Map[string, *model.Table],
domains *orderedmap.Map[string, *model.Domain],
) *explainer {
ex := &explainer{
schemas: schemas,
current: current,
children: map[string][]*model.Table{},
tableAlias: map[string]string{},
columnAlias: map[string]map[string]string{},
domains: domains,
}
for _, t := range current.CollectValues() {
if t.PartitionOf != nil {
ex.children[*t.PartitionOf] = append(ex.children[*t.PartitionOf], t)
}
}
for key, t := range desired.All() {
if t.RenameFrom != nil && *t.RenameFrom != key {
ex.tableAlias[key] = *t.RenameFrom
}
for name, col := range t.Columns.All() {
if col.RenameFrom != nil && *col.RenameFrom != name {
if ex.columnAlias[key] == nil {
ex.columnAlias[key] = map[string]string{}
}
ex.columnAlias[key][name] = *col.RenameFrom
}
}
}
return ex
}
func parseStmts(stmts []string) []*pg_query.Node {
parsed := make([]*pg_query.Node, len(stmts))
for i, sql := range stmts {
parsed[i] = parseOneStmt(sql)
}
return parsed
}
// classifyAll classifies each parsed statement and reports whether any of
// them touches a table, which is when a size is wanted.
func (ex *explainer) classifyAll(parsed []*pg_query.Node) ([]explainEffect, bool) {
effects := make([]explainEffect, len(parsed))
sized := false
for i, node := range parsed {
if node == nil {
continue
}
effects[i] = ex.classify(node)
sized = sized || len(effects[i].targets) > 0
}
return effects, sized
}
// annotate prepends each statement's comment, if it has one.
func (ex *explainer) annotate(stmts []string, effects []explainEffect) []string {
out := make([]string, len(stmts))
for i, sql := range stmts {
out[i] = sql
if line := ex.render(effects[i]); line != "" {
out[i] = line + "\n" + sql
}
}
return out
}
// parseOneStmt parses a statement the diff emitted. Anything that does not
// parse as exactly one statement is left unexplained rather than reported:
// the plan is what matters, and the comment is an aid to reading it.
func parseOneStmt(sql string) *pg_query.Node {
result, err := pg_query.Parse(sql)
if err != nil || len(result.Stmts) != 1 {
return nil
}
return result.Stmts[0].Stmt
}
// currentTable resolves a name the plan uses to the table the catalog read,
// through a rename directive when the plan renamed it first. A table the plan
// creates has no current entry and resolves to nil, and so does a statement
// with no relation at all.
func (ex *explainer) currentTable(rv *pg_query.RangeVar) (string, *model.Table) {
key := rangeVarIdent(rv)
return key, ex.currentTableByKey(key)
}
// currentTableByKey is currentTable for a name already written as a key.
func (ex *explainer) currentTableByKey(key string) *model.Table {
if old, ok := ex.tableAlias[key]; ok {
if t, ok := ex.current.GetOk(old); ok {
return t
}
}
t, _ := ex.current.GetOk(key)
return t
}
// referencedTable resolves the table a foreign key references. A name without
// a schema is looked up in each managed schema and then in public, the same
// order as the search_path that apply sets.
func (ex *explainer) referencedTable(rv *pg_query.RangeVar) (string, *model.Table) {
for _, key := range ex.resolveRangeVar(rv) {
if t := ex.currentTableByKey(key); t != nil {
return key, t
}
}
return rangeVarIdent(rv), nil
}
// currentColumn resolves a column of the table the plan names, through a
// column rename directive when the plan renamed it first.
func (ex *explainer) currentColumn(key string, t *model.Table, name string) *model.Column {
col, _ := t.Columns.GetOk(renamedFrom(ex.columnAlias[key], name))
return col
}
// target builds the entry for a table, counting the relations the statement
// recurses into when recurse is set: every partition of a partitioned table,
// every child of an INHERITS parent.
func (ex *explainer) target(key string, t *model.Table, recurse bool) explainTarget {
tg := explainTarget{key: key}
if !recurse {
return tg
}
descendants := ex.descendants(t)
if len(descendants) == 0 {
return tg
}
tg.descendants = len(descendants)
if t.Partitioned {
tg.descendantKind = "partition"
} else {
tg.descendantKind = "child table"
}
return tg
}
// descendants lists every table under t, at any depth.
func (ex *explainer) descendants(t *model.Table) []*model.Table {
var out []*model.Table
for _, c := range ex.children[t.FQTN()] {
out = append(out, c)
out = append(out, ex.descendants(c)...)
}
return out
}
// indexCount is the number of indexes a rewrite of t builds again: the ones
// declared as indexes and the ones a PRIMARY KEY, UNIQUE or EXCLUDE
// constraint owns.
func indexCount(t *model.Table) int {
n := t.Indexes.Len()
for _, con := range t.Constraints.CollectValues() {
if con.Type.IsPrimaryKeyConstraint() || con.Type.IsUniqueConstraint() || con.Type.IsExclusionConstraint() {
n++
}
}
return n
}
// classify reads one parsed statement.
func (ex *explainer) classify(node *pg_query.Node) explainEffect {
switch {
case node.GetAlterTableStmt() != nil:
return ex.classifyAlterTable(node.GetAlterTableStmt())
case node.GetIndexStmt() != nil:
return ex.classifyIndex(node.GetIndexStmt())
case node.GetAlterDomainStmt() != nil:
return ex.classifyAlterDomain(node.GetAlterDomainStmt())
case node.GetCreateTableAsStmt() != nil:
return ex.classifyCreateTableAs(node.GetCreateTableAsStmt())
}
return explainEffect{}
}
func (ex *explainer) classifyAlterTable(as *pg_query.AlterTableStmt) explainEffect {
if as.Objtype != pg_query.ObjectType_OBJECT_TABLE || as.Relation == nil {
return explainEffect{}
}
key, t := ex.currentTable(as.Relation)
if t == nil {
return explainEffect{}
}
// Every node of Cmds is an AlterTableCmd, and a nil one would read as the
// undefined subtype, which the table below does not name.
var eff explainEffect
for _, n := range as.Cmds {
eff.merge(ex.classifyAlterTableCmd(key, t, as.Relation.Inh, n.GetAlterTableCmd()))
}
if eff.touch == touchNone {
return explainEffect{}
}
return eff
}
// classifyAlterTableCmd is the table for ALTER TABLE. An action not listed
// changes the catalog alone and takes nothing.
func (ex *explainer) classifyAlterTableCmd(key string, t *model.Table, recurse bool, cmd *pg_query.AlterTableCmd) explainEffect {
self := func(touch touchKind, block blockKind) explainEffect {
tg := ex.target(key, t, recurse)
if touch >= touchMayRewrite {
tg.rebuilt = indexCount(t)
}
return explainEffect{touch: touch, block: block, targets: []explainTarget{tg}}
}
switch cmd.GetSubtype() {
case pg_query.AlterTableType_AT_AddColumn:
return self(ex.addColumnTouch(cmd.GetDef().GetColumnDef()), blockAll)
case pg_query.AlterTableType_AT_AlterColumnType:
col := ex.currentColumn(key, t, cmd.GetName())
cd := cmd.GetDef().GetColumnDef()
tn := cd.GetTypeName()
if col == nil || tn == nil {
return explainEffect{}
}
// A USING expression other than the column itself, or the column cast
// to the target type, computes new values, so every row is rewritten
// whatever the types.
if u := cd.GetRawDefault(); u != nil && !isColumnCast(u, cmd.GetName(), tn) {
return self(touchRewrite, blockAll)
}
return self(ex.alterTypeTouch(col.TypeName, tn), blockAll)
case pg_query.AlterTableType_AT_SetNotNull:
return self(touchScan, blockAll)
case pg_query.AlterTableType_AT_SetLogged, pg_query.AlterTableType_AT_SetUnLogged:
return self(touchRewrite, blockAll)
case pg_query.AlterTableType_AT_ValidateConstraint:
return self(touchScan, blockNothing)
case pg_query.AlterTableType_AT_AddConstraint:
con := cmd.GetDef().GetConstraint()
switch con.GetContype() {
case pg_query.ConstrType_CONSTR_CHECK, pg_query.ConstrType_CONSTR_NOTNULL:
if con.GetSkipValidation() {
return explainEffect{}
}
return self(touchScan, blockAll)
case pg_query.ConstrType_CONSTR_PRIMARY, pg_query.ConstrType_CONSTR_UNIQUE, pg_query.ConstrType_CONSTR_EXCLUSION:
// USING INDEX takes an index that already exists rather than
// building one, so nothing is scanned.
if con.GetIndexname() != "" {
return explainEffect{}
}
return self(touchScan, blockAll)
case pg_query.ConstrType_CONSTR_FOREIGN:
if con.GetSkipValidation() {
return explainEffect{}
}
// Both tables take SHARE ROW EXCLUSIVE. The referencing table
// is scanned; the referenced one is probed through its key,
// and every partition of it is locked. A referenced table the
// same plan creates is named without a size.
eff := self(touchScan, blockWrites)
refKey, ref := ex.referencedTable(con.GetPktable())
if ref != nil {
eff.addTarget(ex.target(refKey, ref, ref.Partitioned))
} else {
eff.addTarget(explainTarget{key: refKey})
}
return eff
}
}
return explainEffect{}
}
// addColumnTouch decides what ADD COLUMN does to the rows that exist. A
// volatile default has to be evaluated per row, and so does an identity, a
// generated expression, a serial and a domain with a constraint, each of which
// is a rewrite. A NOT NULL without a default is checked against every row.
// Anything else is stored as a missing value in the catalog.
func (ex *explainer) addColumnTouch(cd *pg_query.ColumnDef) touchKind {
if isSerialTypeName(cd.GetTypeName()) || ex.isConstrainedDomain(cd.GetTypeName()) {
return touchRewrite
}
notNull := false
hasDefault := false
mayRewrite := false
for _, n := range cd.GetConstraints() {
con := n.GetConstraint()
switch con.GetContype() {
case pg_query.ConstrType_CONSTR_IDENTITY, pg_query.ConstrType_CONSTR_GENERATED:
return touchRewrite
case pg_query.ConstrType_CONSTR_NOTNULL:
notNull = true
case pg_query.ConstrType_CONSTR_DEFAULT:
hasDefault = true
for _, f := range funcNamesOf(con.GetRawExpr()) {
if ex.offline {
mayRewrite = true
} else if ex.volatile[f] {
return touchRewrite
}
}
}
}
if mayRewrite {
return touchMayRewrite
}
if notNull && !hasDefault {
return touchScan
}
return touchNone
}
// isConstrainedDomain reports whether the type names a desired domain that
// carries a NOT NULL or a CHECK.
func (ex *explainer) isConstrainedDomain(tn *pg_query.TypeName) bool {
if ex.domains == nil {
return false
}
name := typeNameString(tn)
for key, d := range ex.domains.All() {
if key == name || (!strings.Contains(name, ".") && model.Ident(d.Name) == name) {
return d.NotNull || len(d.Constraints) > 0
}
}
return false
}
// isColumnCast reports whether expr is the named column, bare or cast to the
// target type once. As a USING expression it changes the values only as the
// types alone would. A cast to any other type can change them, as
// code::varchar(10) truncates on the way to varchar(100).
func isColumnCast(expr *pg_query.Node, name string, target *pg_query.TypeName) bool {
if tc := expr.GetTypeCast(); tc != nil {
ct := tc.GetTypeName()
if typeNameString(ct) != typeNameString(target) ||
!slices.Equal(typmodsOf(ct), typmodsOf(target)) ||
len(ct.GetArrayBounds()) != len(target.GetArrayBounds()) {
return false
}
expr = tc.GetArg()
}
fields := expr.GetColumnRef().GetFields()
return len(fields) > 0 && fields[len(fields)-1].GetString_().GetSval() == name
}
// alterTypeTouch decides what SET DATA TYPE does to the rows. A change the
// server answered as a plain relabel changes the catalog alone, and so does a
// wider modifier on the same type. Everything else converts every row.
func (ex *explainer) alterTypeTouch(current string, desired *pg_query.TypeName) touchKind {
if ex.offline {
return touchMayRewrite
}
ch := catalog.TypeChange{Src: baseTypeString(current), Dst: typeNameString(desired)}
info, ok := ex.types[ch]
if !ok || !info.Known {
// The plan creates the type, so the server cannot tell yet.
return touchMayRewrite
}
if info.Constrained || strings.HasSuffix(current, "[]") != (len(desired.ArrayBounds) > 0) {
return touchRewrite
}
if info.SameBase {
if typmodWidens(info.BaseType, typmodOf(current), typmodsOf(desired)) {
return touchNone
}
return touchRewrite
}
if info.Binary && len(desired.Typmods) == 0 {
return touchNone
}
return touchRewrite
}
// typmodWidens reports whether the modifier change on one base type keeps the
// stored values as they are. These are the types whose length coercion has a
// support function that lets the planner drop it when the new modifier admits
// every value the old one did: a longer varchar or varbit, a numeric of the
// same scale and more precision, a timestamp or time of more precision. No
// modifier at all admits everything. A change on any other type, or a
// narrowing, converts every row.
func typmodWidens(baseType string, src, dst []int64) bool {
if len(dst) == 0 {
return true
}
switch baseType {
case "character varying", "bit varying":
return len(src) == 1 && dst[0] >= src[0]
case "numeric":
if len(src) < 2 {
return false
}
if len(dst) < 2 {
dst = append(dst, 0)
}
return dst[1] == src[1] && dst[0] >= src[0]
case "timestamp without time zone", "timestamp with time zone", "time without time zone", "time with time zone":
if len(src) == 0 {
src = []int64{6}
}
return dst[0] >= src[0]
}
return false
}
// typmodOf reads the modifier out of a catalog type name, "(10,2)" in
// "numeric(10,2)" or "(3)" in "timestamp(3) without time zone".
func typmodOf(typeName string) []int64 {
m := typmodRe.FindStringSubmatch(typeName)
if m == nil {
return nil
}
var mods []int64
for part := range strings.SplitSeq(m[1], ",") {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil {
return nil
}
mods = append(mods, n)
}
return mods
}
var typmodRe = regexp.MustCompile(`\(([0-9, ]+)\)`)
// typmodsOf reads the modifier off a parsed type name.
func typmodsOf(tn *pg_query.TypeName) []int64 {
var mods []int64
for _, n := range tn.Typmods {
c := n.GetAConst()
if c == nil || c.GetIval() == nil {
return nil
}
mods = append(mods, int64(c.GetIval().Ival))
}
return mods
}
// baseTypeString strips the modifier and the array bound off a catalog type
// name, leaving what to_regtype resolves.
func baseTypeString(typeName string) string {
s := typmodRe.ReplaceAllString(typeName, "")
return strings.TrimSuffix(s, "[]")
}
// typeNameString spells a parsed type name the way to_regtype reads it: the
// name without its modifier or array bound, and without the pg_catalog prefix
// the grammar puts on a built-in type. A nil type name gives "", which
// to_regtype answers with NULL rather than an error. Each part is quoted where
// it needs it, or the server would fold a name like "App" to lower case.
func typeNameString(tn *pg_query.TypeName) string {
var parts []string
for _, n := range tn.GetNames() {
if s := n.GetString_(); s != nil {
parts = append(parts, s.Sval)
}
}
if len(parts) == 2 && parts[0] == "pg_catalog" {
parts = parts[1:]
}
return model.Ident(parts...)
}
func isSerialTypeName(tn *pg_query.TypeName) bool {
switch typeNameString(tn) {
case "serial", "serial4", "bigserial", "serial8", "smallserial", "serial2":
return true
}
return false
}
// typeChangesOf collects the type changes a statement carries, so the server
// can be asked about all of them at once.
func (ex *explainer) typeChangesOf(node *pg_query.Node) []catalog.TypeChange {
as := node.GetAlterTableStmt()
if as == nil || as.Objtype != pg_query.ObjectType_OBJECT_TABLE || as.Relation == nil {
return nil
}
key, t := ex.currentTable(as.Relation)
if t == nil {
return nil
}
var changes []catalog.TypeChange
for _, n := range as.Cmds {
cmd := n.GetAlterTableCmd()
if cmd.GetSubtype() != pg_query.AlterTableType_AT_AlterColumnType {
continue
}
col := ex.currentColumn(key, t, cmd.GetName())
tn := cmd.GetDef().GetColumnDef().GetTypeName()
if col == nil || tn == nil {
continue
}
changes = append(changes, catalog.TypeChange{Src: baseTypeString(col.TypeName), Dst: typeNameString(tn)})
}
return changes
}
// defaultFuncsOf collects the functions the defaults of a statement's added
// columns call.
func defaultFuncsOf(node *pg_query.Node) []string {
as := node.GetAlterTableStmt()
if as == nil {
return nil
}
var funcs []string
for _, n := range as.Cmds {
cmd := n.GetAlterTableCmd()
if cmd.GetSubtype() != pg_query.AlterTableType_AT_AddColumn {
continue
}
for _, c := range cmd.GetDef().GetColumnDef().GetConstraints() {
if con := c.GetConstraint(); con.GetContype() == pg_query.ConstrType_CONSTR_DEFAULT {
funcs = append(funcs, funcNamesOf(con.GetRawExpr())...)
}
}
}
return funcs
}
// funcNamesOf lists the functions an expression calls, by name alone.
func funcNamesOf(expr *pg_query.Node) []string {
var names []string
pgast.Walk(expr, pgast.WalkOptions{}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
if fc := n.GetFuncCall(); fc != nil && len(fc.Funcname) > 0 {
if s := fc.Funcname[len(fc.Funcname)-1].GetString_(); s != nil {
names = append(names, s.Sval)
}
}
return n
})
return names
}
func (ex *explainer) classifyIndex(is *pg_query.IndexStmt) explainEffect {
key, t := ex.currentTable(is.GetRelation())
if t == nil {
return explainEffect{}
}
block := blockWrites
if is.Concurrent {
block = blockNothing
}
return explainEffect{
touch: touchScan,
block: block,
targets: []explainTarget{ex.target(key, t, is.GetRelation().GetInh() && t.Partitioned)},
}
}
// classifyAlterDomain covers the domain changes that verify the values that
// exist: SET NOT NULL, ADD CONSTRAINT without NOT VALID and VALIDATE
// CONSTRAINT. Each opens every table with a column of the domain under a
// SHARE lock and reads it through.
func (ex *explainer) classifyAlterDomain(ad *pg_query.AlterDomainStmt) explainEffect {
switch ad.Subtype {
case "O", "V":
case "C":
if con := ad.Def.GetConstraint(); con == nil || con.SkipValidation {
return explainEffect{}
}
default:
return explainEffect{}
}
var parts []string
for _, n := range ad.TypeName {
if s := n.GetString_(); s != nil {
parts = append(parts, s.Sval)
}
}
if len(parts) == 0 {
return explainEffect{}
}
domain := model.Ident(parts...)
bare := parts[len(parts)-1]
// A partition is left to the parent, which sums it in. It carries the
// parent's columns, so it matches the domain on its own and would be
// counted a second time. An INHERITS child is listed, since its parent is
// not summed and each side holds its own rows.
eff := explainEffect{touch: touchScan, block: blockWrites}
for key, t := range ex.current.All() {
if t.IsPartitionChild() {
continue
}
for _, col := range t.Columns.CollectValues() {
typ := strings.TrimSuffix(col.TypeName, "[]")
if typ == domain || typ == bare {
eff.addTarget(ex.target(key, t, t.Partitioned))
break
}
}
}
if len(eff.targets) == 0 {
return explainEffect{}
}
return eff
}
// classifyCreateTableAs covers CREATE MATERIALIZED VIEW ... WITH DATA, which
// runs its query over the tables it names and holds them under ACCESS SHARE.
func (ex *explainer) classifyCreateTableAs(cas *pg_query.CreateTableAsStmt) explainEffect {
if cas.Objtype != pg_query.ObjectType_OBJECT_MATVIEW || cas.Into == nil || cas.Into.SkipData {
return explainEffect{}
}
eff := explainEffect{touch: touchScan, block: blockNothing}
pgast.Walk(cas.Query, pgast.WalkOptions{}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
rv := n.GetRangeVar()
if rv == nil {
return n
}
for _, candidate := range ex.resolveRangeVar(rv) {
if t, ok := ex.current.GetOk(candidate); ok {
eff.addTarget(ex.target(candidate, t, true))
break
}
}
return n
})
if len(eff.targets) == 0 {
return explainEffect{}
}
return eff
}
// resolveRangeVar lists the keys a relation reference in a query body may
// resolve to: the qualified name as written, or the name under each target
// schema and then public when the reference carries no schema.
func (ex *explainer) resolveRangeVar(rv *pg_query.RangeVar) []string {
if rv.Schemaname != "" {
return []string{model.Ident(rv.Schemaname, rv.Relname)}
}
var keys []string
for _, s := range ex.schemas {
keys = append(keys, model.Ident(s, rv.Relname))
}
return append(keys, model.Ident("public", rv.Relname))
}
// render writes the comment line, or nothing for an effect that touches no
// rows.
func (ex *explainer) render(eff explainEffect) string {
if eff.touch == touchNone || len(eff.targets) == 0 {
return ""
}
parts := make([]string, len(eff.targets))
for i, tg := range eff.targets {
parts[i] = ex.renderTarget(tg, eff.touch == touchMayRewrite)
}
return fmt.Sprintf("-- %s, %s: %s", eff.touch, eff.block, strings.Join(parts, ", "))
}
// renderTarget writes one table with its size. The rows and bytes of a
// partitioned table are those of its partitions, since the parent holds
// nothing itself; an INHERITS parent holds its own rows next to its
// children's. A table no VACUUM or ANALYZE has visited reads as not analyzed,
// and a table outside the managed schemas is named alone.
//
// The "as of" date is when the estimate was last written, in the local time
// zone, and over several relations it is the oldest of them, since the sum is
// no fresher than its stalest part. It is left out when the server no longer
// remembers a time.
func (ex *explainer) renderTarget(tg explainTarget, mayRewrite bool) string {
t, ok := ex.current.GetOk(tg.key)
if !ok {
if old, aliased := ex.tableAlias[tg.key]; aliased {
t, ok = ex.current.GetOk(old)
}
}
if !ok {
return tg.key
}
relations := []*model.Table{t}
if tg.descendants > 0 {
relations = append(relations, ex.descendants(t)...)
}
var details []string
if !ex.offline {
details = sizeDetails(ex.stats, relations)
}
if tg.rebuilt > 0 {
rebuilt := " rebuilt"
if mayRewrite {
rebuilt = " may be rebuilt"
}
details = append(details, plural(tg.rebuilt, "index", "indexes")+rebuilt)
}
if tg.descendants > 0 {
details = append(details, pluralize(tg.descendants, tg.descendantKind))
}
if len(details) == 0 {
return tg.key
}
return tg.key + " (" + strings.Join(details, ", ") + ")"
}
// sizeDetails writes the row and byte estimate of the relations, summed, with
// the date of the oldest estimate among them. A partitioned parent adds
// nothing, since its partitions hold the rows. When none of them has been
// analyzed the size reads as not analyzed.
func sizeDetails(stats map[string]catalog.TableStat, relations []*model.Table) []string {
var rows, bytes int64
analyzed := false
var statsAt time.Time
for _, r := range relations {
st, ok := stats[r.FQTN()]
if !ok || r.Partitioned {
continue
}
bytes += st.Bytes
if st.Rows >= 0 {
rows += st.Rows
analyzed = true
}
if !st.StatsAt.IsZero() && (statsAt.IsZero() || st.StatsAt.Before(statsAt)) {
statsAt = st.StatsAt
}
}
if !analyzed {
return []string{"not analyzed"}
}
noun := "rows"
if rows == 1 {
noun = "row"
}
details := []string{"~" + groupDigits(rows) + " " + noun, sizePretty(bytes)}
if !statsAt.IsZero() {
details = append(details, "as of "+statsAt.Local().Format(time.DateOnly))
}
return details
}
// plural is pluralize for a noun whose plural is not the singular plus s.
func plural(n int, singular, pluralForm string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, singular)
}
return fmt.Sprintf("%d %s", n, pluralForm)
}
// groupDigits writes a count with a comma between every three digits, so a
// row estimate in the hundreds of millions reads at a glance. The byte count
// needs none: sizePretty keeps it under five digits.
func groupDigits(n int64) string {
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
head := len(s) % 3
b.WriteString(s[:head])
for i := head; i < len(s); i += 3 {
if b.Len() > 0 {
b.WriteByte(',')
}
b.WriteString(s[i : i+3])
}
return b.String()
}
// sizePretty writes a byte count the way pg_size_pretty does: in bytes up to
// 10 kB, then in the largest unit that keeps the number under 10240. Like
// pg_size_pretty, it counts in half units and rounds once, at the end, so a
// size is not rounded up twice on its way to a larger unit.
func sizePretty(bytes int64) string {
const limit = 10 * 1024
if bytes < limit {
return strconv.FormatInt(bytes, 10) + " bytes"
}
n := bytes >> 9
for _, unit := range []string{"kB", "MB", "GB", "TB"} {
if n < 2*limit-1 {
return strconv.FormatInt((n+1)/2, 10) + " " + unit
}
n >>= 10
}
return strconv.FormatInt((n+1)/2, 10) + " PB"
}
package pistachio
import (
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// filterByName keeps the objects of one type that --include, --exclude,
// --enable and --disable select.
func filterByName[V any](f *FilterOptions, objType string, m *orderedmap.Map[string, V], getName func(V) string) *orderedmap.Map[string, V] {
if !f.IsTypeEnabled(objType) {
return orderedmap.New[string, V]()
}
if len(f.Include) == 0 && len(f.Exclude) == 0 {
return m
}
return m.Filter(func(_ string, v V) bool { return f.MatchName(getName(v)) })
}
func (f *FilterOptions) filterTables(tables *orderedmap.Map[string, *model.Table]) *orderedmap.Map[string, *model.Table] {
tables = filterByName(f, "table", tables, func(t *model.Table) string { return t.Name })
if !f.SkipPartitionChild {
return tables
}
return tables.Filter(func(_ string, t *model.Table) bool { return !t.IsPartitionChild() })
}
func (f *FilterOptions) filterViews(views *orderedmap.Map[string, *model.View]) *orderedmap.Map[string, *model.View] {
return filterByName(f, "view", views, func(v *model.View) string { return v.Name })
}
func (f *FilterOptions) filterEnums(enums *orderedmap.Map[string, *model.Enum]) *orderedmap.Map[string, *model.Enum] {
return filterByName(f, "enum", enums, func(e *model.Enum) string { return e.Name })
}
func (f *FilterOptions) filterSequences(sequences *orderedmap.Map[string, *model.Sequence]) *orderedmap.Map[string, *model.Sequence] {
return filterByName(f, "sequence", sequences, func(s *model.Sequence) string { return s.Name })
}
func (f *FilterOptions) filterCompositeTypes(compositeTypes *orderedmap.Map[string, *model.CompositeType]) *orderedmap.Map[string, *model.CompositeType] {
return filterByName(f, "composite_type", compositeTypes, func(ct *model.CompositeType) string { return ct.Name })
}
func (f *FilterOptions) filterDomains(domains *orderedmap.Map[string, *model.Domain]) *orderedmap.Map[string, *model.Domain] {
return filterByName(f, "domain", domains, func(d *model.Domain) string { return d.Name })
}
// filterRoutines returns the routines to manage. Routines are opt-in: without
// --manage-routine the map is empty, which keeps pg_proc out of the diff and
// leaves the output of every existing schema unchanged.
func (f *FilterOptions) filterRoutines(routines *orderedmap.Map[string, *model.Routine]) *orderedmap.Map[string, *model.Routine] {
if !f.ManageRoutine {
return orderedmap.New[string, *model.Routine]()
}
return filterByName(f, "routine", routines, func(r *model.Routine) string { return r.Name })
}
// Package format lays out schema SQL without changing the statements it
// reads. It works on the token stream, so the identifiers, the keywords and
// the expressions come out exactly as they were written; only the whitespace
// between the tokens moves. The one exception is a quoted identifier that does
// not need its quotes, which is unquoted after the change is proved to leave
// the statement's meaning alone.
package format
import (
"fmt"
"regexp"
"sort"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
)
// indentUnit is one level of indentation inside a definition list. pg_dump and
// pista dump both write four spaces.
const indentUnit = " "
type token struct {
kind pg_query.Token
// orig is the token as it was written, out the token as it is emitted.
// The two differ for an identifier that lost its quotes and for a "--"
// comment that lost its trailing whitespace.
orig string
out string
start int
end int
// nl counts the newlines between the previous token and this one, space
// reports whitespace without a newline, and indent holds the original
// indentation of the line this token opens.
nl int
space bool
indent string
}
func (t *token) isComment() bool {
return t.kind == pg_query.Token_SQL_COMMENT || t.kind == pg_query.Token_C_COMMENT
}
// isLineComment reports a "--" comment, which swallows the rest of its line.
// A token after one has to start a new line.
func (t *token) isLineComment() bool {
return t.kind == pg_query.Token_SQL_COMMENT
}
type stmt struct {
start int
end int
node *pg_query.Node
}
// region is the definition list of a CREATE TABLE or a CREATE TYPE, from its
// opening parenthesis to the matching closing one.
type region struct {
open int
close int
}
// Format returns sql laid out. It fails when sql does not parse, and when the
// result would not carry the same tokens as the input.
func Format(sql string) (string, error) {
toks, err := scan(sql)
if err != nil {
return "", err
}
if len(toks) == 0 {
return sql, nil
}
stmts, err := split(sql)
if err != nil {
return "", err
}
unquote(sql, toks, stmts)
depth := parenDepth(toks)
out := render(toks, &layout{
regions: listRegions(toks, stmts, depth),
depth: depth,
body: viewBodies(toks, stmts, depth),
stmtStart: stmtStarts(toks, stmts),
stmtLead: stmtLeads(toks, stmts),
routineCon: routineConts(toks, stmts),
})
if err := verify(toks, out); err != nil {
return "", err
}
return out, nil
}
func scan(sql string) ([]*token, error) {
result, err := pg_query.Scan(sql)
if err != nil {
return nil, fmt.Errorf("failed to scan SQL: %w", err)
}
toks := make([]*token, 0, len(result.Tokens))
prev := 0
for _, t := range result.Tokens {
start, end := int(t.Start), int(t.End)
if start < prev || end > len(sql) || start > end {
return nil, fmt.Errorf("unexpected token range %d-%d", start, end)
}
gap := sql[prev:start]
tok := &token{
kind: t.Token,
orig: sql[start:end],
out: sql[start:end],
start: start,
end: end,
nl: strings.Count(gap, "\n"),
}
// A "--" comment runs to the end of its line, trailing whitespace
// included, and a line of the output carries none.
if tok.isLineComment() {
tok.out = strings.TrimRight(tok.out, " \t")
}
switch {
case tok.nl > 0:
tok.indent = gap[strings.LastIndex(gap, "\n")+1:]
case len(toks) == 0:
tok.indent = gap
default:
tok.space = gap != ""
}
toks = append(toks, tok)
prev = end
}
return toks, nil
}
// split returns one entry per statement. A statement pg_query reports no
// length for, which is what a file whose last statement has no semicolon
// gives, runs to the next statement or to the end of the input.
func split(sql string) ([]stmt, error) {
tree, err := pg_query.Parse(sql)
if err != nil {
return nil, err
}
stmts := make([]stmt, 0, len(tree.Stmts))
for i, raw := range tree.Stmts {
start := int(raw.StmtLocation)
end := start + int(raw.StmtLen)
if raw.StmtLen == 0 {
if i+1 < len(tree.Stmts) {
end = int(tree.Stmts[i+1].StmtLocation)
} else {
end = len(sql)
}
}
stmts = append(stmts, stmt{start: start, end: end, node: raw.Stmt})
}
return stmts, nil
}
// tokenRange returns the tokens of one statement, from the first that starts
// at or after it to the first that starts after it ends. The tokens are in
// order, so the ends are found by binary search: a linear scan per statement
// makes formatting quadratic in the size of the file, which a dump of a few
// thousand tables notices.
func tokenRange(toks []*token, s stmt) (int, int) {
lo := sort.Search(len(toks), func(i int) bool { return toks[i].start >= s.start })
hi := sort.Search(len(toks), func(i int) bool { return toks[i].start >= s.end })
return lo, hi
}
// nextCode returns the index of the first token at or after i that is not a
// comment.
func nextCode(toks []*token, i, hi int) int {
for ; i < hi; i++ {
if !toks[i].isComment() {
return i
}
}
return hi
}
func upper(t *token) string {
return strings.ToUpper(t.orig)
}
// parenDepth returns the bracket depth of every token. A square bracket counts
// as well as a parenthesis: the depth decides which commas separate the
// elements of a definition list, and the ones inside an ARRAY constructor
// separate its elements instead. A closing bracket carries the depth it leaves
// behind, so the line it opens lines up with whatever opened the group.
func parenDepth(toks []*token) []int {
depth := make([]int, len(toks))
level := 0
for i, t := range toks {
switch t.orig {
case "(", "[":
depth[i] = level
level++
case ")", "]":
if level > 0 {
level--
}
depth[i] = level
default:
depth[i] = level
}
}
return depth
}
// viewBodies marks the query of a view, a materialized view and a CREATE TABLE
// AS. PostgreSQL writes such a body back with an indentation of its own, which
// dump carries, so the depth rule is kept away from it.
func viewBodies(toks []*token, stmts []stmt, depth []int) []bool {
body := make([]bool, len(toks))
for _, s := range stmts {
if s.node.GetViewStmt() == nil && s.node.GetCreateTableAsStmt() == nil {
continue
}
lo, hi := tokenRange(toks, s)
if lo >= hi {
continue
}
base := depth[lo]
for i := lo; i < hi; i++ {
if !toks[i].isComment() && depth[i] == base && upper(toks[i]) == "AS" {
for j := i + 1; j < hi; j++ {
body[j] = true
}
break
}
}
}
return body
}
// routineConts returns the indentation of each token of a CREATE FUNCTION or
// CREATE PROCEDURE that is not the first of the statement, and "" for any
// other token. A line one of them opens is a continuation of the statement,
// which dump indents by one level. The statements of a BEGIN ATOMIC block sit
// one level further in, between the BEGIN ATOMIC and the END that close it.
func routineConts(toks []*token, stmts []stmt) []string {
cont := make([]string, len(toks))
for _, s := range stmts {
cfs := s.node.GetCreateFunctionStmt()
if cfs == nil {
continue
}
lo, hi := tokenRange(toks, s)
first := nextCode(toks, lo, hi)
for i := first + 1; i < hi; i++ {
cont[i] = indentUnit
}
if cfs.SqlBody.GetList() == nil {
continue
}
atomic, end := -1, -1
for i := first + 1; i < hi; i++ {
if toks[i].isComment() {
continue
}
if atomic < 0 && upper(toks[i]) == "BEGIN" {
if next := nextCode(toks, i+1, hi); next < hi && upper(toks[next]) == "ATOMIC" {
atomic = next
}
}
if upper(toks[i]) == "END" {
end = i
}
}
if atomic < 0 {
continue
}
for i := atomic + 1; i < end; i++ {
cont[i] = indentUnit + indentUnit
}
}
return cont
}
// listRegions marks the definition lists of the statements that have one, and
// records the parenthesis depth of every token inside them. A closing
// parenthesis carries the depth it leaves behind, so the line it opens lines
// up with the element that owns it.
func listRegions(toks []*token, stmts []stmt, depth []int) []*region {
regions := make([]*region, len(toks))
for _, s := range stmts {
lo, hi := tokenRange(toks, s)
open, ok := listOpen(toks, lo, hi, s.node)
if !ok {
continue
}
closeIdx, ok := matchParen(toks, open, hi, depth)
if !ok {
continue
}
r := ®ion{open: open, close: closeIdx}
for i := open; i <= closeIdx; i++ {
regions[i] = r
}
}
return regions
}
// matchParen returns the parenthesis that closes the one at open.
func matchParen(toks []*token, open, hi int, depth []int) (int, bool) {
for i := open + 1; i < hi; i++ {
if toks[i].orig == ")" && depth[i] == depth[open] {
return i, true
}
}
return 0, false
}
// listOpen returns the parenthesis that opens the statement's definition list.
// Only CREATE TABLE and the two CREATE TYPE forms have one; dump writes those
// three across several lines and every other statement on one.
func listOpen(toks []*token, lo, hi int, node *pg_query.Node) (int, bool) {
switch {
case node.GetCreateStmt() != nil:
return tableListOpen(toks, lo, hi)
case node.GetCompositeTypeStmt() != nil, node.GetCreateEnumStmt() != nil:
return typeListOpen(toks, lo, hi)
}
return 0, false
}
// tableListOpen finds the parenthesis that follows the table name. A partition
// child written as PARTITION OF has none, and neither has a typed table, so
// the name has to be walked rather than assumed.
func tableListOpen(toks []*token, lo, hi int) (int, bool) {
i := lo
for ; i < hi; i++ {
if !toks[i].isComment() && upper(toks[i]) == "TABLE" {
break
}
}
if i == hi {
return 0, false
}
i = nextCode(toks, i+1, hi)
if i+2 < hi && upper(toks[i]) == "IF" && upper(toks[i+1]) == "NOT" && upper(toks[i+2]) == "EXISTS" {
i = nextCode(toks, i+3, hi)
}
// The name is one part, or several joined by dots.
if i >= hi || toks[i].orig == "(" {
return 0, false
}
i = nextCode(toks, i+1, hi)
for i < hi && toks[i].orig == "." {
i = nextCode(toks, i+1, hi)
if i >= hi {
return 0, false
}
i = nextCode(toks, i+1, hi)
}
if i < hi && toks[i].orig == "(" {
return i, true
}
return 0, false
}
// typeListOpen finds the parenthesis after AS, or after AS ENUM.
func typeListOpen(toks []*token, lo, hi int) (int, bool) {
i := lo
for ; i < hi; i++ {
if !toks[i].isComment() && upper(toks[i]) == "AS" {
break
}
}
if i == hi {
return 0, false
}
i = nextCode(toks, i+1, hi)
if i < hi && upper(toks[i]) == "ENUM" {
i = nextCode(toks, i+1, hi)
}
if i < hi && toks[i].orig == "(" {
return i, true
}
return 0, false
}
var bareIdentPattern = regexp.MustCompile(`^[a-z_][a-z0-9_$]*$`)
// unquote drops the quotes around an identifier that reads the same without
// them. Whether it does depends on where the identifier sits: a column may be
// named name unquoted, a function may not be named precision unquoted. Rather
// than model the grammar, the statement is parsed again with the quotes gone
// and kept only when it deparses to what it deparsed to before.
func unquote(sql string, toks []*token, stmts []stmt) {
for _, s := range stmts {
lo, hi := tokenRange(toks, s)
var candidates []int
for i := lo; i < hi; i++ {
if _, ok := bareIdent(toks[i]); ok {
candidates = append(candidates, i)
}
}
if len(candidates) == 0 {
continue
}
text := sql[s.start:s.end]
before, ok := deparse(text)
if !ok {
continue
}
keeps := func(sel []int) bool {
after, ok := deparse(replaceTokens(text, s.start, toks, sel))
return ok && after == before
}
if keeps(candidates) {
applyUnquote(toks, candidates)
continue
}
// One of them changes the statement. Keep the ones that do not.
var safe []int
for _, i := range candidates {
if keeps([]int{i}) {
safe = append(safe, i)
}
}
if len(safe) > 0 && keeps(safe) {
applyUnquote(toks, safe)
}
}
}
// bareIdent returns the identifier's text without its quotes, when dropping
// them is worth trying at all. An identifier holding an upper-case letter
// folds to something else unquoted, so it keeps its quotes.
//
// The keyword categories follow model.quoteIdent, which is how dump decides:
// an unreserved or a col_name keyword is written bare, a reserved or a
// type_func_name keyword is quoted. Whether the position at hand really takes
// the bare spelling is settled by the caller, which parses the statement
// again.
func bareIdent(t *token) (string, bool) {
if t.kind != pg_query.Token_IDENT || len(t.orig) < 3 {
return "", false
}
if !strings.HasPrefix(t.orig, `"`) || !strings.HasSuffix(t.orig, `"`) {
return "", false
}
inner := strings.ReplaceAll(t.orig[1:len(t.orig)-1], `""`, `"`)
if !bareIdentPattern.MatchString(inner) {
return "", false
}
result, err := pg_query.Scan(inner)
if err != nil || len(result.Tokens) != 1 {
return "", false
}
switch result.Tokens[0].KeywordKind {
case pg_query.KeywordKind_NO_KEYWORD,
pg_query.KeywordKind_UNRESERVED_KEYWORD,
pg_query.KeywordKind_COL_NAME_KEYWORD:
return inner, true
default:
return "", false
}
}
func applyUnquote(toks []*token, sel []int) {
for _, i := range sel {
if inner, ok := bareIdent(toks[i]); ok {
toks[i].out = inner
}
}
}
// replaceTokens rewrites text, which starts at offset in the input, with the
// selected identifiers unquoted.
func replaceTokens(text string, offset int, toks []*token, sel []int) string {
var b strings.Builder
prev := 0
for _, i := range sel {
inner, ok := bareIdent(toks[i])
if !ok {
continue
}
start, end := toks[i].start-offset, toks[i].end-offset
if start < prev || end > len(text) {
continue
}
b.WriteString(text[prev:start])
b.WriteString(inner)
prev = end
}
b.WriteString(text[prev:])
return b.String()
}
func deparse(text string) (string, bool) {
tree, err := pg_query.Parse(text)
if err != nil {
return "", false
}
out, err := pg_query.Deparse(tree)
if err != nil {
return "", false
}
return out, true
}
// stmtStarts marks the first token of every statement, each of which opens a
// line of its own. A comment the input left after the previous semicolon is
// not moved, so the mark sits on the first token that is not one.
func stmtStarts(toks []*token, stmts []stmt) []bool {
starts := make([]bool, len(toks))
for _, s := range stmts {
lo, hi := tokenRange(toks, s)
if j := nextCode(toks, lo, hi); j < hi {
starts[j] = true
}
}
return starts
}
// stmtLeads marks the first token of every statement, the comments in front of
// it and the comments that end the file. A line one of them opens starts at the
// first column, as dump writes it, whatever indentation the input gave it. The
// comments that end the file are counted from the end, since a last statement
// with no semicolon runs to the end of the input and takes them in.
func stmtLeads(toks []*token, stmts []stmt) []bool {
lead := make([]bool, len(toks))
for _, s := range stmts {
lo, hi := tokenRange(toks, s)
for i := lo; i < hi; i++ {
lead[i] = true
if !toks[i].isComment() {
break
}
}
}
for i := len(toks) - 1; i >= 0 && toks[i].isComment(); i-- {
lead[i] = true
}
return lead
}
// layout holds what the renderer needs to know about each token: the
// definition list it belongs to, its parenthesis depth, whether it sits in a
// view body, whether it opens a statement, and whether it leads one.
type layout struct {
regions []*region
depth []int
body []bool
stmtStart []bool
stmtLead []bool
routineCon []string
}
// parenFrame remembers where an open parenthesis sits, so the lines inside it
// hang off the line that opened it and the closing parenthesis lines up with
// that line again.
type parenFrame struct {
openLine string
content string
}
func render(toks []*token, l *layout) string {
var lines []string
var cur strings.Builder
started := false
// lineIndent is the indentation of the line being built, and stack holds
// one frame per parenthesis that is still open.
lineIndent := ""
var stack []parenFrame
flush := func() {
if started {
lines = append(lines, strings.TrimRight(cur.String(), " \t"))
cur.Reset()
started = false
}
}
newline := func(indent string, blank bool) {
flush()
if blank {
lines = append(lines, "")
}
cur.WriteString(indent)
lineIndent = indent
started = true
}
indentFor := func(i int, t *token) string {
if l.stmtLead[i] {
return ""
}
if l.body[i] {
return t.indent
}
if len(stack) == 0 {
// A routine's clauses hang off its CREATE line, the way dump
// writes them. Everything else keeps the indentation it had.
if l.routineCon[i] != "" && t.orig != ")" {
return l.routineCon[i]
}
return t.indent
}
if t.orig == ")" {
return stack[len(stack)-1].openLine
}
return stack[len(stack)-1].content
}
// tailIndent is set once a definition list has been closed: whatever
// follows the closing parenthesis, an INHERITS or a PARTITION BY, opens a
// line of its own, lined up with that parenthesis. The semicolon that ends
// the statement stays where it is.
var tailIndent *string
// pending marks a break owed to the next token: after a line comment,
// which would otherwise swallow it, and after the comma that ends an
// element of a definition list.
pending := false
var prev *token
for i, t := range toks {
r := l.regions[i]
inList := r != nil && i > r.open && i <= r.close
listElem := inList && l.depth[i] == l.depth[r.open]+1
// owed marks the comma that still owes the break its element ends
// with. A comma that had to open a line of its own does not: the
// element after it follows on that line instead.
owed := listElem && t.orig == ","
switch {
case !started:
newline(indentFor(i, t), false)
pending = false
case owed:
// The comma belongs to the element it ends, whichever line the
// input put it on, unless that line ends in a comment, which
// would swallow it.
if pending {
newline(indentFor(i, t), false)
pending = false
owed = false
}
case inList:
sameLine := t.isComment() && t.nl == 0 && i != r.close
if (pending || i == r.open+1 || i == r.close || t.nl > 0) && !sameLine {
newline(indentFor(i, t), t.nl >= 2)
pending = false
} else if needsSpace(prev, t, l.body[i-1], l.body[i]) {
cur.WriteString(" ")
}
case tailIndent != nil && t.orig != ";" && (!t.isComment() || t.nl > 0):
newline(*tailIndent, t.nl >= 2)
pending = false
default:
if pending || t.nl > 0 || l.stmtStart[i] {
newline(indentFor(i, t), t.nl >= 2)
pending = false
} else if needsSpace(prev, t, l.body[i-1], l.body[i]) {
cur.WriteString(" ")
}
}
cur.WriteString(t.out)
switch t.orig {
case "(":
stack = append(stack, parenFrame{openLine: lineIndent, content: lineIndent + indentUnit})
case ")":
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
}
switch {
case inList && i == r.close:
tail := lineIndent
tailIndent = &tail
case t.isComment() && t.nl == 0:
// A comment left on the line does not answer the break the
// closing parenthesis owes.
default:
tailIndent = nil
}
if t.isLineComment() || owed {
pending = true
}
prev = t
}
flush()
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + "\n"
}
// needsSpace decides the separator between two tokens that share a line. The
// whitespace the input has is kept, collapsed to one space, and dropped before
// a comma and a semicolon and just inside a parenthesis, which is where
// pg_dump closes them up as well. Two tokens written with nothing between them
// stay that way unless one of them lost its quotes and the two would now read
// as one token.
//
// A view body keeps the spacing PostgreSQL gave it, so a dump reads back
// unchanged.
func needsSpace(prev, cur *token, prevBody, curBody bool) bool {
if cur.out == "," || cur.out == ";" {
return false
}
if closesUp(prev, cur) && !prevBody && !curBody && joins(prev.out, cur.out) {
return false
}
if cur.space {
return true
}
if prev == nil || (prev.out == prev.orig && cur.out == cur.orig) {
return false
}
return !joins(prev.out, cur.out)
}
// closesUp reports a pair that carries no space in the way PostgreSQL writes
// it: just inside a parenthesis, around the square brackets of an array type
// or a subscript, and on either side of a cast. Square brackets and "::" have
// no other use, so unlike a parenthesis, which follows a keyword as often as a
// name, they take one rule each.
//
// A comment is left where it is; closing the space up in front of one would
// not change what it comments out, but it reads as part of the comment.
func closesUp(prev, cur *token) bool {
if prev == nil || prev.isComment() || cur.isComment() {
return false
}
switch {
case prev.orig == "(", cur.orig == ")":
return true
case prev.orig == "[", cur.orig == "[", cur.orig == "]":
return true
case prev.orig == "::", cur.orig == "::":
return true
}
return false
}
// joins reports whether two adjacent tokens still read as those two tokens
// when nothing separates them.
func joins(a, b string) bool {
text := a + b
result, err := pg_query.Scan(text)
if err != nil || len(result.Tokens) != 2 {
return false
}
first := text[result.Tokens[0].Start:result.Tokens[0].End]
second := text[result.Tokens[1].Start:result.Tokens[1].End]
return first == a && second == b
}
// verify reports a formatted result that does not carry the tokens the input
// carried. Nothing is written when it fails, so a bug in the layout cannot
// damage a schema file.
func verify(toks []*token, out string) error {
result, err := pg_query.Scan(out)
if err != nil {
return fmt.Errorf("failed to scan the formatted SQL: %w", err)
}
if len(result.Tokens) != len(toks) {
return fmt.Errorf("formatting changed the token count: %d -> %d", len(toks), len(result.Tokens))
}
for i, t := range result.Tokens {
got := out[t.Start:t.End]
// An identifier that lost its quotes scans as the keyword it spells,
// so only a token that was left alone is compared by kind.
if got != toks[i].out || (toks[i].out == toks[i].orig && t.Token != toks[i].kind) {
return fmt.Errorf("formatting changed a token: %q -> %q", toks[i].out, got)
}
}
return nil
}
package format
import (
"fmt"
"regexp"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
)
var renamedFromPattern = regexp.MustCompile(`^--[ \t]*pista:renamed-from(?:[ \t\r]|$)`)
// StripRenamedFrom removes every "-- pista:renamed-from" line from sql. Only a
// comment on a line of its own is a directive, so a comment after code stays,
// and so does text inside a string or a routine body, which the scanner reads
// as part of that token.
func StripRenamedFrom(sql string) (string, error) {
result, err := pg_query.Scan(sql)
if err != nil {
return "", fmt.Errorf("failed to scan SQL: %w", err)
}
var b strings.Builder
prev := 0
for _, t := range result.Tokens {
if t.Token != pg_query.Token_SQL_COMMENT {
continue
}
start, end := int(t.Start), int(t.End)
if !renamedFromPattern.MatchString(sql[start:end]) {
continue
}
lineStart := strings.LastIndex(sql[:start], "\n") + 1
if strings.Trim(sql[lineStart:start], " \t") != "" {
continue
}
lineEnd := end
for lineEnd < len(sql) && (sql[lineEnd] == '\r' || sql[lineEnd] == ' ' || sql[lineEnd] == '\t') {
lineEnd++
}
if lineEnd < len(sql) && sql[lineEnd] == '\n' {
lineEnd++
}
b.WriteString(sql[prev:lineStart])
prev = lineEnd
}
b.WriteString(sql[prev:])
return b.String(), nil
}
// Package dsqlproxy is a PostgreSQL wire protocol proxy that makes a
// PostgreSQL server answer the way Aurora DSQL does, for the part of DSQL that
// pistachio's DSQL scenarios reach. It rewrites the text of each statement a
// client sends and leaves every other message alone:
//
// - CREATE INDEX ASYNC and ALTER TABLE ASYNC ... VALIDATE CONSTRAINT run at
// once and return a job ID, which sys.wait_for_job then reports on. The
// index stays invalid, and the constraint NOT VALID, until the wait.
// - A statement DSQL refuses fails with DSQL's SQLSTATE and message, and so
// does a second DDL statement in a transaction.
// - The catalog reads return the values DSQL reports: btree_index as the
// access method, every other column of a primary key's table as
// INCLUDE, and lz4 compression.
//
// The rules follow DSQL.md, which records what a live cluster answered. The
// proxy is a test tool. It does not replace the live cluster, and it knows
// nothing DSQL.md does not say.
package dsqlproxy
import (
"context"
_ "embed"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"sync/atomic"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgproto3"
)
//go:embed setup.sql
var setupSQL string
// Proxy accepts connections and relays each to a connection of its own to the
// backend.
type Proxy struct {
ln net.Listener
backend string
wg sync.WaitGroup
}
// Start sets the backend up, listens on addr and serves in the background.
// backend is the connection string of the PostgreSQL server behind the proxy.
func Start(ctx context.Context, addr, backend string) (*Proxy, error) {
if err := Setup(ctx, backend); err != nil {
return nil, err
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
p := &Proxy{ln: ln, backend: backend}
p.wg.Add(1)
go p.accept()
return p, nil
}
// Setup creates in the backend the objects the rewritten statements use.
func Setup(ctx context.Context, backend string) error {
conn, err := pgconn.Connect(ctx, backend)
if err != nil {
return err
}
defer conn.Close(ctx) //nolint:errcheck
if _, err := conn.Exec(ctx, setupSQL).ReadAll(); err != nil {
return fmt.Errorf("failed to set the backend up: %w", err)
}
return nil
}
// Addr returns the address the proxy listens on.
func (p *Proxy) Addr() net.Addr {
return p.ln.Addr()
}
// Close stops listening. Connections already open run until their client
// closes them.
func (p *Proxy) Close() error {
err := p.ln.Close()
p.wg.Wait()
return err
}
func (p *Proxy) accept() {
defer p.wg.Done()
for {
conn, err := p.ln.Accept()
if err != nil {
return
}
go p.serve(conn)
}
}
func (p *Proxy) serve(client net.Conn) {
defer client.Close() //nolint:errcheck
fe := pgproto3.NewBackend(client, client)
startup, err := receiveStartup(client, fe)
if err != nil || startup == nil {
return
}
// DSQL takes the read-only flag only as a statement after connecting.
if _, ok := startup.Parameters["default_transaction_read_only"]; ok {
sendError(fe, &pgconn.PgError{Severity: "FATAL", Code: "0A000", Message: msgDefaultReadOnly})
return
}
ctx := context.Background()
cfg, err := pgconn.ParseConfig(p.backend)
if err != nil {
sendError(fe, &pgconn.PgError{Severity: "FATAL", Code: "08000", Message: err.Error()})
return
}
for k, v := range startup.Parameters {
if k != "user" && k != "database" {
cfg.RuntimeParams[k] = v
}
}
conn, err := pgconn.ConnectConfig(ctx, cfg)
if err != nil {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
pgErr = &pgconn.PgError{Severity: "FATAL", Code: "08000", Message: err.Error()}
}
sendError(fe, pgErr)
return
}
hc, err := conn.Hijack()
if err != nil {
return
}
defer hc.Conn.Close() //nolint:errcheck
be := hc.Frontend
fe.Send(&pgproto3.AuthenticationOk{})
for k, v := range hc.ParameterStatuses {
// DSQL reports the bare version number.
if k == "server_version" {
v, _, _ = strings.Cut(v, " ")
}
fe.Send(&pgproto3.ParameterStatus{Name: k, Value: v})
}
fe.Send(&pgproto3.BackendKeyData{ProcessID: hc.PID, SecretKey: hc.SecretKey})
fe.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'})
if err := fe.Flush(); err != nil {
return
}
var txStatus atomic.Int32
txStatus.Store('I')
var mu sync.Mutex
sess := &session{inTx: func() bool { return txStatus.Load() != 'I' }}
tg := newTags()
done := make(chan struct{})
go func() {
defer close(done)
defer client.Close() //nolint:errcheck
for {
msg, err := be.Receive()
if err != nil {
return
}
mu.Lock()
switch m := msg.(type) {
case *pgproto3.CommandComplete:
if tag := tg.complete(); tag != "" {
m.CommandTag = []byte(tag)
}
case *pgproto3.ReadyForQuery:
tg.ready()
txStatus.Store(int32(m.TxStatus))
if m.TxStatus == 'I' {
sess.txEnded()
}
}
mu.Unlock()
fe.Send(msg)
if err := fe.Flush(); err != nil {
return
}
}
}()
for {
msg, err := fe.Receive()
if err != nil {
break
}
mu.Lock()
switch m := msg.(type) {
case *pgproto3.Query:
var stmtTags []string
m.String, stmtTags = sess.rewrite(m.String)
tg.query(stmtTags)
case *pgproto3.Parse:
var stmtTags []string
m.Query, stmtTags = sess.rewrite(m.Query)
tg.parse(m.Name, stmtTags)
case *pgproto3.Bind:
tg.bind(m.DestinationPortal, m.PreparedStatement)
case *pgproto3.Execute:
tg.execute(m.Portal)
case *pgproto3.Sync:
tg.sync()
}
mu.Unlock()
be.Send(msg)
if err := be.Flush(); err != nil {
break
}
if _, ok := msg.(*pgproto3.Terminate); ok {
break
}
}
hc.Conn.Close() //nolint:errcheck
<-done
}
// receiveStartup answers the requests a client may send before its startup
// message: the proxy speaks neither TLS nor GSS encryption. It returns nil
// for a cancel request, which the proxy does not pass on.
func receiveStartup(client io.Writer, fe *pgproto3.Backend) (*pgproto3.StartupMessage, error) {
for {
msg, err := fe.ReceiveStartupMessage()
if err != nil {
return nil, err
}
switch m := msg.(type) {
case *pgproto3.SSLRequest, *pgproto3.GSSEncRequest:
if _, err := client.Write([]byte("N")); err != nil {
return nil, err
}
case *pgproto3.StartupMessage:
return m, nil
default:
return nil, nil
}
}
}
func sendError(fe *pgproto3.Backend, e *pgconn.PgError) {
fe.Send(&pgproto3.ErrorResponse{Severity: e.Severity, SeverityUnlocalized: e.Severity, Code: e.Code, Message: e.Message})
fe.Flush() //nolint:errcheck
}
package dsqlproxy
import (
"regexp"
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
)
// The messages come from DSQL.md, which records what a live cluster
// answered. TestConformance checks them against the cluster.
const (
msgAsyncIndex = "unsupported mode. please use CREATE INDEX ASYNC."
msgIndexUsing = "USING not supported for CREATE INDEX"
msgIndexConcurrent = "CONCURRENTLY not supported for CREATE INDEX"
msgDropConcurrent = "DROP INDEX CONCURRENTLY must be first action in transaction"
msgAddColumn = "ALTER TABLE ADD COLUMN with constraint not supported"
msgCollate = "COLLATE clause not supported"
msgCompression = "COMPRESSION clause not supported"
msgSetNotNull = "unsupported ALTER TABLE ALTER COLUMN ... SET NOT NULL statement"
msgSetDataType = "unsupported ALTER TABLE ALTER COLUMN ... SET DATA TYPE statement"
msgSetCompression = "unsupported ALTER TABLE ALTER COLUMN ... SET COMPRESSION statement"
msgAddConstraint = "unsupported ALTER TABLE ADD CONSTRAINT statement"
msgValidate = "unsupported ALTER TABLE VALIDATE CONSTRAINT statement"
msgAsyncAlter = "ALTER TABLE ASYNC not supported"
msgRowSecurity = "unsupported ALTER TABLE ENABLE ROW SECURITY statement"
msgPartitionBy = "PARTITION BY clause not supported for CREATE TABLE"
msgPartitionOf = "PARTITION OF clause not supported for CREATE TABLE"
msgIdentityCache = "identity column is not supported without an explicit cache size. please define CACHE greater than or equal to 65536 or equal to 1"
msgSequenceCache = "CREATE SEQUENCE is not supported without an explicit cache size. please define CACHE greater than or equal to 65536 or equal to 1"
msgDomainCheck = "ADD CONSTRAINT clause not supported for ALTER DOMAIN"
msgDomainNotNull = "SET NOT NULL clause not supported for ALTER DOMAIN"
msgMultipleDDL = "multiple ddl statements not supported in a transaction"
msgDefaultReadOnly = `setting configuration parameter "default_transaction_read_only" not supported`
msgAdvisoryLockFunc = "function %s not supported"
)
var (
asyncIndexRe = regexp.MustCompile(`(?is)^(\s*create\s+(?:unique\s+)?index)\s+async\b(.*)$`)
asyncAlterRe = regexp.MustCompile(`(?is)^(\s*alter\s+table)\s+async\b(.*)$`)
indexUsingRe = regexp.MustCompile(`(?is)\bon\s+(?:only\s+)?[^(]*\busing\b`)
// The catalog values DSQL reports differently are read through the
// functions setup.sql defines in their place.
catalogFuncRe = regexp.MustCompile(`(?i)\b(?:pg_catalog\.)?(pg_get_constraintdef|pg_get_indexdef)\s*\(`)
attcompressRe = regexp.MustCompile(`(?i)\b([a-z_][a-z0-9_]*)\.attcompression\b`)
advisoryLockRe = regexp.MustCompile(`(?i)\b(pg_(?:try_)?advisory_(?:xact_)?lock(?:_shared)?)\s*\(`)
)
// session is the state of one client connection the rules need: how many DDL
// statements the current transaction has run.
type session struct {
inTx func() bool
ddlCount int
}
// rewrite returns the text to send to the backend in place of sql, and for
// each statement in it the command tag DSQL answers with, or "" to keep the
// backend's. A statement that needs no change is sent as it is.
func (s *session) rewrite(sql string) (string, []string) {
stmts, err := pg_query.SplitWithScanner(sql, true)
if err != nil || len(stmts) == 0 {
return sql, []string{""}
}
changed := false
out := make([]string, len(stmts))
tags := make([]string, len(stmts))
for i, stmt := range stmts {
// The statements of one query string run in one transaction.
out[i], tags[i] = s.rewriteStmt(stmt, i > 0)
changed = changed || out[i] != stmt
}
if !changed {
return sql, tags
}
return strings.Join(out, ";\n"), tags
}
func (s *session) rewriteStmt(stmt string, implicitTx bool) (string, string) {
if m := asyncIndexRe.FindStringSubmatch(stmt); m != nil {
body := m[1] + m[2]
if msg := asyncIndexRefusal(body); msg != "" {
return refuse(msg), ""
}
if msg := s.countDDL(implicitTx); msg != "" {
return refuse(msg), ""
}
return runJob("INDEX_BUILD", body), "CREATE INDEX"
}
if m := asyncAlterRe.FindStringSubmatch(stmt); m != nil {
body := m[1] + m[2]
if !isValidate(body) {
return refuse(msgAsyncAlter), ""
}
if msg := s.countDDL(implicitTx); msg != "" {
return refuse(msg), ""
}
return runJob("VALIDATE_CONSTRAINT", body), "ALTER TABLE"
}
result, err := pg_query.Parse(stmt)
if err != nil || len(result.Stmts) != 1 {
// The backend reports the error.
return stmt, ""
}
node := result.Stmts[0].Stmt
if msg := refusal(node); msg != "" {
return refuse(msg), ""
}
if isDDL(node) {
if msg := s.countDDL(implicitTx); msg != "" {
return refuse(msg), ""
}
if guard := dropPkeyGuard(node); guard != "" {
return "DO $dsql_proxy$BEGIN " + guard + " EXECUTE " + quoteLiteral(stmt) + "; END$dsql_proxy$", "ALTER TABLE"
}
return stmt, ""
}
if node.GetSelectStmt() != nil {
if m := advisoryLockRe.FindStringSubmatch(stmt); m != nil {
return refuse(strings.Replace(msgAdvisoryLockFunc, "%s", strings.ToLower(m[1]), 1)), ""
}
stmt = catalogFuncRe.ReplaceAllString(stmt, "dsql_proxy.$1(")
stmt = attcompressRe.ReplaceAllString(stmt, "dsql_proxy.attcompression($1)")
}
return stmt, ""
}
// countDDL counts a DDL statement and refuses the second one in a
// transaction.
func (s *session) countDDL(implicitTx bool) string {
s.ddlCount++
if s.ddlCount > 1 && (implicitTx || s.inTx()) {
return msgMultipleDDL
}
return ""
}
// txEnded starts the count again once the backend is out of a transaction.
func (s *session) txEnded() {
s.ddlCount = 0
}
func asyncIndexRefusal(body string) string {
result, err := pg_query.Parse(body)
if err != nil || len(result.Stmts) != 1 {
return ""
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return ""
}
if is.GetConcurrent() {
return msgIndexConcurrent
}
if indexUsingRe.MatchString(body) {
return msgIndexUsing
}
return ""
}
func isValidate(body string) bool {
result, err := pg_query.Parse(body)
if err != nil || len(result.Stmts) != 1 {
return false
}
at := result.Stmts[0].Stmt.GetAlterTableStmt()
if at == nil || len(at.GetCmds()) != 1 {
return false
}
return at.GetCmds()[0].GetAlterTableCmd().GetSubtype() == pg_query.AlterTableType_AT_ValidateConstraint
}
// refusal returns the message DSQL refuses the statement with, or "" when
// DSQL takes it.
func refusal(node *pg_query.Node) string {
switch {
case node.GetIndexStmt() != nil:
if node.GetIndexStmt().GetConcurrent() {
return msgIndexConcurrent
}
return msgAsyncIndex
case node.GetDropStmt() != nil:
ds := node.GetDropStmt()
if ds.GetConcurrent() && ds.GetRemoveType() == pg_query.ObjectType_OBJECT_INDEX {
return msgDropConcurrent
}
case node.GetAlterTableStmt() != nil:
at := node.GetAlterTableStmt()
if at.GetObjtype() != pg_query.ObjectType_OBJECT_TABLE {
return ""
}
for _, c := range at.GetCmds() {
if msg := alterTableCmdRefusal(c.GetAlterTableCmd()); msg != "" {
return msg
}
}
case node.GetCreateStmt() != nil:
cs := node.GetCreateStmt()
if cs.GetPartspec() != nil {
return msgPartitionBy
}
if cs.GetPartbound() != nil {
return msgPartitionOf
}
for _, elt := range cs.GetTableElts() {
col := elt.GetColumnDef()
if col == nil {
continue
}
if col.GetCollClause() != nil {
return msgCollate
}
if col.GetCompression() != "" {
return msgCompression
}
for _, c := range col.GetConstraints() {
con := c.GetConstraint()
if con.GetContype() == pg_query.ConstrType_CONSTR_IDENTITY && !hasOption(con.GetOptions(), "cache") {
return msgIdentityCache
}
}
}
case node.GetCreateSeqStmt() != nil:
if !hasOption(node.GetCreateSeqStmt().GetOptions(), "cache") {
return msgSequenceCache
}
case node.GetAlterDomainStmt() != nil:
switch node.GetAlterDomainStmt().GetSubtype() {
case "C":
return msgDomainCheck
case "O":
return msgDomainNotNull
}
case node.GetVariableSetStmt() != nil:
if node.GetVariableSetStmt().GetName() == "default_transaction_read_only" {
return msgDefaultReadOnly
}
case node.GetCreateEnumStmt() != nil:
return "CREATE TYPE not supported"
case node.GetCompositeTypeStmt() != nil:
return "unsupported statement: CompositeType"
case node.GetCreatePolicyStmt() != nil:
return "unsupported statement: CreatePolicy"
case node.GetCreateExtensionStmt() != nil:
return "unsupported statement: CreateExtension"
case node.GetCreateTableAsStmt() != nil:
return "unsupported statement: CreateTableAs"
}
return ""
}
func alterTableCmdRefusal(cmd *pg_query.AlterTableCmd) string {
switch cmd.GetSubtype() {
case pg_query.AlterTableType_AT_AddColumn:
col := cmd.GetDef().GetColumnDef()
if col.GetCollClause() != nil {
return msgCollate
}
if col.GetCompression() != "" {
return msgCompression
}
if len(col.GetConstraints()) > 0 {
return msgAddColumn
}
case pg_query.AlterTableType_AT_SetNotNull:
return msgSetNotNull
case pg_query.AlterTableType_AT_AlterColumnType:
return msgSetDataType
case pg_query.AlterTableType_AT_SetCompression:
return msgSetCompression
case pg_query.AlterTableType_AT_ValidateConstraint:
return msgValidate
case pg_query.AlterTableType_AT_EnableRowSecurity, pg_query.AlterTableType_AT_ForceRowSecurity:
return msgRowSecurity
case pg_query.AlterTableType_AT_AddConstraint:
con := cmd.GetDef().GetConstraint()
switch con.GetContype() {
case pg_query.ConstrType_CONSTR_UNIQUE:
if con.GetIndexname() == "" {
return msgAddConstraint
}
case pg_query.ConstrType_CONSTR_CHECK, pg_query.ConstrType_CONSTR_FOREIGN:
if !con.GetSkipValidation() {
return msgAddConstraint
}
default:
return msgAddConstraint
}
}
return ""
}
// dropPkeyGuard returns the call that refuses the drop of a primary key, for
// an ALTER TABLE that drops a constraint. Whether the constraint is the
// primary key is known only to the backend.
func dropPkeyGuard(node *pg_query.Node) string {
at := node.GetAlterTableStmt()
if at == nil || at.GetObjtype() != pg_query.ObjectType_OBJECT_TABLE {
return ""
}
rel := quoteIdent(at.GetRelation().GetRelname())
if s := at.GetRelation().GetSchemaname(); s != "" {
rel = quoteIdent(s) + "." + rel
}
var guard strings.Builder
for _, c := range at.GetCmds() {
cmd := c.GetAlterTableCmd()
if cmd.GetSubtype() == pg_query.AlterTableType_AT_DropConstraint {
guard.WriteString("PERFORM dsql_proxy.refuse_drop_pkey(")
guard.WriteString(quoteLiteral(rel))
guard.WriteString(", ")
guard.WriteString(quoteLiteral(cmd.GetName()))
guard.WriteString(");")
}
}
return guard.String()
}
// isDDL reports a statement that DSQL counts toward its one DDL statement per
// transaction.
func isDDL(node *pg_query.Node) bool {
switch {
case node.GetSelectStmt() != nil,
node.GetInsertStmt() != nil,
node.GetUpdateStmt() != nil,
node.GetDeleteStmt() != nil,
node.GetMergeStmt() != nil,
node.GetTransactionStmt() != nil,
node.GetVariableSetStmt() != nil,
node.GetVariableShowStmt() != nil,
node.GetCallStmt() != nil,
node.GetExplainStmt() != nil,
node.GetCopyStmt() != nil,
node.GetPrepareStmt() != nil,
node.GetExecuteStmt() != nil,
node.GetDeallocateStmt() != nil,
node.GetDiscardStmt() != nil,
node.GetLockStmt() != nil,
node.GetDoStmt() != nil:
return false
}
return true
}
func hasOption(opts []*pg_query.Node, name string) bool {
return slices.ContainsFunc(opts, func(o *pg_query.Node) bool {
return o.GetDefElem().GetDefname() == name
})
}
func runJob(jobType, stmt string) string {
return "SELECT job_id FROM dsql_proxy.run_job(" + quoteLiteral(jobType) + ", " + quoteLiteral(stmt) + ")"
}
func refuse(msg string) string {
return "SELECT dsql_proxy.refuse('0A000', " + quoteLiteral(msg) + ")"
}
func quoteLiteral(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
package dsqlproxy
// tags puts back the command tag DSQL answers with for a statement the proxy
// rewrote: CREATE INDEX ASYNC answers CREATE INDEX on DSQL, where the
// rewritten SELECT answers SELECT 1.
//
// Each statement that runs gets one entry, in the order the client sent them,
// and each CommandComplete takes the first one. The entries of a simple query
// or of the extended messages up to a Sync form a batch, which the backend
// ends with ReadyForQuery. An error skips the rest of a batch, so
// ReadyForQuery drops what is left of it.
type tags struct {
stmts map[string]string // prepared statement -> tag
portals map[string]string // portal -> tag
batches [][]string
open bool // the last batch takes extended messages until a Sync
}
func newTags() *tags {
return &tags{stmts: map[string]string{}, portals: map[string]string{}}
}
// query adds the batch of a simple query.
func (t *tags) query(stmtTags []string) {
t.open = false
t.batches = append(t.batches, stmtTags)
}
// parse records the tag of a prepared statement. An extended query holds one
// statement.
func (t *tags) parse(name string, stmtTags []string) {
tag := ""
if len(stmtTags) == 1 {
tag = stmtTags[0]
}
t.stmts[name] = tag
}
func (t *tags) bind(portal, stmt string) {
t.portals[portal] = t.stmts[stmt]
}
func (t *tags) execute(portal string) {
if !t.open {
t.batches = append(t.batches, nil)
t.open = true
}
last := len(t.batches) - 1
t.batches[last] = append(t.batches[last], t.portals[portal])
}
func (t *tags) sync() {
t.open = false
}
// complete returns the tag for the next CommandComplete, or "" to keep the
// backend's.
func (t *tags) complete() string {
if len(t.batches) == 0 || len(t.batches[0]) == 0 {
return ""
}
tag := t.batches[0][0]
t.batches[0] = t.batches[0][1:]
return tag
}
// ready ends the first batch.
func (t *tags) ready() {
if len(t.batches) > 0 {
t.batches = t.batches[1:]
}
}
// Package gitfile reads schema files out of a git repository, so a diff can
// compare two revisions of a file without the caller writing them out first.
// It shells out to git rather than reading the object store, which keeps
// every revision spelling git accepts working, worktrees and submodules
// included.
package gitfile
import (
"bytes"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// Range is the pair of revisions a diff reads. Desired is empty when the
// desired side is the working tree rather than a revision.
type Range struct {
Current string
Desired string
}
// ParseRange turns a git range into the two revisions to compare, the way
// git diff reads one:
//
// A..B A against B
// A...B B against its merge base with A
// A A against the working tree
//
// An omitted side of A..B or A...B is HEAD. git is never given the range
// itself, so any spelling it accepts for a single revision works, HEAD^ and
// HEAD@{yesterday} included; a ref name cannot hold "..", so splitting on it
// does not cut one in half.
//
// The revisions are verified here, which separates a revision that does not
// exist from a file that is not in it.
func ParseRange(spec string) (*Range, error) {
if strings.TrimSpace(spec) == "" {
return nil, errors.New("empty git range")
}
left, right, mergeBased := splitRange(spec)
// Both endpoints are verified before the merge base is asked for, so a
// revision that does not exist is reported the same way whichever form
// the range was written in.
for _, rev := range []string{left, right} {
if rev == "" {
continue
}
if err := verify(rev); err != nil {
return nil, err
}
}
if !mergeBased {
return &Range{Current: left, Desired: right}, nil
}
base, err := mergeBase(left, right)
if err != nil {
return nil, err
}
return &Range{Current: base, Desired: right}, nil
}
// splitRange returns the two endpoints of the range and whether the current
// side is their merge base. A bare revision has no desired revision: the
// working tree stands in for it, which the empty string says.
func splitRange(spec string) (left, right string, mergeBased bool) {
if l, r, ok := strings.Cut(spec, "..."); ok {
return orHEAD(l), orHEAD(r), true
}
if l, r, ok := strings.Cut(spec, ".."); ok {
return orHEAD(l), orHEAD(r), false
}
return spec, "", false
}
// Read returns the contents of path at rev. ok is false when rev does not
// hold the path, which a diff takes as an empty file: the path was added or
// removed between the two revisions.
func Read(rev, path string) (string, bool, error) {
object := rev + ":" + gitPath(path)
kind, found := objectType(object)
if !found {
return "", false, nil
}
if kind != "blob" {
return "", false, fmt.Errorf("%s is not a file in %s", path, rev)
}
sql, err := run("show", "--end-of-options", object)
if err != nil {
return "", false, err
}
return sql, true, nil
}
// gitPath writes path the way git resolves it against the working directory
// rather than against the repository root, so the path on the command line is
// the path git looks up. An absolute path is made relative first, since the
// "./" prefix that asks for it cannot carry one.
func gitPath(path string) string {
if filepath.IsAbs(path) {
if wd, err := filepath.Abs("."); err == nil {
if rel, err := filepath.Rel(wd, path); err == nil {
path = rel
}
}
}
return "./" + filepath.ToSlash(path)
}
// objectType returns the type of the object, and false when git does not find
// it. The revision has been verified by then, so an object git cannot reach
// is a path the revision does not hold.
func objectType(object string) (string, bool) {
out, err := run("cat-file", "-t", "--end-of-options", object)
if err != nil {
return "", false
}
return strings.TrimSpace(out), true
}
func orHEAD(rev string) string {
if rev == "" {
return "HEAD"
}
return rev
}
// verify fails unless rev names a commit git can reach.
func verify(rev string) error {
if _, err := run("rev-parse", "--verify", "--end-of-options", rev+"^{commit}"); err != nil {
return fmt.Errorf("git revision %s: %w", rev, err)
}
return nil
}
// mergeBase resolves the current side of a three-dot range. git reports two
// revisions with no merge base by exit status 1 and nothing on stderr, so
// that case is named here rather than left as a bare "exit status 1".
func mergeBase(left, right string) (string, error) {
out, err := run("merge-base", "--end-of-options", left, right)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
err = errors.New("the two share no history")
}
return "", fmt.Errorf("git merge-base %s %s: %w", left, right, err)
}
return strings.TrimSpace(out), nil
}
// run returns what git wrote to stdout, byte for byte: the output is a file's
// contents as often as it is a revision.
func run(args ...string) (string, error) {
cmd := exec.Command("git", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return "", errors.New(strings.TrimPrefix(msg, "fatal: "))
}
return "", err
}
return stdout.String(), nil
}
package jsonschema
import (
"fmt"
"reflect"
"strings"
"github.com/invopop/jsonschema"
)
// markNullable widens every property whose Go field is a pointer to accept
// null. A field that holds its zero value is written rather than left out, so
// an unset pointer reaches the reader as null and a schema that named only the
// pointed-to type would reject the output.
//
// The walk starts at the root struct and follows the definitions the reflector
// wrote, so a type reached only through an ordered map is covered as well.
// types is what definitionTypes returns for root.
func markNullable(schema *jsonschema.Schema, root reflect.Type, types map[string]reflect.Type) error {
// The root is expanded into the document itself rather than a definition.
if err := markStruct(schema, root); err != nil {
return err
}
for name, def := range schema.Definitions {
t, ok := types[name]
if !ok {
return fmt.Errorf("jsonschema: no Go type for definition %q", name)
}
if err := markStruct(def, t); err != nil {
return err
}
}
return nil
}
// markStruct widens the properties of one struct's schema.
func markStruct(schema *jsonschema.Schema, t reflect.Type) error {
if schema.Properties == nil {
return nil
}
for _, f := range reflect.VisibleFields(t) {
pointer := f.Type.Kind() == reflect.Pointer
// A slice of pointers to a plain value writes an unset element as
// null, as an index's columns does for an expression. A slice of
// pointers to a struct, such as a routine's arguments, holds no nil.
pointerItems := f.Type.Kind() == reflect.Slice && f.Type.Elem().Kind() == reflect.Pointer &&
f.Type.Elem().Elem().Kind() != reflect.Struct
if !pointer && !pointerItems {
continue
}
name, ok := jsonName(f)
if !ok {
continue
}
prop, ok := schema.Properties.Get(name)
if !ok {
return fmt.Errorf("jsonschema: %s has no property %q", t.Name(), name)
}
if pointer {
schema.Properties.Set(name, nullable(prop))
} else if prop.Items != nil {
prop.Items = nullable(prop.Items)
}
}
return nil
}
// nullable returns a schema that accepts what prop accepts, and null.
func nullable(prop *jsonschema.Schema) *jsonschema.Schema {
return &jsonschema.Schema{
OneOf: []*jsonschema.Schema{prop, {Type: "null"}},
}
}
// definitionTypes maps the name the reflector gives a definition to the Go
// type behind it, for every struct reachable from root.
func definitionTypes(root reflect.Type) (map[string]reflect.Type, error) {
types := map[string]reflect.Type{}
var walk func(reflect.Type) error
walk = func(t reflect.Type) error {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
t = t.Elem()
}
if value, ok := orderedMapValue(t); ok {
return walk(value)
}
if t.Kind() != reflect.Struct || t.PkgPath() == "" {
return nil
}
name := t.Name()
if seen, ok := types[name]; ok {
if seen != t {
return fmt.Errorf("jsonschema: two types named %q: %s and %s", name, seen, t)
}
return nil
}
types[name] = t
for _, f := range reflect.VisibleFields(t) {
if _, ok := jsonName(f); !ok {
continue
}
if err := walk(f.Type); err != nil {
return err
}
}
return nil
}
if err := walk(root); err != nil {
return nil, err
}
return types, nil
}
// jsonName returns the name a field is written under, and false when it is not
// written at all.
func jsonName(f reflect.StructField) (string, bool) {
if !f.IsExported() {
return "", false
}
tag, ok := f.Tag.Lookup("json")
if !ok {
return "", false
}
name, _, _ := strings.Cut(tag, ",")
if name == "" || name == "-" {
return "", false
}
return name, true
}
// Package jsonschema builds the JSON Schema of the document `pista parse`
// writes. It reflects over document.Document, so the schema follows the
// structs rather than a description kept beside them.
package jsonschema
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/invopop/jsonschema"
"github.com/winebarrel/pistachio/document"
)
// Version is the version the schema's file name and URL carry.
const Version = "1.4"
// Path is where the file sits in the repository and on the documentation site.
const Path = "docs/json/schema-" + Version + ".json"
// ID is the URL the documentation site carries the schema at.
const ID = "https://winebarrel.github.io/pistachio/json/schema-" + Version + ".json"
const (
title = "pistachio schema document"
description = "The PostgreSQL schema objects pistachio manages, as it writes them in JSON."
)
// Marshal returns the schema as the file holds it: indented, and with the
// trailing newline a text file ends on.
func Marshal() ([]byte, error) {
schema, err := Build()
if err != nil {
return nil, err
}
b, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// Build returns the schema of a `pista parse` document.
func Build() (*jsonschema.Schema, error) {
r := &jsonschema.Reflector{
// The document is the Document itself, not a $ref to it.
ExpandedStruct: true,
// A field absent from the JSON is not a thing the output has: every
// field is written, so every field is required.
RequiredFromJSONSchemaTags: false,
}
r.Mapper = mapper
root := reflect.TypeFor[document.Document]()
schema := r.Reflect(&document.Document{})
schema.ID = ID
schema.Title = title
schema.Description = description
// The mapper answers for an ordered map with a $ref, so the reflector
// never walks into the type it holds and never writes its definition.
// Reflect each reachable type and collect what they define. This one
// leaves the type it is given in the definitions rather than expanding it
// into the document.
types, err := definitionTypes(root)
if err != nil {
return nil, err
}
defs := &jsonschema.Reflector{Mapper: mapper}
if err := addDefinitions(defs, schema, root, types); err != nil {
return nil, err
}
if err := markNullable(schema, root, types); err != nil {
return nil, err
}
return schema, nil
}
// addDefinitions writes a definition for every struct reachable from root that
// the reflector left out. types is what definitionTypes returns for root.
func addDefinitions(r *jsonschema.Reflector, schema *jsonschema.Schema, root reflect.Type, types map[string]reflect.Type) error {
if schema.Definitions == nil {
schema.Definitions = jsonschema.Definitions{}
}
for name, t := range types {
if t == root {
continue
}
for defName, def := range r.ReflectFromType(t).Definitions {
if _, ok := schema.Definitions[defName]; !ok {
schema.Definitions[defName] = def
}
}
if _, ok := schema.Definitions[name]; !ok {
return fmt.Errorf("jsonschema: %s produced no definition named %q", t, name)
}
}
return nil
}
// mapper answers for the types reflection alone reads wrong: a byte that
// marshals as a word, and the ordered map, whose entries live in unexported
// fields.
func mapper(t reflect.Type) *jsonschema.Schema {
if values := enumValues(t); values != nil {
return &jsonschema.Schema{Type: "string", Enum: toAny(values)}
}
if valueType, ok := orderedMapValue(t); ok {
value, err := refFor(valueType)
if err != nil {
// The mapper cannot fail, so a value type it does not know is
// left to the reflector, which describes it as best it can. Build
// catches it: the definition the $ref would have named is missing.
return nil
}
return &jsonschema.Schema{Type: "object", AdditionalProperties: value}
}
return nil
}
// enumValues returns the words a byte-backed MarshalJSON can write, asked of
// it for every value the byte holds, and nil for any other type. Deriving them
// keeps the schema and the marshaler from drifting apart.
func enumValues(t reflect.Type) []string {
if t.Kind() != reflect.Uint8 || t.PkgPath() == "" {
return nil
}
seen := map[string]bool{}
values := []string{}
for i := range 256 {
v := reflect.New(t).Elem()
v.SetUint(uint64(i))
m, ok := reflect.TypeAssert[json.Marshaler](v)
if !ok {
return nil
}
b, err := m.MarshalJSON()
if err != nil {
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
// The type marshals as something other than a string, so it is
// not one of the words.
return nil
}
if !seen[s] {
seen[s] = true
values = append(values, s)
}
}
return values
}
// orderedMapValue reports the value type of an orderedmap.Map. The entries sit
// in unexported fields, so the type argument is read off the map that indexes
// them rather than from a field of the value type.
func orderedMapValue(t reflect.Type) (reflect.Type, bool) {
if t.Kind() != reflect.Struct || !strings.HasPrefix(t.String(), "orderedmap.Map[") {
return nil, false
}
f, ok := t.FieldByName("elementByKey")
if !ok {
return nil, false
}
// map[K]*linkedlist.Element[*orderedmap.Pair[K, V]]
element := deref(f.Type.Elem())
held, ok := element.FieldByName("Value")
if !ok {
return nil, false
}
pair := deref(held.Type)
value, ok := pair.FieldByName("Value")
if !ok {
return nil, false
}
return value.Type, true
}
// deref strips the pointers off a type.
func deref(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t
}
// refFor names the definition of a type the way the reflector does, or the
// JSON type a scalar is written as.
func refFor(t reflect.Type) (*jsonschema.Schema, error) {
t = deref(t)
if t.Kind() == reflect.Struct {
return &jsonschema.Schema{Ref: "#/$defs/" + t.Name()}, nil
}
name, err := jsonType(t)
if err != nil {
return nil, err
}
return &jsonschema.Schema{Type: name}, nil
}
// jsonType is the JSON type a Go scalar is written as. A kind it does not name
// is an error rather than a guess: calling it an integer would put a wrong
// type in a published schema, which is worse than failing the generator.
func jsonType(t reflect.Type) (string, error) {
switch t.Kind() {
case reflect.String:
return "string", nil
case reflect.Bool:
return "boolean", nil
case reflect.Float32, reflect.Float64:
return "number", nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer", nil
default:
return "", fmt.Errorf("jsonschema: no JSON type for %s", t)
}
}
func toAny(values []string) []any {
out := make([]any, len(values))
for i, v := range values {
out[i] = v
}
return out
}
// Package pgast provides shared pg_query AST helpers used by both the
// rename rewriter (diff package) and the column-reference validator
// (parser package).
package pgast
import (
"errors"
"fmt"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
)
// ParseTreeVersion is the parse tree version the bundled libpg_query writes.
// Deparse rejects a ParseResult with any other Version, so a ParseResult
// built by hand must set it. An empty string always parses.
var ParseTreeVersion = func() int32 {
result, _ := pg_query.Parse("")
return result.GetVersion()
}()
// constraintWrapPrefix is the wrapper used to parse a constraint definition
// fragment (e.g. "PRIMARY KEY (id)") as part of a full statement so that
// pg_query produces a Constraint node we can inspect or mutate.
const constraintWrapPrefix = "ALTER TABLE _t ADD CONSTRAINT _c "
// ParseConstraintDef parses a constraint definition fragment and returns
// the underlying *pg_query.Constraint. Returns nil for unparseable or
// unexpected input so callers can degrade gracefully.
func ParseConstraintDef(def string) *pg_query.Constraint {
_, con, err := ParseConstraintDefStrict(def)
if err != nil {
return nil
}
return con
}
// ParseConstraintDefStrict is like ParseConstraintDef but also returns the
// full ParseResult and a typed error, so callers that need to mutate the
// AST and deparse it back can distinguish failure modes.
func ParseConstraintDefStrict(def string) (*pg_query.ParseResult, *pg_query.Constraint, error) {
result, err := pg_query.Parse(constraintWrapPrefix + def)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse constraint definition: %w", err)
}
if len(result.Stmts) == 0 {
return nil, nil, errors.New("empty constraint definition")
}
as := result.Stmts[0].Stmt.GetAlterTableStmt()
if as == nil || len(as.Cmds) == 0 {
return nil, nil, errors.New("unexpected parse result for constraint definition")
}
con := as.Cmds[0].GetAlterTableCmd().GetDef().GetConstraint()
if con == nil {
return nil, nil, errors.New("unexpected parse result for constraint definition")
}
return result, con, nil
}
// DeparseConstraintDef deparses a ParseResult that came from
// ParseConstraintDefStrict and strips the wrapper so the caller receives
// just the constraint fragment.
func DeparseConstraintDef(result *pg_query.ParseResult) (string, error) {
deparsed, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse constraint definition: %w", err)
}
deparsed = strings.TrimSuffix(deparsed, ";")
def, ok := strings.CutPrefix(deparsed, constraintWrapPrefix)
if !ok {
return "", fmt.Errorf("unexpected deparsed form: %s", deparsed)
}
return def, nil
}
// WalkExprColumnRefs walks an expression tree and invokes visit for each
// unqualified single-field ColumnRef's underlying String node. Visitors may
// mutate the String's Sval to rename the column. Qualified references
// (table.col, schema.table.col) are skipped because they refer outside the
// local scope, and so is a sub-link's contained SELECT.
//
// Every other node kind is reached, because Walk enumerates children through
// protoreflect rather than from a list. That matters for the three callers:
// the constraint namer copies the column into the name PostgreSQL would
// generate, the desired-schema validator reports a column that does not
// exist, and the column renamer rewrites the reference in place. A node kind
// missing from the walk breaks all three at once.
//
// The walk hands over those names too, since it decides nothing: an
// xmlelement name, an xmlattributes alias, a named-argument label, a function
// name, an A_Indirection field and a type name all arrive. They stay out
// because they are String nodes sitting outside any ColumnRef, and this acts
// on ColumnRef alone.
func WalkExprColumnRefs(node *pg_query.Node, visit func(*pg_query.String)) {
Walk(node, WalkOptions{SkipSubqueries: true}, func(_ Ctx, n *pg_query.Node) *pg_query.Node {
if cr := n.GetColumnRef(); cr != nil && len(cr.Fields) == 1 {
if s := cr.Fields[0].GetString_(); s != nil {
visit(s)
}
}
return n
})
}
// ParseExpr parses a bare SQL expression by wrapping it in `SELECT <expr>`,
// returning the parse result and the ResTarget holding the expression.
// Callers may mutate target.Val and re-deparse the result.
//
// A bare expression is how PostgreSQL stores a column DEFAULT, a generated
// expression and a policy USING / WITH CHECK clause, so the diff and the
// validator both read one this way.
//
// The prefix gives exactly one target, and the loop avoids a guard for a shape
// that cannot occur. The trailing error covers a parse that produced no target
// at all.
func ParseExpr(expr string) (*pg_query.ParseResult, *pg_query.ResTarget, error) {
result, err := pg_query.Parse("SELECT " + expr)
if err != nil {
return nil, nil, err
}
for _, raw := range result.GetStmts() {
for _, node := range raw.GetStmt().GetSelectStmt().GetTargetList() {
return result, node.GetResTarget(), nil
}
}
return nil, nil, fmt.Errorf("unexpected parse result for expression: %s", expr)
}
package pgast
import (
pg_query "github.com/pganalyze/pg_query_go/v6"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
// Walk and WalkPair traverse a pg_query parse tree without naming a single
// node kind. pg_query nodes are protobuf messages, so the children of a node
// are just its message-typed fields; enumerating them through protoreflect
// reaches every one of the 268 kinds the grammar produces, including the ones
// PostgreSQL adds in a later release.
//
// The hand-written alternative was five recursive functions, each with its own
// switch covering about twenty kinds between them. Every kind left out was
// skipped in silence, which is how a column inside a SQL/JSON constructor went
// missing from a generated constraint name and how a view kept the table
// qualification the catalog strips.
//
// Two things the traversal deliberately does not do:
//
// - It decides nothing about which nodes matter. Every node is handed over,
// including the ones wrapping a String: a function name in
// FuncCall.Funcname, a field name in an A_Indirection and a type name in a
// TypeName all arrive, and each is written where a column name could go.
// What keeps the column renamer off them is that it acts on ColumnRef
// alone and those names sit outside any ColumnRef. A visitor matching
// String directly would rewrite them, so do not write one.
// - It stops at a nested query when asked. Columns there resolve against
// another table.
// Ctx tells a visitor where the node it is looking at sits: Parent is the
// message holding it and Field is the name of the holding field, with
// UpParent and UpField saying the same for the parent. Everything is zero at
// the root.
//
// A Node and the concrete message inside its oneof count as one step, so
// Parent is the message that meaningfully holds the node rather than the Node
// wrapper. Two levels is what the one rule using this needs, and keeping it a
// value rather than a chain keeps the walk from allocating per field.
type Ctx struct {
Parent proto.Message
Field string
UpParent proto.Message
UpField string
}
func (c Ctx) push(parent proto.Message, field string) Ctx {
return Ctx{Parent: parent, Field: field, UpParent: c.Parent, UpField: c.Field}
}
// IsSelectTarget reports whether the node is the expression of a SELECT target
// list entry. A cast written there decides the resulting view's output column
// type, so the normalizations that strip casts have to leave that one alone.
// The check reaches two levels up because a ResTarget also holds the elements
// of xmlattributes() and xmlforest(), where the rule does not apply.
func (c Ctx) IsSelectTarget() bool {
if c.Field != "val" || c.UpField != "target_list" {
return false
}
_, ok := c.Parent.(*pg_query.ResTarget)
return ok
}
// VisitFunc is called for each node in the tree. Returning a different node
// replaces it in its parent; returning the node unchanged leaves it alone.
// The return value must not be nil: a rewrite drops a wrapper by returning
// what it wrapped, never by removing the position outright.
type VisitFunc func(ctx Ctx, node *pg_query.Node) *pg_query.Node
// PairVisitFunc is the two-tree form. It returns the node to keep on the
// current side, which must not be nil for the same reason.
type PairVisitFunc func(ctx Ctx, desired, current *pg_query.Node) *pg_query.Node
// WalkOptions carries the one exception the traversal knows about.
type WalkOptions struct {
// SkipSubqueries stops the walk at a nested SELECT, wherever it hangs: a
// sub-link's contained query, a JSON_ARRAY over a sub-query, a CTE. The
// constraint namer, the desired-schema validator and the column renamer all
// work within one table, so a column named in a sub-query is not theirs to
// read or rewrite. The view comparison, which has to normalize the whole
// body, leaves this false.
SkipSubqueries bool
}
// Walk visits every node in the tree, children before the node itself, and
// returns the (possibly replaced) root. Children first because the
// normalizations are written that way: stripping a cast wrapper is decided
// after whatever it wraps has been normalized.
func Walk(node *pg_query.Node, opts WalkOptions, visit VisitFunc) *pg_query.Node {
return walkNode(node, Ctx{}, opts, visit)
}
// WalkPair visits desired and current in step, the node itself before its
// children, and returns the (possibly replaced) current root. It descends only
// where both sides hold the same kind of node and their repeated fields have
// equal length, so a structural difference simply stops the walk there.
//
// The node comes before its children here because the only rule using it
// unwraps casts from the current side, and the unwrapped node is what the
// children have to be paired against.
func WalkPair(desired, current *pg_query.Node, visit PairVisitFunc) *pg_query.Node {
return walkPairNode(desired, current, Ctx{}, visit)
}
func walkNode(node *pg_query.Node, ctx Ctx, opts WalkOptions, visit VisitFunc) *pg_query.Node {
if node == nil {
return nil
}
walkMessage(node, ctx, opts, visit)
return visit(ctx, node)
}
func walkMessage(m proto.Message, ctx Ctx, opts WalkOptions, visit VisitFunc) {
r := m.ProtoReflect()
// A Node is a wrapper holding one of 268 oneof members. Asking which one is
// set beats scanning the field list, and the member it holds counts as the
// same step rather than a level of its own, so ctx passes through.
if _, atNode := m.(*pg_query.Node); atNode {
if fd := nodeOneof(r); fd != nil {
walkMessage(r.Get(fd).Message().Interface(), ctx, opts, visit)
}
return
}
fds := r.Descriptor().Fields()
for i := range fds.Len() {
fd := fds.Get(i)
if fd.Kind() != protoreflect.MessageKind || !r.Has(fd) {
continue
}
childCtx := ctx.push(m, fd.TextName())
value := r.Get(fd)
if fd.IsList() {
// Every repeated message field the grammar produces holds Nodes.
list := value.List()
for j := range list.Len() {
node, ok := list.Get(j).Message().Interface().(*pg_query.Node)
if !ok || (opts.SkipSubqueries && isNestedQuery(node)) {
continue
}
if replaced := walkNode(node, childCtx, opts, visit); replaced != nil && replaced != node {
list.Set(j, protoreflect.ValueOfMessage(replaced.ProtoReflect()))
}
}
continue
}
child := value.Message().Interface()
node, ok := child.(*pg_query.Node)
if !ok {
walkMessage(child, childCtx, opts, visit)
continue
}
if opts.SkipSubqueries && isNestedQuery(node) {
continue
}
if replaced := walkNode(node, childCtx, opts, visit); replaced != nil && replaced != node {
r.Set(fd, protoreflect.ValueOfMessage(replaced.ProtoReflect()))
}
}
}
func walkPairNode(desired, current *pg_query.Node, ctx Ctx, visit PairVisitFunc) *pg_query.Node {
if desired == nil || current == nil {
return current
}
current = visit(ctx, desired, current)
walkPairMessage(desired, current, ctx, visit)
return current
}
func walkPairMessage(desired, current proto.Message, ctx Ctx, visit PairVisitFunc) {
dr, cr := desired.ProtoReflect(), current.ProtoReflect()
if _, atNode := desired.(*pg_query.Node); atNode {
// Both sides must hold the same oneof member, which is what keeps the
// walk from pairing an A_Expr against a FuncCall.
fd := nodeOneof(dr)
if fd == nil || cr.WhichOneof(fd.ContainingOneof()) != fd {
return
}
walkPairMessage(dr.Get(fd).Message().Interface(), cr.Get(fd).Message().Interface(), ctx, visit)
return
}
fds := dr.Descriptor().Fields()
for i := range fds.Len() {
fd := fds.Get(i)
if fd.Kind() != protoreflect.MessageKind || !dr.Has(fd) || !cr.Has(fd) {
continue
}
childCtx := ctx.push(current, fd.TextName())
dv, cv := dr.Get(fd), cr.Get(fd)
if fd.IsList() {
dl, cl := dv.List(), cv.List()
if dl.Len() != cl.Len() {
continue
}
for j := range dl.Len() {
dn, dok := dl.Get(j).Message().Interface().(*pg_query.Node)
cn, cok := cl.Get(j).Message().Interface().(*pg_query.Node)
if !dok || !cok {
continue
}
if replaced := walkPairNode(dn, cn, childCtx, visit); replaced != nil && replaced != cn {
cl.Set(j, protoreflect.ValueOfMessage(replaced.ProtoReflect()))
}
}
continue
}
dm, cm := dv.Message().Interface(), cv.Message().Interface()
dn, dok := dm.(*pg_query.Node)
cn, cok := cm.(*pg_query.Node)
if !dok || !cok {
walkPairMessage(dm, cm, childCtx, visit)
continue
}
if replaced := walkPairNode(dn, cn, childCtx, visit); replaced != nil && replaced != cn {
cr.Set(fd, protoreflect.ValueOfMessage(replaced.ProtoReflect()))
}
}
}
// nodeOneof returns the descriptor of the oneof member a Node holds, or nil
// for an empty Node. Only ever called on a Node, which declares exactly one
// oneof.
func nodeOneof(r protoreflect.Message) protoreflect.FieldDescriptor {
return r.WhichOneof(r.Descriptor().Oneofs().Get(0))
}
// isNestedQuery reports whether a child node opens a scope of its own. Keying
// on the node rather than on the field that holds it covers every way one is
// reached: SubLink.subselect, JsonArrayQueryConstructor.query,
// CommonTableExpr.ctequery and RangeSubselect.subquery all arrive here as a
// Node wrapping a SelectStmt. The root of a walk is never checked, so passing
// a whole statement in still works.
func isNestedQuery(node *pg_query.Node) bool {
return node.GetSelectStmt() != nil
}
package lint
import (
"encoding/json/v2"
"fmt"
"io"
"reflect"
"cel.dev/cel-go/cel"
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/common/types/ref"
"cel.dev/cel-go/common/types/traits"
"cel.dev/cel-go/ext"
"github.com/winebarrel/pistachio/parser"
"google.golang.org/protobuf/types/known/structpb"
)
// trace says which object and rule an evaluation is for, so that debug() can
// name them. Rules are evaluated one at a time.
type trace struct {
w io.Writer
object string
rule string
}
// variables lists the variables a rule of each kind reads, besides doc.
var variables = map[parser.LintKind][]string{
parser.LintTable: {"table"},
parser.LintColumn: {"table", "column"},
parser.LintIndex: {"table", "index"},
parser.LintForeignKey: {"table", "fk"},
}
// must returns v, and panics on an error that only a bug in this package can
// cause, such as a CEL declaration that does not build.
func must[T any](v T, err error) T {
mustNoError(err)
return v
}
func mustNoError(err error) {
if err != nil {
panic(err)
}
}
// newEnvs builds one CEL environment per kind. A rule can use only the
// variables of its kind, so a column rule that reads index fails to compile.
func newEnvs(tr *trace) map[parser.LintKind]*cel.Env {
funcs := []cel.EnvOption{
// The CEL extensions for strings (indexOf, substring), regular
// expressions (regex.replace) and cel.bind. The regular expression
// functions need optional types.
ext.Strings(),
cel.OptionalTypes(),
ext.Regex(),
ext.Bindings(),
// m.values() lists the values of a map. Most of the document is
// keyed by name, and the macros iterate a map's keys.
cel.Function("values",
cel.MemberOverload("map_values", []*cel.Type{cel.MapType(cel.DynType, cel.DynType)}, cel.ListType(cel.DynType),
cel.UnaryBinding(mapValues))),
// hasPrefix(list, prefix) is true when list starts with prefix.
cel.Function("hasPrefix",
cel.Overload("list_has_prefix", []*cel.Type{cel.ListType(cel.DynType), cel.ListType(cel.DynType)}, cel.BoolType,
cel.BinaryBinding(listHasPrefix))),
// debug(label, value) writes value and returns it unchanged.
cel.Function("debug",
cel.Overload("debug_dyn", []*cel.Type{cel.StringType, cel.DynType}, cel.DynType,
cel.BinaryBinding(func(label, value ref.Val) ref.Val {
fmt.Fprintf(tr.w, "debug: %s: %s: %v = %s\n", tr.object, tr.rule, label.Value(), toJSON(value)) //nolint:errcheck
return value
}))),
}
envs := map[parser.LintKind]*cel.Env{}
for kind, names := range variables {
opts := append([]cel.EnvOption{cel.Variable("doc", cel.DynType)}, funcs...)
for _, name := range names {
opts = append(opts, cel.Variable(name, cel.DynType))
}
envs[kind] = must(cel.NewEnv(opts...))
}
return envs
}
// mapValues and listHasPrefix are declared for a map and for lists, so CEL
// calls them with nothing else.
func mapValues(v ref.Val) ref.Val {
m := v.(traits.Mapper)
var values []ref.Val
it := m.Iterator()
for it.HasNext() == types.True {
values = append(values, m.Get(it.Next()))
}
return types.DefaultTypeAdapter.NativeToValue(values)
}
func listHasPrefix(list, prefix ref.Val) ref.Val {
l := list.(traits.Lister)
p := prefix.(traits.Lister)
n := p.Size().(types.Int)
if l.Size().(types.Int) < n {
return types.False
}
for i := range n {
if l.Get(i).Equal(p.Get(i)) != types.True {
return types.False
}
}
return types.True
}
// toJSON writes a CEL value as JSON, or as Go writes it when it has no JSON
// form.
func toJSON(v ref.Val) string {
// protojson varies its whitespace on purpose, so the value is written
// through encoding/json instead.
native, err := v.ConvertToNative(reflect.TypeFor[*structpb.Value]())
if err == nil {
if b, err := json.Marshal(native.(*structpb.Value).AsInterface()); err == nil {
return string(b)
}
}
return fmt.Sprint(v.Value())
}
// Package lint checks the objects that schema files declare against rules the
// user writes. A rule is a CEL expression that must be true for every object
// of one kind. Its when expression can limit it to some of those objects. The
// rule reads the object as pista parse writes it in JSON.
package lint
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"cel.dev/cel-go/cel"
celast "cel.dev/cel-go/common/ast"
"github.com/winebarrel/pistachio/parser"
"gopkg.in/yaml.v3"
)
// Rule is one rule read from a rule file.
type Rule struct {
Name string
On parser.LintKind
Description string
Let []Let
When string
Assert string
Message string
File string
// when is nil when the rule has no when, so it checks every object of
// its kind.
when cel.Program
program cel.Program
}
// Let is one value that let names. assert reads it by Name.
type Let struct {
Name string
Expr string
program cel.Program
}
type ruleFile struct {
Rules []ruleSpec `yaml:"rules"`
}
type ruleSpec struct {
Name string `yaml:"name"`
On string `yaml:"on"`
Description string `yaml:"description"`
Let []letSpec `yaml:"let"`
When string `yaml:"when"`
Assert string `yaml:"assert"`
Message string `yaml:"message"`
}
type letSpec struct {
Name string `yaml:"name"`
Expr string `yaml:"expr"`
}
// kinds lists the values of on, in the order the documentation gives them.
var kinds = []parser.LintKind{parser.LintTable, parser.LintColumn, parser.LintIndex, parser.LintForeignKey}
// Linter holds the rules that Load read.
type Linter struct {
Rules []*Rule
trace *trace
}
// Load reads the rules in paths. A path is a rule file, or a directory whose
// .yml and .yaml files are read in name order. Subdirectories are not read.
// Two rules with the same name are an error. stderr receives what the debug()
// function prints and the warnings Run writes.
func Load(paths []string, stderr io.Writer) (*Linter, error) {
var files []string
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
files = append(files, path)
continue
}
entries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
for _, e := range entries {
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".yml" && ext != ".yaml" {
continue
}
// Stat follows a symbolic link, so a link to a rule file is read.
file := filepath.Join(path, e.Name())
info, err := os.Stat(file)
if err != nil {
return nil, err
}
if !info.IsDir() {
files = append(files, file)
}
}
}
tr := &trace{w: stderr}
envs := newEnvs(tr)
var rules []*Rule
seen := map[string]string{}
for _, file := range files {
specs, err := readRuleFile(file)
if err != nil {
return nil, err
}
for _, spec := range specs {
rule, err := compileRule(spec, file, envs)
if err != nil {
return nil, err
}
if prev, ok := seen[rule.Name]; ok {
return nil, fmt.Errorf("%s: rule %s is also defined in %s", file, rule.Name, prev)
}
seen[rule.Name] = file
rules = append(rules, rule)
}
}
if len(rules) == 0 {
return nil, errors.New("no rules found in " + strings.Join(paths, ", "))
}
return &Linter{Rules: rules, trace: tr}, nil
}
func readRuleFile(file string) ([]ruleSpec, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var rf ruleFile
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&rf); err != nil {
if errors.Is(err, io.EOF) {
return nil, nil
}
return nil, fmt.Errorf("%s: %w", file, err)
}
// A second document after --- would be skipped without a word.
var next yaml.Node
if err := dec.Decode(&next); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%s: a rule file holds one YAML document", file)
}
return rf.Rules, nil
}
func compileRule(spec ruleSpec, file string, envs map[parser.LintKind]*cel.Env) (*Rule, error) {
if spec.Name == "" {
return nil, fmt.Errorf("%s: a rule has no name", file)
}
// -- pista:lint-ignore separates names with commas and spaces.
if strings.ContainsAny(spec.Name, ", \t") {
return nil, fmt.Errorf("%s: rule name %q has a comma or a space", file, spec.Name)
}
where := fmt.Sprintf("%s: rule %s", file, spec.Name)
kind := parser.LintKind(spec.On)
if !slices.Contains(kinds, kind) {
return nil, fmt.Errorf("%s: on must be one of table, column, index or foreign_key, not %q", where, spec.On)
}
if spec.Assert == "" {
return nil, fmt.Errorf("%s: assert is empty", where)
}
if spec.Message == "" {
return nil, fmt.Errorf("%s: message is empty", where)
}
// when cannot read let, since let is evaluated only where when is true.
env := envs[kind]
var when cel.Program
if spec.When != "" {
var err error
when, err = compileExpr(env, spec.When, "when", where)
if err != nil {
return nil, err
}
}
lets, env, err := compileLets(env, spec.Let, kind, where)
if err != nil {
return nil, err
}
program, err := compileExpr(env, spec.Assert, "assert", where)
if err != nil {
return nil, err
}
return &Rule{
Name: spec.Name,
On: kind,
Description: spec.Description,
Let: lets,
When: spec.When,
Assert: spec.Assert,
Message: spec.Message,
File: file,
when: when,
program: program,
}, nil
}
// compileLets compiles the values of let in order, each one with the values
// before it declared. It returns env with every value declared, for assert.
func compileLets(env *cel.Env, specs []letSpec, kind parser.LintKind, where string) ([]Let, *cel.Env, error) {
defined := append([]string{"doc"}, variables[kind]...)
var lets []Let
for _, spec := range specs {
if spec.Name == "" {
return nil, nil, fmt.Errorf("%s: a let has no name", where)
}
if !isIdent(env, spec.Name) {
return nil, nil, fmt.Errorf("%s: let name %q is not a CEL identifier", where, spec.Name)
}
if slices.Contains(defined, spec.Name) {
return nil, nil, fmt.Errorf("%s: let %s is already defined", where, spec.Name)
}
if spec.Expr == "" {
return nil, nil, fmt.Errorf("%s: let %s: expr is empty", where, spec.Name)
}
ast, iss := env.Compile(spec.Expr)
if iss.Err() != nil {
return nil, nil, fmt.Errorf("%s: let %s: %w", where, spec.Name, iss.Err())
}
// The value keeps the type CEL found, so assert x on an int fails to
// compile rather than when it runs.
env = must(env.Extend(cel.Variable(spec.Name, ast.OutputType())))
lets = append(lets, Let{Name: spec.Name, Expr: spec.Expr, program: must(env.Program(ast))})
defined = append(defined, spec.Name)
}
return lets, env, nil
}
// isIdent reports whether name parses as one CEL identifier, so CEL decides
// which words are reserved. CEL also parses .x as an identifier, one that
// reads x from the root scope, so a leading dot is refused here.
func isIdent(env *cel.Env, name string) bool {
if strings.HasPrefix(name, ".") {
return false
}
parsed, iss := env.Parse(name)
if iss.Err() != nil {
return false
}
e := parsed.NativeRep().Expr()
return e.Kind() == celast.IdentKind && e.AsIdent() == name
}
// compileExpr compiles the CEL expression of the field assert or when. Its
// result must be a boolean.
func compileExpr(env *cel.Env, expr, field, where string) (cel.Program, error) {
ast, iss := env.Compile(expr)
if iss.Err() != nil {
return nil, fmt.Errorf("%s: %s: %w", where, field, iss.Err())
}
if t := ast.OutputType(); t != cel.BoolType && t != cel.DynType {
return nil, fmt.Errorf("%s: %s is %s, not bool", where, field, t)
}
return must(env.Program(ast)), nil
}
package lint
import (
"encoding/json/v2"
"fmt"
"maps"
"slices"
"strings"
"cel.dev/cel-go/cel"
"cel.dev/cel-go/common/types"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/document"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
// Violation is one object for which a rule is false. Position is where the
// object is declared, and empty for SQL that came from no file.
type Violation struct {
Position string
Object string
Rule string
Message string
}
func (v Violation) String() string {
return withPosition(v.Position, v.Object+": "+v.Rule+": "+v.Message)
}
func withPosition(pos, s string) string {
if pos == "" {
return s
}
return pos + ": " + s
}
// target is one object to check, with the variables a rule of its kind reads.
type target struct {
key parser.LintTarget
label string
vars map[string]any
}
// Run checks every object of the parse result against the rules, in the order
// the files declare the objects, and returns the objects a rule is false for.
// A table marked -- pista:ignore is not checked, nor is anything on it.
// schemas resolves an unqualified domain name, as document.New does.
func (l *Linter) Run(r *parser.ParseResult, schemas []string) ([]Violation, error) {
targets := collectTargets(r, schemas)
l.warnUnknownNames(r, targets)
var violations []Violation
for _, t := range targets {
ignored := r.LintIgnores[t.key]
if slices.Contains(ignored, parser.LintIgnoreAll) {
continue
}
pos := position(r, t.key)
for _, rule := range l.Rules {
if rule.On != t.key.Kind || slices.Contains(ignored, rule.Name) {
continue
}
l.trace.object, l.trace.rule = t.label, rule.Name
where := withPosition(pos, t.label+": "+rule.Name)
if rule.when != nil {
applies, err := evalBool(rule.when, t.vars, "when", where)
if err != nil {
return nil, err
}
if !applies {
continue
}
}
vars, err := evalLets(rule.Let, t.vars, where)
if err != nil {
return nil, err
}
ok, err := evalBool(rule.program, vars, "assert", where)
if err != nil {
return nil, err
}
if !ok {
violations = append(violations, Violation{Position: pos, Object: t.label, Rule: rule.Name, Message: rule.Message})
}
}
}
return violations, nil
}
// evalLets evaluates the values of let in order and returns vars with them
// added. vars itself is shared by every rule, so it is left alone.
func evalLets(lets []Let, vars map[string]any, where string) (map[string]any, error) {
if len(lets) == 0 {
return vars, nil
}
vars = maps.Clone(vars)
for _, l := range lets {
out, _, err := l.program.Eval(vars)
if err != nil {
return nil, fmt.Errorf("%s: let %s: %w", where, l.Name, err)
}
vars[l.Name] = out
}
return vars, nil
}
// evalBool evaluates the program of the field assert or when. A rule whose
// type CEL could not tell at compile time can still return something else.
func evalBool(program cel.Program, vars map[string]any, field, where string) (bool, error) {
out, _, err := program.Eval(vars)
if err != nil {
return false, fmt.Errorf("%s: %w", where, err)
}
b, ok := out.(types.Bool)
if !ok {
return false, fmt.Errorf("%s: %s returned %s, not bool", where, field, out.Type().TypeName())
}
return bool(b), nil
}
// collectTargets lists the objects to check. Each object is read from the JSON
// document that pista parse writes, so a rule sees the same fields. The
// document's maps carry no order once decoded, so the order comes from the
// parse result.
func collectTargets(r *parser.ParseResult, schemas []string) []target {
doc := document.New(r, schemas)
// The document is built here from a parse result, so it always encodes,
// and what it encodes to always decodes.
encoded := must(json.Marshal(doc, json.Deterministic(true), model.JSONMarshalers))
var root map[string]any
mustNoError(json.Unmarshal(encoded, &root))
jsonTables, _ := root["tables"].(map[string]any)
jsonViews, _ := root["views"].(map[string]any)
var targets []target
add := func(key parser.LintTarget, label string, vars map[string]any) {
vars["doc"] = root
targets = append(targets, target{key: key, label: label, vars: vars})
}
if doc.Tables != nil {
for fqtn, t := range doc.Tables.All() {
if t.Ignore {
continue
}
table, _ := jsonTables[fqtn].(map[string]any)
add(parser.LintTarget{Kind: parser.LintTable, Table: fqtn}, "table "+fqtn, map[string]any{"table": table})
columns, _ := table["columns"].([]any)
for _, c := range columns {
column, _ := c.(map[string]any)
name, _ := column["name"].(string)
add(parser.LintTarget{Kind: parser.LintColumn, Table: fqtn, Name: name},
"column "+fqtn+"."+model.Ident(name), map[string]any{"table": table, "column": column})
}
addIndexes(add, fqtn, table, t.Indexes)
fks, _ := table["foreign_keys"].(map[string]any)
if t.ForeignKeys != nil {
for name := range t.ForeignKeys.Keys() {
add(parser.LintTarget{Kind: parser.LintForeignKey, Table: fqtn, Name: name},
"foreign key "+model.Ident(name)+" on "+fqtn, map[string]any{"table": table, "fk": fks[name]})
}
}
}
}
// An index on a materialized view is checked with the view as table.
if doc.Views != nil {
for fqvn, v := range doc.Views.All() {
if v.Ignore || !v.Materialized {
continue
}
view, _ := jsonViews[fqvn].(map[string]any)
addIndexes(add, fqvn, view, v.Indexes)
}
}
return targets
}
func addIndexes(add func(parser.LintTarget, string, map[string]any), fqtn string, table map[string]any, indexes *orderedmap.Map[string, *document.Index]) {
if indexes == nil {
return
}
jsonIndexes, _ := table["indexes"].(map[string]any)
for name, idx := range indexes.All() {
add(parser.LintTarget{Kind: parser.LintIndex, Table: fqtn, Name: name},
"index "+model.Ident(idx.Schema, idx.Name), map[string]any{"table": table, "index": jsonIndexes[name]})
}
}
func position(r *parser.ParseResult, key parser.LintTarget) string {
if p, ok := r.Positions[key]; ok {
return p.String()
}
return ""
}
// warnUnknownNames writes a warning for each name in a -- pista:lint-ignore
// that no rule of the kinds it reaches has. Such a name turns nothing off,
// which is most likely a typo. A directive on a table that is not checked is
// left alone.
func (l *Linter) warnUnknownNames(r *parser.ParseResult, targets []target) {
labels := map[parser.LintTarget]string{}
for _, t := range targets {
labels[t.key] = t.label
}
names := map[parser.LintKind]map[string]bool{}
for _, rule := range l.Rules {
if names[rule.On] == nil {
names[rule.On] = map[string]bool{}
}
names[rule.On][rule.Name] = true
}
for _, d := range r.LintIgnoreDirectives {
first := d.Targets[0]
label, checked := labels[first]
if !checked {
continue
}
var kinds []string
for _, k := range d.Targets {
if !slices.Contains(kinds, string(k.Kind)) {
kinds = append(kinds, string(k.Kind))
}
}
for _, name := range d.Rules {
known := name == parser.LintIgnoreAll
for _, k := range d.Targets {
known = known || names[k.Kind][name]
}
if !known {
fmt.Fprintf(l.trace.w, "warning: %s: -- pista:lint-ignore names no %s rule %s\n", //nolint:errcheck
withPosition(position(r, first), label), strings.Join(kinds, " or "), name)
}
}
}
}
package pistachio
import (
"context"
"slices"
"strings"
"github.com/jackc/pgx/v5"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/diff"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// --evaluate-literals compares a string literal with a cast by value, not by
// text. PostgreSQL stores the value of a literal, not its text, and the
// catalog prints the value in the output format of its type. DEFAULT '1 hour'
// on an interval column is read back as '01:00:00'::interval. The diff
// compares text, so it plans the same statement on every run.
//
// Only the server knows the output format of each type, so the literals whose
// text differs are sent to it. When the server prints the desired literal the
// same way as the current one, the current side is rewritten to the desired
// text before the diff runs, as referenceRenames does for a rename. The
// statements are written from the desired side, which is not changed.
//
// This covers the DEFAULT of a column or domain, the expression of a
// generated column, which the model keeps in Default, and a CHECK constraint
// on a table or domain. A table with a column that changes type in the same
// run is skipped, and so is a domain whose base type changes, because the cast
// on the current side names the old type.
// literalPrinter returns the text the server prints for each cast expression.
// An expression it cannot evaluate is left out of the result.
type literalPrinter func(exprs []string) map[string]string
// printLiterals asks the server for the output form of each expression in one
// round trip. format's %s goes through the type's output function, which is
// what the catalog prints. A cast to text can differ: inet::text keeps the
// netmask.
// A literal the type refuses fails the whole query, so on an error each is
// asked on its own and the failures are dropped. apply reports them when it
// runs the statement.
func printLiterals(ctx context.Context, conn *pgx.Conn, exprs []string) map[string]string {
cols := make([]string, len(exprs))
for i, e := range exprs {
cols[i] = "format('%s', " + e + ")"
}
var printed []string
err := conn.QueryRow(ctx, "SELECT ARRAY["+strings.Join(cols, ", ")+"]").Scan(&printed)
if err == nil {
out := make(map[string]string, len(exprs))
for i, e := range exprs {
out[e] = printed[i]
}
return out
}
out := map[string]string{}
for _, e := range exprs {
var s string
if conn.QueryRow(ctx, "SELECT format('%s', "+e+")").Scan(&s) == nil {
out[e] = s
}
}
return out
}
// literalPair is one current-side literal the desired side spells
// differently: the string the desired side wrote, and the cast expression
// that applies the current side's type to it.
type literalPair struct {
desired string
expr string
}
// castLiteral returns the string constant under a cast and the cast, or nil
// when node is not a cast on a string constant.
func castLiteral(node *pg_query.Node) (*pg_query.String, *pg_query.TypeCast) {
tc := node.GetTypeCast()
if tc == nil {
return nil, nil
}
s := tc.Arg.GetAConst().GetSval()
if s == nil {
return nil, nil
}
return s, tc
}
// castExpr writes the SQL for value cast to tn. A tree that came out of the
// parser deparses, so the error is not checked: an empty string matches
// nothing.
func castExpr(value string, tn *pg_query.TypeName) string {
cast := &pg_query.Node{Node: &pg_query.Node_TypeCast{TypeCast: &pg_query.TypeCast{
Arg: pg_query.MakeAConstStrNode(value, 0),
TypeName: tn,
}}}
sql, _ := pg_query.Deparse(&pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{Stmt: &pg_query.Node{Node: &pg_query.Node_SelectStmt{SelectStmt: &pg_query.SelectStmt{
TargetList: []*pg_query.Node{pg_query.MakeResTargetNodeWithVal(cast, 0)},
}}}}},
})
return strings.TrimPrefix(sql, "SELECT ")
}
// matchLiterals walks desired and current in step and calls visit for each
// position where the current side holds a cast string constant and the
// desired side the same constant spelled differently, bare or cast to the
// same type.
func matchLiterals(desired, current *pg_query.Node, visit func(cur *pg_query.String, pair literalPair)) {
pgast.WalkPair(desired, current, func(_ pgast.Ctx, des, cur *pg_query.Node) *pg_query.Node {
curStr, curCast := castLiteral(cur)
if curStr == nil {
return cur
}
desStr := des.GetAConst().GetSval()
desStr2, desCast := castLiteral(des)
if desStr == nil {
desStr = desStr2
}
if desStr == nil || desStr.Sval == curStr.Sval {
return cur
}
expr := castExpr(desStr.Sval, curCast.TypeName)
if expr == "" || desCast != nil && castExpr(desStr.Sval, desCast.TypeName) != expr {
return cur
}
visit(curStr, literalPair{desired: desStr.Sval, expr: expr})
return cur
})
}
// literalAligner collects the literals to print on its first pass and
// rewrites the current side with the printed forms on its second.
type literalAligner struct {
printed map[string]string
exprs []string
seen map[string]bool
}
// align compares one current-side expression with its desired counterpart.
// Before the literals are printed it records what to ask; after, it returns
// the current expression with each literal whose printed form matches spelled
// the desired way, and whether anything changed.
//
// parse returns a nil node and deparse an empty string on an error. Both sides
// were parsed once already, so neither happens, and a nil node pairs with
// nothing.
func (a *literalAligner) align(current, desired string, parse func(string) (*pg_query.ParseResult, *pg_query.Node), deparse func(*pg_query.ParseResult) string) (string, bool) {
if current == desired {
return current, false
}
curResult, curNode := parse(current)
_, desNode := parse(desired)
changed := false
matchLiterals(desNode, curNode, func(cur *pg_query.String, pair literalPair) {
if a.printed == nil {
if !a.seen[pair.expr] {
a.seen[pair.expr] = true
a.exprs = append(a.exprs, pair.expr)
}
return
}
if p, ok := a.printed[pair.expr]; ok && p == cur.Sval {
cur.Sval = pair.desired
changed = true
}
})
if changed {
if out := deparse(curResult); out != "" {
return out, true
}
}
return current, false
}
func parseDefault(expr string) (*pg_query.ParseResult, *pg_query.Node) {
result, target, _ := pgast.ParseExpr(expr)
return result, target.GetVal()
}
func deparseDefault(result *pg_query.ParseResult) string {
sql, _ := pg_query.Deparse(result)
return strings.TrimPrefix(sql, "SELECT ")
}
// parseCheck normalizes the expression the way the CHECK comparison does
// before the literals are paired. The catalog prints IN (...) as
// = ANY (ARRAY[...]) and expands BETWEEN, so the raw trees of the two sides
// do not line up.
func parseCheck(def string) (*pg_query.ParseResult, *pg_query.Node) {
result, con, _ := pgast.ParseConstraintDefStrict(def)
if con == nil {
return result, nil
}
con.RawExpr = diff.NormalizeCheckExpr(con.RawExpr)
return result, con.RawExpr
}
func deparseCheck(result *pg_query.ParseResult) string {
def, _ := pgast.DeparseConstraintDef(result)
return def
}
func (a *literalAligner) alignDefault(current, desired *string) (*string, bool) {
if current == nil || desired == nil {
return current, false
}
out, changed := a.align(*current, *desired, parseDefault, deparseDefault)
return &out, changed
}
func (a *literalAligner) alignCheck(current, desired string) (string, bool) {
return a.align(current, desired, parseCheck, deparseCheck)
}
// alignTables returns tables with the literals in their column defaults and
// check constraints spelled the way desired spells them, where the server
// prints the two the same. A table any of whose columns changes type is
// left whole, since its checks may name that column.
func (a *literalAligner) alignTables(tables, desired *orderedmap.Map[string, *model.Table]) *orderedmap.Map[string, *model.Table] {
out := tables.Clone()
for key, t := range tables.All() {
d, ok := desired.GetOk(key)
if !ok || retypedTable(t, d) {
continue
}
var cols *orderedmap.Map[string, *model.Column]
for name, col := range t.Columns.All() {
dc, ok := d.Columns.GetOk(name)
if !ok {
continue
}
def, changed := a.alignDefault(col.Default, dc.Default)
if !changed {
continue
}
if cols == nil {
cols = t.Columns.Clone()
}
c := *col
c.Default = def
cols.Set(name, &c)
}
var cons *orderedmap.Map[string, *model.Constraint]
for name, con := range t.Constraints.All() {
dc, ok := d.Constraints.GetOk(name)
if !ok || !con.Type.IsCheckConstraint() || !dc.Type.IsCheckConstraint() {
continue
}
def, changed := a.alignCheck(con.Definition, dc.Definition)
if !changed {
continue
}
if cons == nil {
cons = t.Constraints.Clone()
}
c := *con
c.Definition = def
cons.Set(name, &c)
}
if cols == nil && cons == nil {
continue
}
copied := *t
if cols != nil {
copied.Columns = cols
}
if cons != nil {
copied.Constraints = cons
}
out.Set(key, &copied)
}
return out
}
// retypedTable reports whether any column the two sides share changes type.
func retypedTable(current, desired *model.Table) bool {
for name, col := range current.Columns.All() {
if dc, ok := desired.Columns.GetOk(name); ok && !diff.EqualTypeName(col.TypeName, dc.TypeName, current.Schema) {
return true
}
}
return false
}
// alignDomains is alignTables for domains: the default and each check
// constraint, unless the base type changes.
func (a *literalAligner) alignDomains(domains, desired *orderedmap.Map[string, *model.Domain]) *orderedmap.Map[string, *model.Domain] {
out := domains.Clone()
for key, d := range domains.All() {
dd, ok := desired.GetOk(key)
if !ok || !diff.EqualTypeName(d.BaseType, dd.BaseType, d.Schema) {
continue
}
def, defChanged := a.alignDefault(d.Default, dd.Default)
var cons []*model.DomainConstraint
for i, c := range d.Constraints {
for _, dc := range dd.Constraints {
if dc.Name != c.Name {
continue
}
aligned, changed := a.alignCheck(c.Definition, dc.Definition)
if !changed {
break
}
if cons == nil {
cons = slices.Clone(d.Constraints)
}
copied := *c
copied.Definition = aligned
cons[i] = &copied
break
}
}
if !defChanged && cons == nil {
continue
}
copied := *d
if defChanged {
copied.Default = def
}
if cons != nil {
copied.Constraints = cons
}
out.Set(key, &copied)
}
return out
}
// evaluateLiterals runs the aligner's two passes: it collects the literals
// that differ, has printer evaluate them in one call, and returns the current
// tables and domains rewritten with the matches.
func evaluateLiterals(
printer literalPrinter,
tables, desiredTables *orderedmap.Map[string, *model.Table],
domains, desiredDomains *orderedmap.Map[string, *model.Domain],
) (*orderedmap.Map[string, *model.Table], *orderedmap.Map[string, *model.Domain]) {
a := &literalAligner{seen: map[string]bool{}}
a.alignTables(tables, desiredTables)
a.alignDomains(domains, desiredDomains)
if len(a.exprs) == 0 {
return tables, domains
}
a.printed = printer(a.exprs)
return a.alignTables(tables, desiredTables), a.alignDomains(domains, desiredDomains)
}
package model
import "fmt"
// https://www.postgresql.org/docs/current/catalog-pg-attribute.html
type ColumnIdentity byte
// MarshalJSON writes the identity kind as a word instead of the pg_attribute
// character, since the JSON is read outside pistachio.
func (b ColumnIdentity) MarshalJSON() ([]byte, error) {
switch b {
case 'a':
return []byte(`"always"`), nil
case 'd':
return []byte(`"by_default"`), nil
default:
return []byte(`""`), nil
}
}
func (b ColumnIdentity) IsIdentityColumn() bool {
return b != 0
}
func (b ColumnIdentity) IsGeneratedAlways() bool {
return b == 'a'
}
func (b ColumnIdentity) IsGeneratedByDefault() bool {
return b == 'd'
}
type ColumnGenerated byte
// MarshalJSON writes the generation kind as a word instead of the
// pg_attribute character, since the JSON is read outside pistachio.
func (b ColumnGenerated) MarshalJSON() ([]byte, error) {
switch b {
case 's':
return []byte(`"stored"`), nil
case 'v':
return []byte(`"virtual"`), nil
default:
return []byte(`""`), nil
}
}
func (b ColumnGenerated) IsGeneratedColumn() bool {
return b != 0
}
func (b ColumnGenerated) IsStoredGeneratedColumn() bool {
return b == 's'
}
type Column struct {
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
TypeName string `json:"type"`
// RetypeUsing is the USING expression of a type change, set by
// -- pista:retype-using. Only the desired side sets it, and it only shapes
// the SET DATA TYPE statement.
RetypeUsing *string `json:"-"`
// SerialSequence is the schema-qualified name of the sequence that the
// column owns and takes its default from. Only the catalog sets it; nil
// otherwise. The type name is a serial type only when the sequence has
// the default name. Default holds the nextval() in both cases. A serial
// type does not write it, because PostgreSQL rejects a default with it.
SerialSequence *string `json:"serial_sequence"`
NotNull bool `json:"not_null"`
NotNullName *string `json:"not_null_name"`
Default *string `json:"default"`
Identity ColumnIdentity `json:"identity"`
// IdentitySeq holds the sequence parameters of an identity column. It is
// nil when the column is not an identity column.
IdentitySeq *IdentitySequence `json:"identity_sequence"`
Generated ColumnGenerated `json:"generated"`
// Collation in quoted SQL form, ready to follow COLLATE
// (e.g. `pg_catalog."C"`). nil for the default collation.
Collation *string `json:"collation"`
// StorageType is the column's TOAST strategy: plain, external, extended
// or main. The desired side holds what the definition wrote, empty when
// it wrote nothing or wrote DEFAULT.
StorageType string `json:"storage_type"`
// TypeStorage is the strategy the column's type defaults to. Only the
// catalog fills it, since the desired side cannot know it, and the diff
// resolves an unwritten StorageType against it.
TypeStorage string `json:"type_storage"`
// Compression is the column's TOAST compression method (pglz, lz4), empty
// when the column carries none and default_toast_compression decides.
Compression string `json:"compression"`
Comment *string `json:"comment"`
}
func (col *Column) String() string {
return fmt.Sprintf("%#v", *col)
}
// IsSerialType reports whether a type name is one of the serial pseudo-types.
func IsSerialType(typeName string) bool {
switch typeName {
case "serial", "bigserial", "smallserial":
return true
}
return false
}
package model
import (
"strings"
"github.com/winebarrel/orderedmap/v2"
)
// CompositeAttribute is one field of a composite type (CREATE TYPE ... AS (...)).
type CompositeAttribute struct {
Name string `json:"name"`
TypeName string `json:"type"`
// Collation in quoted SQL form, ready to follow COLLATE
// (e.g. `pg_catalog."C"`). nil for the default collation.
Collation *string `json:"collation"`
// RenameFrom maps the attribute to the current attribute it renames. Set by
// the parser from an inline -- pista:renamed-from directive; always nil on
// the catalog side.
RenameFrom *string `json:"rename_from"`
Comment *string `json:"comment"`
}
type CompositeType struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Attributes []*CompositeAttribute `json:"attributes"`
Comment *string `json:"comment"`
// Ignore marks the composite type as unmanaged (set by -- pista:ignore).
// Ignored objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
}
func (ct CompositeType) FQCN() string {
return Ident(ct.Schema, ct.Name)
}
// TypeSQL renders the attribute's type with its COLLATE clause, e.g.
// "text" or "text COLLATE \"C\"".
func (a CompositeAttribute) TypeSQL() string {
return a.TypeName + collateClause(a.Collation)
}
// collateClause renders a COLLATE clause. The stored name is already quoted.
func collateClause(collation *string) string {
if collation == nil {
return ""
}
return " COLLATE " + *collation
}
func (ct CompositeType) SQL() string {
lines := make([]string, len(ct.Attributes))
for i, a := range ct.Attributes {
lines[i] = " " + Ident(a.Name) + " " + a.TypeSQL()
}
return "CREATE TYPE " + ct.FQCN() + " AS (\n" +
strings.Join(lines, ",\n") + "\n);"
}
func (ct CompositeType) CommentSQL() string {
if ct.Comment != nil {
return "COMMENT ON TYPE " + ct.FQCN() + " IS " + QuoteLiteral(*ct.Comment) + ";"
}
return ""
}
// AttributeCommentSQLs returns a COMMENT ON COLUMN statement for each attribute
// that carries a comment.
func (ct CompositeType) AttributeCommentSQLs() []string {
var stmts []string
for _, a := range ct.Attributes {
if a.Comment != nil {
stmts = append(stmts, "COMMENT ON COLUMN "+Ident(ct.Schema, ct.Name, a.Name)+" IS "+QuoteLiteral(*a.Comment)+";")
}
}
return stmts
}
func CompositeTypeToSQL(ct *CompositeType) string {
parts := []string{"-- " + ct.FQCN(), ct.SQL()}
if s := ct.CommentSQL(); s != "" {
parts = append(parts, s)
}
parts = append(parts, ct.AttributeCommentSQLs()...)
return strings.Join(parts, "\n")
}
func CompositeTypesToSQL(compositeTypes *orderedmap.Map[string, *CompositeType]) string {
return joinSQL(compositeTypes, CompositeTypeToSQL)
}
package model
import "fmt"
// https://www.postgresql.org/docs/current/catalog-pg-constraint.html
type ConstraintType byte
// MarshalJSON writes the constraint kind as a word instead of the
// pg_constraint character, since the JSON is read outside pistachio.
func (b ConstraintType) MarshalJSON() ([]byte, error) {
switch b {
case 'c':
return []byte(`"check"`), nil
case 'f':
return []byte(`"foreign_key"`), nil
case 'n':
return []byte(`"not_null"`), nil
case 'p':
return []byte(`"primary_key"`), nil
case 'u':
return []byte(`"unique"`), nil
case 'x':
return []byte(`"exclusion"`), nil
default:
return []byte(`""`), nil
}
}
func (b ConstraintType) IsCheckConstraint() bool {
return b == 'c'
}
func (b ConstraintType) IsForeignKeyConstraint() bool {
return b == 'f'
}
func (b ConstraintType) IsNotNullConstraint() bool {
return b == 'n'
}
func (b ConstraintType) IsPrimaryKeyConstraint() bool {
return b == 'p'
}
func (b ConstraintType) IsUniqueConstraint() bool {
return b == 'u'
}
func (b ConstraintType) IsExclusionConstraint() bool {
return b == 'x'
}
type Constraint struct {
OID uint32 `json:"oid"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Type ConstraintType `json:"type"`
Definition string `json:"definition"`
Columns []string `json:"columns"`
Deferrable bool `json:"deferrable"`
Deferred bool `json:"deferred"`
Validated bool `json:"validated"`
// Inherited marks a constraint a partition child holds only because its
// parent has one. PostgreSQL refuses to alter or drop such a copy, and a
// statement on the parent reaches it, so the diff leaves it alone. Only
// the catalog sets it: a desired schema declares what it writes.
Inherited bool `json:"inherited"`
// IndexName is the index a desired constraint written
// ADD CONSTRAINT ... USING INDEX takes over. Only the parser sets it:
// the promotion renames the index to the constraint's name, so the
// catalog has no such name to report. The diff keeps that index out of
// the index drops and adds, and takes an existing constraint of the same
// name, type and deferral as satisfying the declaration.
IndexName string `json:"index_name"`
Comment *string `json:"comment"`
}
func (con *Constraint) String() string {
return fmt.Sprintf("%#v", *con)
}
// CommentSQL renders the constraint's COMMENT ON, or an empty string when it
// carries none. fqtn is the table the constraint is on.
func (con Constraint) CommentSQL(fqtn string) string {
if con.Comment == nil {
return ""
}
return "COMMENT ON CONSTRAINT " + Ident(con.Name) + " ON " + fqtn + " IS " + QuoteLiteral(*con.Comment) + ";"
}
type ForeignKey struct {
Constraint
Schema string `json:"schema"`
Table string `json:"table"`
RefSchema *string `json:"ref_schema"`
RefTable *string `json:"ref_table"`
}
func (fk *ForeignKey) String() string {
return fmt.Sprintf("%#v", *fk)
}
// SQL renders the ALTER TABLE that adds the key. onPartitioned says the owning
// table is partitioned, where PostgreSQL rejects ONLY outright; a foreign key
// is not inherited either way, so the word only tracks what pg_dump writes.
func (fk ForeignKey) SQL(onPartitioned bool) string {
only := "ONLY "
if onPartitioned {
only = ""
}
sql := "ALTER TABLE " + only + Ident(fk.Schema, fk.Table) +
" ADD CONSTRAINT " + Ident(fk.Name) + " " + fk.Definition
if !fk.Validated {
sql += " NOT VALID"
}
return sql + ";"
}
package model
import (
"strings"
"github.com/winebarrel/orderedmap/v2"
)
type DomainConstraint struct {
Name string `json:"name"`
Definition string `json:"definition"`
Validated bool `json:"validated"`
Comment *string `json:"comment"`
}
// CommentSQL renders the constraint's COMMENT ON, or an empty string when it
// carries none. fqdn is the domain the constraint is on.
func (c DomainConstraint) CommentSQL(fqdn string) string {
if c.Comment == nil {
return ""
}
return "COMMENT ON CONSTRAINT " + Ident(c.Name) + " ON DOMAIN " + fqdn + " IS " + QuoteLiteral(*c.Comment) + ";"
}
type Domain struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
BaseType string `json:"base_type"`
NotNull bool `json:"not_null"`
Default *string `json:"default"`
// Collation in quoted SQL form, ready to follow COLLATE
// (e.g. `pg_catalog."C"`). nil for the default collation.
Collation *string `json:"collation"`
Constraints []*DomainConstraint `json:"constraints"`
Comment *string `json:"comment"`
// Ignore marks the domain as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
}
func (d Domain) FQDN() string {
return Ident(d.Schema, d.Name)
}
func (d Domain) SQL() string {
var sql strings.Builder
sql.WriteString("CREATE DOMAIN " + d.FQDN() + " AS " + d.BaseType)
sql.WriteString(collateClause(d.Collation))
if d.Default != nil {
sql.WriteString(" DEFAULT " + *d.Default)
}
if d.NotNull {
sql.WriteString(" NOT NULL")
}
// CREATE DOMAIN cannot write NOT VALID, so NotValidConSQL writes those
// constraints instead.
for _, c := range d.Constraints {
if !c.Validated {
continue
}
sql.WriteString("\n CONSTRAINT " + Ident(c.Name) + " " + c.Definition)
}
return sql.String() + ";"
}
// NotValidConSQL writes each NOT VALID constraint as an ALTER DOMAIN.
func (d Domain) NotValidConSQL() []string {
var stmts []string
for _, c := range d.Constraints {
if c.Validated {
continue
}
stmts = append(stmts, "ALTER DOMAIN "+d.FQDN()+" ADD CONSTRAINT "+Ident(c.Name)+" "+c.Definition+" NOT VALID;")
}
return stmts
}
func (d Domain) CommentSQL() string {
if d.Comment != nil {
return "COMMENT ON DOMAIN " + d.FQDN() + " IS " + QuoteLiteral(*d.Comment) + ";"
}
return ""
}
// ConstraintCommentSQL renders the comments on the domain's constraints.
func (d Domain) ConstraintCommentSQL() []string {
var stmts []string
for _, c := range d.Constraints {
if s := c.CommentSQL(d.FQDN()); s != "" {
stmts = append(stmts, s)
}
}
return stmts
}
func DomainToSQL(d *Domain) string {
parts := []string{"-- " + d.FQDN(), d.SQL()}
parts = append(parts, d.NotValidConSQL()...)
if s := d.CommentSQL(); s != "" {
parts = append(parts, s)
}
parts = append(parts, d.ConstraintCommentSQL()...)
return strings.Join(parts, "\n")
}
func DomainsToSQL(domains *orderedmap.Map[string, *Domain]) string {
return joinSQL(domains, DomainToSQL)
}
package model
import (
"strings"
"github.com/winebarrel/orderedmap/v2"
)
type Enum struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Values []string `json:"values"`
// ValueRenameFrom maps a desired value to the current value it renames.
// Set by the parser from -- pista:renamed-from directives inside the
// value list; always empty on the catalog side.
ValueRenameFrom map[string]string `json:"value_rename_from"`
Comment *string `json:"comment"`
// Ignore marks the enum as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
}
func (e Enum) FQEN() string {
return Ident(e.Schema, e.Name)
}
func (e Enum) SQL() string {
if len(e.Values) == 0 {
return "CREATE TYPE " + e.FQEN() + " AS ENUM (\n);"
}
quoted := make([]string, len(e.Values))
for i, v := range e.Values {
quoted[i] = QuoteLiteral(v)
}
return "CREATE TYPE " + e.FQEN() + " AS ENUM (\n " +
strings.Join(quoted, ",\n ") + "\n);"
}
func (e Enum) CommentSQL() string {
if e.Comment != nil {
return "COMMENT ON TYPE " + e.FQEN() + " IS " + QuoteLiteral(*e.Comment) + ";"
}
return ""
}
func EnumToSQL(e *Enum) string {
parts := []string{"-- " + e.FQEN(), e.SQL()}
if s := e.CommentSQL(); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, "\n")
}
func EnumsToSQL(enums *orderedmap.Map[string, *Enum]) string {
return joinSQL(enums, EnumToSQL)
}
package model
import (
"strconv"
"strings"
)
// IdentitySequence holds the parameters of the sequence behind an identity
// column, the sequence_options of GENERATED ... AS IDENTITY. PostgreSQL takes
// the sequence data type from the column, so there is no data type here.
//
// Both the catalog and the parser fill in every field, applying the defaults
// PostgreSQL derives from the column type and the increment, so the diff is a
// plain comparison. It is nil for a column that is not an identity column.
type IdentitySequence struct {
Start int64 `json:"start"`
Min int64 `json:"min"`
Max int64 `json:"max"`
Increment int64 `json:"increment"`
Cache int64 `json:"cache"`
Cycle bool `json:"cycle"`
}
// seqTypeBounds returns the min and max value of a sequence data type. An
// unknown type falls back to the bigint bounds.
func seqTypeBounds(dataType string) (int64, int64) {
switch dataType {
case "smallint":
return -32768, 32767
case "integer":
return -2147483648, 2147483647
default: // bigint
return -9223372036854775808, 9223372036854775807
}
}
// DefaultIdentitySequence returns the parameters PostgreSQL gives an identity
// column of typeName whose definition writes no option other than the
// increment. The increment decides the direction, and the direction decides
// the bounds and the start, so it has to be resolved first.
func DefaultIdentitySequence(typeName string, increment int64) IdentitySequence {
typeMin, typeMax := seqTypeBounds(typeName)
seq := IdentitySequence{Increment: increment, Cache: 1}
if increment > 0 {
seq.Min = 1
seq.Max = typeMax
seq.Start = seq.Min
} else {
seq.Min = typeMin
seq.Max = -1
seq.Start = seq.Max
}
return seq
}
// RetypedTo returns the parameters the sequence has once the column's type
// changes from oldType to newType. PostgreSQL moves a bound sitting at the old
// type's extreme to the new type's extreme, in either direction, and leaves
// any other bound alone; the start does not move. A diff that follows a type
// change reads the current side through this, or it plans a bound the retype
// already set.
func (s *IdentitySequence) RetypedTo(oldType, newType string) *IdentitySequence {
if s == nil || oldType == newType {
return s
}
oldMin, oldMax := seqTypeBounds(oldType)
newMin, newMax := seqTypeBounds(newType)
retyped := *s
if retyped.Min == oldMin {
retyped.Min = newMin
}
if retyped.Max == oldMax {
retyped.Max = newMax
}
return &retyped
}
// Options returns the sequence options that differ from the defaults for a
// column of typeName, each as a clause such as "START WITH 100". A column left
// entirely at the defaults returns nothing, so the common identity column
// keeps its short form.
func (s *IdentitySequence) Options(typeName string) []string {
if s == nil {
return nil
}
// The increment's own default is 1. The rest resolve against the increment
// this column has, so a descending identity at the bounds that direction
// implies renders as INCREMENT BY alone.
def := DefaultIdentitySequence(typeName, s.Increment)
var opts []string
if s.Start != def.Start {
opts = append(opts, "START WITH "+strconv.FormatInt(s.Start, 10))
}
if s.Increment != 1 {
opts = append(opts, "INCREMENT BY "+strconv.FormatInt(s.Increment, 10))
}
if s.Min != def.Min {
opts = append(opts, "MINVALUE "+strconv.FormatInt(s.Min, 10))
}
if s.Max != def.Max {
opts = append(opts, "MAXVALUE "+strconv.FormatInt(s.Max, 10))
}
if s.Cache != def.Cache {
opts = append(opts, "CACHE "+strconv.FormatInt(s.Cache, 10))
}
if s.Cycle != def.Cycle {
opts = append(opts, "CYCLE")
}
return opts
}
// OptionsSQL renders Options in the parenthesized form that follows
// GENERATED ... AS IDENTITY, with a leading space. It returns an empty string
// when every option is at its default.
func (s *IdentitySequence) OptionsSQL(typeName string) string {
opts := s.Options(typeName)
if len(opts) == 0 {
return ""
}
return " (" + strings.Join(opts, " ") + ")"
}
package model
type Index struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Table string `json:"table"`
Definition string `json:"definition"`
TableSpace *string `json:"table_space"`
Concurrently bool `json:"concurrently"`
Comment *string `json:"comment"`
// Attached marks an index attached to an index on the partitioned parent.
// PostgreSQL rejects a DROP of one and drops it with the parent's. Only the
// catalog sets it.
Attached bool `json:"attached"`
// Invalid marks an index whose build failed or has not finished,
// pg_index.indisvalid false. plan and apply warn about one, since it
// matches its definition but does nothing. Only the catalog sets it, and
// it stays out of the JSON, which the state hash and parse read.
Invalid bool `json:"-"`
// Size is the size estimate dump --explain writes in a comment above the
// index. Only dump sets it.
Size string `json:"-"`
}
func (idx Index) FQTN() string {
return Ident(idx.Schema, idx.Table)
}
func (idx Index) SQL() string {
return idx.Definition + ";"
}
// DumpSQL is SQL with the size comment dump --explain puts above it.
func (idx Index) DumpSQL() string {
if idx.Size == "" {
return idx.SQL()
}
return "-- " + idx.Size + "\n" + idx.SQL()
}
// CommentSQL renders the index's own COMMENT ON, or an empty string when it
// carries none. COMMENT ON INDEX names the index alone, never the relation it
// sits on.
func (idx Index) CommentSQL() string {
if idx.Comment == nil {
return ""
}
return "COMMENT ON INDEX " + Ident(idx.Schema, idx.Name) + " IS " + QuoteLiteral(*idx.Comment) + ";"
}
package model
import (
"encoding/json/jsontext"
"encoding/json/v2"
"github.com/winebarrel/orderedmap/v2"
)
// JSONMarshalers holds what the JSON document pistachio writes needs on top of
// the struct tags. `parse` and `dump` both write through it, and the JSON
// Schema describes what it produces, so a test that takes a golden through it
// sees the bytes the commands write.
var JSONMarshalers = json.WithMarshalers(json.MarshalToFunc(marshalColumns))
// marshalColumns writes a table's columns as a JSON array rather than an
// object keyed by name. The order is the physical column order, which a JSON
// object does not promise to keep, and the key an object would carry is
// already the column's name field. CollectValues holds the order and answers
// an empty table with an empty slice, which is written as [] rather than null.
func marshalColumns(enc *jsontext.Encoder, columns *orderedmap.Map[string, *Column]) error {
return json.MarshalEncode(enc, columns.CollectValues())
}
package model
import (
"fmt"
"strings"
)
// PolicyCommand mirrors pg_policy.polcmd:
//
// '*' ALL, 'r' SELECT, 'a' INSERT, 'w' UPDATE, 'd' DELETE
type PolicyCommand byte
func (c PolicyCommand) String() string {
switch c {
case '*':
return "ALL"
case 'r':
return "SELECT"
case 'a':
return "INSERT"
case 'w':
return "UPDATE"
case 'd':
return "DELETE"
default:
return ""
}
}
func (c PolicyCommand) IsAll() bool { return c == '*' }
// MarshalJSON writes the command keyword instead of the pg_policy character,
// since the JSON is read outside pistachio.
func (c PolicyCommand) MarshalJSON() ([]byte, error) {
return []byte(`"` + c.String() + `"`), nil
}
type Policy struct {
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Schema string `json:"schema"`
Table string `json:"table"`
Permissive bool `json:"permissive"`
Command PolicyCommand `json:"command"`
Roles []string `json:"roles"`
Using *string `json:"using"`
WithCheck *string `json:"with_check"`
Comment *string `json:"comment"`
}
func (p *Policy) String() string {
return fmt.Sprintf("%#v", *p)
}
// SQL renders a CREATE POLICY statement.
func (p Policy) SQL() string {
var b strings.Builder
b.WriteString("CREATE POLICY ")
b.WriteString(Ident(p.Name))
b.WriteString(" ON ")
b.WriteString(Ident(p.Schema, p.Table))
if !p.Permissive {
b.WriteString(" AS RESTRICTIVE")
}
if !p.Command.IsAll() {
b.WriteString(" FOR ")
b.WriteString(p.Command.String())
}
b.WriteString(p.rolesClause())
if p.Using != nil {
b.WriteString(" USING (")
b.WriteString(*p.Using)
b.WriteString(")")
}
if p.WithCheck != nil {
b.WriteString(" WITH CHECK (")
b.WriteString(*p.WithCheck)
b.WriteString(")")
}
b.WriteString(";")
return b.String()
}
// CommentSQL renders the policy's COMMENT ON, or an empty string when it
// carries none.
func (p Policy) CommentSQL() string {
if p.Comment == nil {
return ""
}
return "COMMENT ON POLICY " + Ident(p.Name) + " ON " + Ident(p.Schema, p.Table) + " IS " + QuoteLiteral(*p.Comment) + ";"
}
func (p Policy) rolesClause() string {
if len(p.Roles) == 0 {
return ""
}
if len(p.Roles) == 1 && p.Roles[0] == "public" {
return ""
}
return " TO " + RolesSQL(p.Roles)
}
// RolesSQL renders a policy's role list. PUBLIC and the CURRENT_USER family
// are keywords, so they are written bare; any other name is an identifier.
func RolesSQL(roles []string) string {
parts := make([]string, len(roles))
for i, r := range roles {
switch r {
case "public", "current_user", "current_role", "session_user":
parts[i] = r
default:
parts[i] = Ident(r)
}
}
return strings.Join(parts, ", ")
}
package model
import (
"fmt"
"strconv"
"strings"
"github.com/winebarrel/orderedmap/v2"
)
// Routine holds metadata for a PostgreSQL function or procedure (pg_proc rows
// with prokind 'f' or 'p'). Aggregates and window functions are not managed.
type Routine struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
Procedure bool `json:"procedure"`
Args []*RoutineArg `json:"args"`
// ReturnType is empty for a procedure. RETURNS TABLE is stored as "record"
// with ReturnsSet true; the columns live in Args with mode TABLE.
ReturnType string `json:"return_type"`
ReturnsSet bool `json:"returns_set"`
Language string `json:"language"`
// Body is the string in the AS clause. For LANGUAGE c the AS clause names
// two strings, and Body holds the link symbol while ObjFile holds the
// object file.
Body string `json:"body"`
ObjFile string `json:"obj_file"`
// SQLBody is a body written in the SQL-standard form, BEGIN ATOMIC or
// RETURN, as it renders in the CREATE statement. It is empty for a body
// in an AS clause. PostgreSQL parses such a body at creation time and
// records what it reads in pg_depend, so the routine is created after the
// tables and views rather than before them; see Atomic.
SQLBody string `json:"sql_body"`
Volatility string `json:"volatility"` // IMMUTABLE, STABLE or VOLATILE
Strict bool `json:"strict"`
SecurityDefiner bool `json:"security_definer"`
Leakproof bool `json:"leakproof"`
Parallel string `json:"parallel"` // SAFE, RESTRICTED or UNSAFE
// Cost and Rows are nil when the routine carries the default for its
// language, matching pg_get_functiondef, which prints neither.
Cost *float64 `json:"cost"`
Rows *float64 `json:"rows"`
Config []*RoutineConfig `json:"config"`
// Comment is read from pg_description on the catalog side and from
// COMMENT ON FUNCTION / PROCEDURE on the desired side.
Comment *string `json:"comment"`
// Ignore marks the routine as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
}
// RoutineArg is one entry of a routine's parameter list.
type RoutineArg struct {
// Mode is "", "OUT", "INOUT", "VARIADIC" or "TABLE". An empty Mode is IN.
Mode string `json:"mode"`
Name string `json:"name"`
// Type carries no type modifier: PostgreSQL discards one on a parameter,
// so keeping it would differ from what the catalog reports back.
Type string `json:"type"`
// Default is the deparsed default expression, empty when the parameter
// has none.
Default string `json:"default"`
}
// RoutineConfig is one SET clause on a routine (pg_proc.proconfig).
type RoutineConfig struct {
Name string `json:"name"`
Args []string `json:"args"`
}
// Volatility and parallel-safety values, and the defaults pg_get_functiondef
// leaves out of its output.
const (
VolatilityVolatile = "VOLATILE"
ParallelUnsafe = "UNSAFE"
)
// InArg reports whether the parameter is part of the routine's identity.
// PostgreSQL keys pg_proc by the IN, INOUT and VARIADIC parameter types, so
// those are the ones FQRN spells out.
func (a RoutineArg) InArg() bool {
switch a.Mode {
case "", "INOUT", "VARIADIC":
return true
default:
return false
}
}
// FQRN returns the key the diff matches routines by: the schema-qualified name
// with the identity argument list, in the shape
// pg_get_function_identity_arguments produces. Two routines in one schema can
// share a name, so the argument list is part of it.
//
// A type in the routine's own schema loses that qualifier here. The catalog
// reports such a type bare when search_path reaches it while a desired schema
// may write it qualified, and without this the two spellings would key as two
// routines, so a plan would create one and drop the other on every run.
// Signature renders the types as they were read, for the SQL that has to
// resolve them.
func (r Routine) FQRN() string {
return Ident(r.Schema, r.Name) + "(" + strings.Join(r.argTypes(true), ", ") + ")"
}
// Signature returns the schema-qualified name and identity argument list with
// the type names as read, for a DROP or a COMMENT that PostgreSQL has to
// resolve under the same search_path the types were reported through.
func (r Routine) Signature() string {
return Ident(r.Schema, r.Name) + "(" + strings.Join(r.argTypes(false), ", ") + ")"
}
func (r Routine) argTypes(stripSchema bool) []string {
types := make([]string, 0, len(r.Args))
for _, a := range r.Args {
if !a.InArg() {
continue
}
if stripSchema {
types = append(types, StripTypeSchema(a.Type, Ident(r.Schema)))
continue
}
types = append(types, a.Type)
}
return types
}
// Atomic reports whether the body is written in the SQL-standard form.
func (r Routine) Atomic() bool {
return r.SQLBody != ""
}
// Kind returns the keyword the DDL for this routine uses.
func (r Routine) Kind() string {
if r.Procedure {
return "PROCEDURE"
}
return "FUNCTION"
}
// TableArgs returns the RETURNS TABLE columns, empty for any other routine.
func (r Routine) TableArgs() []*RoutineArg {
var args []*RoutineArg
for _, a := range r.Args {
if a.Mode == "TABLE" {
args = append(args, a)
}
}
return args
}
func (a RoutineArg) String() string {
var b strings.Builder
if a.Mode != "" && a.Mode != "TABLE" {
b.WriteString(a.Mode)
b.WriteByte(' ')
}
if a.Name != "" {
b.WriteString(quoteParamName(a.Name))
b.WriteByte(' ')
}
b.WriteString(a.Type)
if a.Default != "" {
b.WriteString(" DEFAULT ")
b.WriteString(a.Default)
}
return b.String()
}
// SQL returns the canonical CREATE statement. Both sides of the diff build a
// Routine through the parser and render it here, so a dump fed back as the
// desired schema produces the same text again.
func (r Routine) SQL() string {
params := make([]string, 0, len(r.Args))
for _, a := range r.Args {
if a.Mode == "TABLE" {
continue
}
params = append(params, a.String())
}
lines := []string{
"CREATE OR REPLACE " + r.Kind() + " " + Ident(r.Schema, r.Name) + "(" + strings.Join(params, ", ") + ")",
}
if ret := r.returnsClause(); ret != "" {
lines = append(lines, " "+ret)
}
lines = append(lines, " LANGUAGE "+quoteIdent(r.Language))
if attrs := r.attrClause(); attrs != "" {
lines = append(lines, " "+attrs)
}
for _, c := range r.Config {
lines = append(lines, " "+c.SQL())
}
if r.Atomic() {
lines = append(lines, indentLines(r.SQLBody, " "))
} else {
lines = append(lines, " AS "+r.asClause())
}
return strings.Join(lines, "\n") + ";"
}
func (r Routine) returnsClause() string {
if r.Procedure {
return ""
}
if tableArgs := r.TableArgs(); len(tableArgs) > 0 {
cols := make([]string, len(tableArgs))
for i, a := range tableArgs {
cols[i] = quoteParamName(a.Name) + " " + a.Type
}
return "RETURNS TABLE(" + strings.Join(cols, ", ") + ")"
}
if r.ReturnType == "" {
return ""
}
if r.ReturnsSet {
return "RETURNS SETOF " + r.ReturnType
}
return "RETURNS " + r.ReturnType
}
// attrClause renders the attributes that differ from the defaults.
// pg_get_functiondef leaves the defaults out, so rendering them would make a
// hand-written VOLATILE or PARALLEL UNSAFE read as a change on every run.
func (r Routine) attrClause() string {
var attrs []string
if r.Volatility != "" && r.Volatility != VolatilityVolatile {
attrs = append(attrs, r.Volatility)
}
if r.Strict {
attrs = append(attrs, "STRICT")
}
if r.SecurityDefiner {
attrs = append(attrs, "SECURITY DEFINER")
}
if r.Leakproof {
attrs = append(attrs, "LEAKPROOF")
}
if r.Parallel != "" && r.Parallel != ParallelUnsafe {
attrs = append(attrs, "PARALLEL "+r.Parallel)
}
if r.Cost != nil {
attrs = append(attrs, "COST "+formatFloat(*r.Cost))
}
if r.Rows != nil {
attrs = append(attrs, "ROWS "+formatFloat(*r.Rows))
}
return strings.Join(attrs, " ")
}
func (r Routine) asClause() string {
if r.ObjFile != "" {
return QuoteLiteral(r.ObjFile) + ", " + QuoteLiteral(r.Body)
}
return DollarQuote(r.Body)
}
// SQL renders one SET clause. PostgreSQL parses SET x TO y and SET x TO 'y'
// to the same value, so either spelling in the desired schema matches what
// the catalog reports.
func (c RoutineConfig) SQL() string {
args := make([]string, len(c.Args))
for i, a := range c.Args {
args[i] = configValue(a)
}
return "SET " + quoteIdent(c.Name) + " TO " + strings.Join(args, ", ")
}
// configValue renders one SET value the way pg_get_functiondef does: bare when
// the value is an identifier, a string literal otherwise. A double-quoted form
// would read as an identifier, which is not what a value like 64MB means.
func configValue(v string) string {
if quoteIdent(v) == v {
return v
}
return QuoteLiteral(v)
}
func indentLines(s, indent string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = indent + l
}
return strings.Join(lines, "\n")
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
// DollarQuote wraps s in the shortest dollar-quote delimiter it does not
// already contain. A $ at the end of s would join the closing tag, so the
// check adds one to s.
func DollarQuote(s string) string {
tag := "$$"
for i := 1; strings.Contains(s+"$", tag); i++ {
tag = "$" + strings.Repeat("_", i) + "$"
}
return tag + s + tag
}
func (r Routine) CommentSQL() string {
if r.Comment != nil {
return "COMMENT ON " + r.Kind() + " " + r.Signature() + " IS " + QuoteLiteral(*r.Comment) + ";"
}
return ""
}
// DropSQL returns the DROP statement for the routine.
func (r Routine) DropSQL() string {
return "DROP " + r.Kind() + " " + r.Signature() + ";"
}
// String returns a debug-friendly representation.
func (r Routine) String() string {
return fmt.Sprintf("%#v", r)
}
func RoutineToSQL(r *Routine) string {
parts := []string{"-- " + r.Signature(), r.SQL()}
if s := r.CommentSQL(); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, "\n")
}
func RoutinesToSQL(routines *orderedmap.Map[string, *Routine]) string {
return joinSQL(routines, RoutineToSQL)
}
package model
import (
"fmt"
"strconv"
"strings"
"github.com/winebarrel/orderedmap/v2"
)
// Sequence holds a PostgreSQL sequence. The sequences of identity columns, and
// of serial columns with the default sequence name, belong to the column and
// are not held here. Other owned sequences are, with their owner.
type Sequence struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
DataType string `json:"data_type"`
Start int64 `json:"start"`
Min int64 `json:"min"`
Max int64 `json:"max"`
Increment int64 `json:"increment"`
Cache int64 `json:"cache"`
Cycle bool `json:"cycle"`
Unlogged bool `json:"unlogged"`
// OwnerTable and OwnerColumn name the column the sequence is OWNED BY,
// nil when no column owns it. PostgreSQL requires the table to be in the
// sequence's schema, so the table name is not qualified.
OwnerTable *string `json:"owner_table"`
OwnerColumn *string `json:"owner_column"`
RenameFrom *string `json:"rename_from"`
Comment *string `json:"comment"`
// Ignore marks the sequence as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the catalog
// side.
Ignore bool `json:"ignore"`
}
func (seq Sequence) FQN() string {
return Ident(seq.Schema, seq.Name)
}
// Owned reports whether a table column owns the sequence.
func (seq Sequence) Owned() bool {
return seq.OwnerTable != nil && seq.OwnerColumn != nil
}
// OwnerFQTN returns the qualified name of the table that owns the sequence, or
// "" when no column owns it.
func (seq Sequence) OwnerFQTN() string {
if !seq.Owned() {
return ""
}
return Ident(seq.Schema, *seq.OwnerTable)
}
// OwnedBySQL returns the ALTER SEQUENCE ... OWNED BY for the sequence, or ""
// when no column owns it. It is separate from SQL because the sequence is
// created before the table and OWNED BY needs the table.
func (seq Sequence) OwnedBySQL() string {
if !seq.Owned() {
return ""
}
return "ALTER SEQUENCE " + seq.FQN() + " OWNED BY " + Ident(seq.Schema, *seq.OwnerTable, *seq.OwnerColumn) + ";"
}
func (seq Sequence) SQL() string {
create := "CREATE SEQUENCE "
if seq.Unlogged {
create = "CREATE UNLOGGED SEQUENCE "
}
lines := []string{
create + seq.FQN(),
" AS " + seq.DataType,
" START WITH " + strconv.FormatInt(seq.Start, 10),
" INCREMENT BY " + strconv.FormatInt(seq.Increment, 10),
" MINVALUE " + strconv.FormatInt(seq.Min, 10),
" MAXVALUE " + strconv.FormatInt(seq.Max, 10),
" CACHE " + strconv.FormatInt(seq.Cache, 10),
}
if seq.Cycle {
lines = append(lines, " CYCLE")
}
return strings.Join(lines, "\n") + ";"
}
func (seq Sequence) CommentSQL() string {
if seq.Comment != nil {
return "COMMENT ON SEQUENCE " + seq.FQN() + " IS " + QuoteLiteral(*seq.Comment) + ";"
}
return ""
}
// String returns a debug-friendly representation.
func (seq Sequence) String() string {
return fmt.Sprintf("%#v", seq)
}
func SequenceToSQL(seq *Sequence) string {
parts := []string{"-- " + seq.FQN(), seq.SQL()}
if s := seq.CommentSQL(); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, "\n")
}
func SequencesToSQL(sequences *orderedmap.Map[string, *Sequence]) string {
return joinSQL(sequences, SequenceToSQL)
}
// SequencesOwnedBySQL returns the OWNED BY statement of every owned sequence,
// in the order of the map, or "" when no column owns any of them.
func SequencesOwnedBySQL(sequences *orderedmap.Map[string, *Sequence]) string {
var stmts []string
for seq := range sequences.Values() {
if s := seq.OwnedBySQL(); s != "" {
stmts = append(stmts, s)
}
}
return strings.Join(stmts, "\n")
}
package model
import (
"maps"
"regexp"
"slices"
"strings"
"github.com/winebarrel/orderedmap/v2"
)
type Table struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
BulkAlter bool `json:"bulk_alter"`
// Ignore marks the table as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
TableSpace *string `json:"table_space"`
// StorageParams holds the table's storage parameters, pg_class.reloptions,
// keyed by parameter name and ordered by it. A `toast.` prefix names a
// parameter of the TOAST relation, which is where pg_dump writes it too.
StorageParams *orderedmap.Map[string, string] `json:"storage_params"`
Unlogged bool `json:"unlogged"`
Partitioned bool `json:"partitioned"`
PartitionDef *string `json:"partition_def"`
PartitionOf *string `json:"partition_of"`
PartitionBound *string `json:"partition_bound"`
RowSecurity bool `json:"row_security"`
ForceRowSecurity bool `json:"force_row_security"`
Columns *orderedmap.Map[string, *Column] `json:"columns"`
Constraints *orderedmap.Map[string, *Constraint] `json:"constraints"`
ForeignKeys *orderedmap.Map[string, *ForeignKey] `json:"foreign_keys"`
Indexes *orderedmap.Map[string, *Index] `json:"indexes"`
Policies *orderedmap.Map[string, *Policy] `json:"policies"`
Triggers *orderedmap.Map[string, *Trigger] `json:"triggers"`
Comment *string `json:"comment"`
// Size is the size estimate dump --explain writes after the table's name
// in the comment above its CREATE TABLE. Only dump sets it.
Size string `json:"-"`
}
// IsPartitionChild reports whether the table is a partition of a declarative
// partitioned table. An INHERITS child also carries PartitionOf, but no bound,
// so the bound is what separates the two.
func (t Table) IsPartitionChild() bool {
return t.PartitionOf != nil && t.PartitionBound != nil
}
// IsInheritsChild reports whether the table inherits from a parent without
// being a partition of it, which is the case the bound separates from the one
// IsPartitionChild reports. Such a child declares columns and constraints of
// its own alongside the ones it inherits, and both sides of the diff carry the
// local half alone: the catalog filters on attislocal and conislocal, and a
// desired schema names what the child's CREATE TABLE writes.
func (t Table) IsInheritsChild() bool {
return t.PartitionOf != nil && t.PartitionBound == nil
}
func (t Table) FQTN() string {
return Ident(t.Schema, t.Name)
}
func (t Table) SQL() string {
sql := "CREATE "
if t.Unlogged {
sql += "UNLOGGED "
}
sql += "TABLE " + t.FQTN()
if t.IsPartitionChild() {
sql += " PARTITION OF " + *t.PartitionOf + " " + *t.PartitionBound
} else {
defs := slices.Concat(
t.Columns.TransformSlice(func(_ string, col *Column) string {
q := " " + Ident(col.Name) + " " + col.TypeName
q += collateClause(col.Collation)
if col.Identity.IsGeneratedAlways() {
q += " GENERATED ALWAYS AS IDENTITY" + col.IdentitySeq.OptionsSQL(col.TypeName)
} else if col.Identity.IsGeneratedByDefault() {
q += " GENERATED BY DEFAULT AS IDENTITY" + col.IdentitySeq.OptionsSQL(col.TypeName)
} else if col.Generated.IsStoredGeneratedColumn() {
if col.Default != nil {
q += " GENERATED ALWAYS AS (" + *col.Default + ") STORED"
}
} else if col.Default != nil && !IsSerialType(col.TypeName) {
q += " DEFAULT " + *col.Default
}
if col.NotNull && !col.Identity.IsIdentityColumn() {
if col.NotNullName != nil {
q += " CONSTRAINT " + Ident(*col.NotNullName)
}
q += " NOT NULL"
}
return q
}),
t.inlineConstraintDefs(),
)
// An INHERITS child that declares nothing of its own writes the parentheses
// empty, the way pg_dump does, rather than the blank line a join of no
// elements would leave between them.
if len(defs) == 0 {
sql += " (\n)"
} else {
sql += " (\n" + strings.Join(defs, ",\n") + "\n)"
}
// The grammar takes INHERITS after the column list, so the child's own
// declarations sit in the parentheses above and the parent follows them.
if t.IsInheritsChild() {
sql += "\nINHERITS (" + *t.PartitionOf + ")"
}
}
if t.Partitioned && t.PartitionDef != nil {
sql += "\nPARTITION BY " + *t.PartitionDef
}
if w := t.StorageParamsSQL(); w != "" {
sql += "\n" + w
}
if t.TableSpace != nil {
sql += "\nTABLESPACE " + Ident(*t.TableSpace)
}
return sql + ";"
}
// inlineConstraintDefs renders the constraints written inside CREATE TABLE.
// A NOT VALID check is left out, since the clause cannot be spelled there and
// writing the constraint without it would restore it validated; NotValidConSQL
// adds it back. A key foldedKeys finds is left out as well, and FoldedKeySQL
// adds it back. So is a key written USING INDEX, since its index does not
// exist yet; UsingIndexConSQL adds it back.
func (t Table) inlineConstraintDefs() []string {
folded := t.foldedKeys()
var defs []string
for _, con := range t.Constraints.CollectValues() {
if con.Type.IsCheckConstraint() && !con.Validated || folded[con.Name] || con.IndexName != "" {
continue
}
defs = append(defs, " CONSTRAINT "+Ident(con.Name)+" "+con.Definition)
}
return defs
}
// foldedKeys returns the keys CREATE TABLE would drop. Of two matching primary
// key, unique or exclusion constraints, PostgreSQL keeps one, the primary key
// first. The comparison treats a primary key as unique and ignores the index's
// storage parameters and tablespace.
func (t Table) foldedKeys() map[string]bool {
folded := map[string]bool{}
seen := map[string]bool{}
visit := func(con *Constraint, def string) {
def = keyStorageRE.ReplaceAllString(def, ")")
def = keyTablespaceRE.ReplaceAllString(def, "")
if seen[def] {
folded[con.Name] = true
}
seen[def] = true
}
cons := t.Constraints.CollectValues()
for _, con := range cons {
if con.Type.IsPrimaryKeyConstraint() {
visit(con, "UNIQUE"+strings.TrimPrefix(con.Definition, "PRIMARY KEY"))
}
}
for _, con := range cons {
if con.Type.IsUniqueConstraint() || con.Type.IsExclusionConstraint() {
visit(con, con.Definition)
}
}
return folded
}
var (
keyStorageRE = regexp.MustCompile(`\) WITH \((?:[^()]|\([^()]*\))*\)`)
keyTablespaceRE = regexp.MustCompile(` USING INDEX TABLESPACE (?:"(?:[^"]|"")*"|[^ ]+)`)
)
// alterPrefix starts an ALTER TABLE that adds a constraint. ONLY is left off on
// a partitioned table, which rejects it once a partition exists.
func (t Table) alterPrefix() string {
if t.Partitioned {
return "ALTER TABLE " + t.FQTN()
}
return "ALTER TABLE ONLY " + t.FQTN()
}
// FoldedKeySQL adds the keys foldedKeys finds after the table, where
// PostgreSQL keeps them. A partition child adds nothing: its keys come from the
// parent.
func (t Table) FoldedKeySQL() []string {
if t.IsPartitionChild() {
return nil
}
folded := t.foldedKeys()
var stmts []string
for _, con := range t.Constraints.CollectValues() {
if folded[con.Name] {
stmts = append(stmts, t.alterPrefix()+" ADD CONSTRAINT "+Ident(con.Name)+" "+con.Definition+";")
}
}
return stmts
}
// NotValidConSQL renders the table's NOT VALID check constraints, each as its
// own ALTER TABLE after the table. A partition child renders nothing:
// its unvalidated entries are the clones the parent's statement creates. An
// INHERITS child carries only its own constraints, so it renders them the way
// a plain table does; writing one inline would restore it validated.
func (t Table) NotValidConSQL() []string {
if t.IsPartitionChild() {
return nil
}
var stmts []string
for _, con := range t.Constraints.CollectValues() {
if !con.Type.IsCheckConstraint() || con.Validated {
continue
}
stmts = append(stmts, t.alterPrefix()+" ADD CONSTRAINT "+Ident(con.Name)+" "+con.Definition+" NOT VALID;")
}
return stmts
}
// UsingIndexConSQL adds the constraints written USING INDEX with ALTER TABLE.
// They go after the table's indexes, since each takes one over.
func (t Table) UsingIndexConSQL() []string {
var stmts []string
for _, con := range t.Constraints.CollectValues() {
if con.IndexName != "" {
stmts = append(stmts, t.alterPrefix()+" ADD CONSTRAINT "+Ident(con.Name)+" "+con.Definition+";")
}
}
return stmts
}
func (t Table) IdxSQL() string {
return strings.Join(
t.Indexes.TransformSlice(func(_ string, idx *Index) string { return idx.DumpSQL() }),
"\n",
)
}
// FkSQL renders the table's foreign keys. A partition's copy of a key its
// parent declares is left out: the parent's statement creates the copy, so
// writing it as well gives a file that does not reload. pg_dump leaves it out
// the same way.
func (t Table) FkSQL() string {
var stmts []string
for _, fk := range t.ForeignKeys.CollectValues() {
if fk.Inherited {
continue
}
stmts = append(stmts, fk.SQL(t.Partitioned))
}
return strings.Join(stmts, "\n")
}
func (t Table) RLSSQL() []string {
var stmts []string
if t.RowSecurity {
stmts = append(stmts, "ALTER TABLE "+t.FQTN()+" ENABLE ROW LEVEL SECURITY;")
}
if t.ForceRowSecurity {
stmts = append(stmts, "ALTER TABLE "+t.FQTN()+" FORCE ROW LEVEL SECURITY;")
}
return stmts
}
// PolicySQL renders the table's policies. dump writes them with the table; the
// plan runs them after every table and view.
func (t Table) PolicySQL() []string {
var stmts []string
if t.Policies != nil {
for _, p := range t.Policies.CollectValues() {
stmts = append(stmts, p.SQL())
}
}
return stmts
}
// TrigSQL renders the table's triggers, each followed by the ALTER TABLE that
// puts it in a non-default enable state.
func (t Table) TrigSQL() []string {
var stmts []string
if t.Triggers != nil {
for _, trg := range t.Triggers.CollectValues() {
stmts = append(stmts, trg.SQL())
if s := trg.StateSQL(); s != "" {
stmts = append(stmts, s)
}
}
}
return stmts
}
// SortedStorageParams turns a name -> value map of storage parameters into an
// ordered map keyed in name order, so neither the comparison nor the dump
// depends on the order a file or the catalog kept them in.
func SortedStorageParams(params map[string]string) *orderedmap.Map[string, string] {
om := orderedmap.New[string, string]()
for _, name := range slices.Sorted(maps.Keys(params)) {
om.Set(name, params[name])
}
return om
}
// StorageParamsSQL renders the table's storage parameters as the WITH clause
// of a CREATE TABLE, or "" when it holds none. Every value is quoted.
// PostgreSQL accepts that for any parameter, so the renderer does not have to
// decide which spellings pass as a bare identifier.
func (t Table) StorageParamsSQL() string {
return storageParamsWith(t.StorageParams)
}
// storageParamsWith renders storage parameters as a WITH clause, or "" when
// there are none. Tables and views share it.
func storageParamsWith(params *orderedmap.Map[string, string]) string {
if params == nil || params.Len() == 0 {
return ""
}
return "WITH (" + strings.Join(
params.TransformSlice(func(name, value string) string {
return name + "=" + QuoteLiteral(value)
}),
", ",
) + ")"
}
// SetStorageParamsSQL returns the statement that sets the named storage
// parameters. It does not rewrite the table.
func SetStorageParamsSQL(fqtn string, params []string) string {
return SetViewStorageParamsSQL(fqtn, "TABLE", params)
}
// ResetStorageParamsSQL returns the statement that hands the named storage
// parameters back to their defaults. RESET is the only way to say that: the
// catalog holds no entry for a parameter that was never set, so a SET has no
// default value to name.
func ResetStorageParamsSQL(fqtn string, names []string) string {
return ResetViewStorageParamsSQL(fqtn, "TABLE", names)
}
// SetStorageSQL returns the statement that puts a column's TOAST strategy at
// storage, one of the four keywords. It is a statement of its own because a
// column definition accepts STORAGE only from PostgreSQL 16 on.
func SetStorageSQL(fqtn, col, storage string) string {
return "ALTER TABLE " + fqtn + " ALTER COLUMN " + Ident(col) + " SET STORAGE " + strings.ToUpper(storage) + ";"
}
// SetCompressionSQL returns the statement that puts a column's TOAST
// compression at compression. An empty method hands the column back to
// default_toast_compression.
func SetCompressionSQL(fqtn, col, compression string) string {
if compression == "" {
compression = "default"
}
return "ALTER TABLE " + fqtn + " ALTER COLUMN " + Ident(col) + " SET COMPRESSION " + compression + ";"
}
// StorageSQL renders the columns whose TOAST storage or compression is not the
// default. A partition child declares no columns of its own and copies both off
// the parent attribute as it is created, so it renders nothing. An INHERITS
// child renders the columns it declares.
func (t Table) StorageSQL() []string {
if t.IsPartitionChild() {
return nil
}
var stmts []string
for _, col := range t.Columns.CollectValues() {
if col.StorageType != "" && col.StorageType != col.TypeStorage {
stmts = append(stmts, SetStorageSQL(t.FQTN(), col.Name, col.StorageType))
}
if col.Compression != "" {
stmts = append(stmts, SetCompressionSQL(t.FQTN(), col.Name, col.Compression))
}
}
return stmts
}
// CommentSQL renders the comments the table and the objects on it carry, in
// the order dump writes the objects. The index comments are left out: dump
// writes them right after the indexes, with IndexCommentSQL.
func (t Table) CommentSQL() []string {
return slices.Concat(
t.RelationCommentSQL(),
t.ConstraintCommentSQL(),
t.FkCommentSQL(),
t.PolicyCommentSQL(),
t.TrigCommentSQL(),
)
}
// RelationCommentSQL renders the comments on the table and its columns.
func (t Table) RelationCommentSQL() []string {
var stmts []string
if t.Comment != nil {
stmts = append(stmts, "COMMENT ON TABLE "+t.FQTN()+" IS "+QuoteLiteral(*t.Comment)+";")
}
for _, col := range t.Columns.CollectValues() {
if col.Comment != nil {
stmts = append(stmts, "COMMENT ON COLUMN "+t.FQTN()+"."+Ident(col.Name)+" IS "+QuoteLiteral(*col.Comment)+";")
}
}
return stmts
}
// IndexCommentSQL renders the comments on the table's indexes.
func (t Table) IndexCommentSQL() []string {
var stmts []string
for _, idx := range t.Indexes.CollectValues() {
if s := idx.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
return stmts
}
// ConstraintCommentSQL renders the comments on the table's constraints other
// than its foreign keys. A partition child renders nothing, the way
// NotValidConSQL does: its constraints are the clones the parent creates.
func (t Table) ConstraintCommentSQL() []string {
if t.IsPartitionChild() {
return nil
}
var stmts []string
for _, con := range t.Constraints.CollectValues() {
if con.Inherited {
continue
}
if s := con.CommentSQL(t.FQTN()); s != "" {
stmts = append(stmts, s)
}
}
return stmts
}
// FkCommentSQL renders the comments on the table's foreign keys, leaving out a
// partition's copy of its parent's key the way FkSQL does.
func (t Table) FkCommentSQL() []string {
var stmts []string
for _, fk := range t.ForeignKeys.CollectValues() {
if fk.Inherited {
continue
}
if s := fk.CommentSQL(t.FQTN()); s != "" {
stmts = append(stmts, s)
}
}
return stmts
}
// PolicyCommentSQL renders the comments on the table's policies.
func (t Table) PolicyCommentSQL() []string {
var stmts []string
if t.Policies != nil {
for _, p := range t.Policies.CollectValues() {
if s := p.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
}
return stmts
}
// TrigCommentSQL renders the comments on the table's triggers.
func (t Table) TrigCommentSQL() []string {
var stmts []string
if t.Triggers != nil {
for _, trg := range t.Triggers.CollectValues() {
if s := trg.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
}
return stmts
}
// sizedHeader writes the comment dump puts above a relation, with the size
// estimate after the name when dump --explain read one.
func sizedHeader(name, size string) string {
if size == "" {
return "-- " + name
}
return "-- " + name + " (" + size + ")"
}
func TableToSQL(t *Table) string {
parts := []string{sizedHeader(t.FQTN(), t.Size), t.SQL()}
parts = append(parts, t.NotValidConSQL()...)
parts = append(parts, t.FoldedKeySQL()...)
parts = append(parts, t.StorageSQL()...)
if s := t.IdxSQL(); s != "" {
parts = append(parts, s)
}
// The index comments go before the USING INDEX constraints, since ADD
// CONSTRAINT renames the index it takes over.
parts = append(parts, t.IndexCommentSQL()...)
parts = append(parts, t.UsingIndexConSQL()...)
if s := t.FkSQL(); s != "" {
parts = append(parts, "\n"+s)
}
if s := slices.Concat(t.RLSSQL(), t.PolicySQL()); len(s) > 0 {
parts = append(parts, "\n"+strings.Join(s, "\n"))
}
if s := t.TrigSQL(); len(s) > 0 {
parts = append(parts, "\n"+strings.Join(s, "\n"))
}
parts = append(parts, t.CommentSQL()...)
return strings.Join(parts, "\n")
}
func TablesToSQL(tables *orderedmap.Map[string, *Table]) string {
return joinSQL(tables, TableToSQL)
}
package model
import "fmt"
// TriggerState mirrors pg_trigger.tgenabled:
//
// 'O' fires in origin and local sessions, which is where CREATE TRIGGER
// leaves a new trigger, 'D' never fires, 'R' fires in replica mode only,
// and 'A' fires in every mode.
type TriggerState byte
// TriggerStateDefault is the state CREATE TRIGGER leaves behind.
const TriggerStateDefault TriggerState = 'O'
// IsDefault reports the state a bare CREATE TRIGGER produces. The zero value
// counts, so a trigger built without a state reads as enabled.
func (s TriggerState) IsDefault() bool {
return s == 0 || s == TriggerStateDefault
}
// MarshalJSON writes the state as a word instead of the pg_trigger character,
// since the JSON is read outside pistachio.
func (s TriggerState) MarshalJSON() ([]byte, error) {
switch s {
case 'D':
return []byte(`"disabled"`), nil
case 'R':
return []byte(`"replica"`), nil
case 'A':
return []byte(`"always"`), nil
default:
return []byte(`"enabled"`), nil
}
}
// Action returns the ALTER TABLE action that puts a trigger in the state.
func (s TriggerState) Action() string {
switch s {
case 'D':
return "DISABLE"
case 'R':
return "ENABLE REPLICA"
case 'A':
return "ENABLE ALWAYS"
default:
return "ENABLE"
}
}
type Trigger struct {
Schema string `json:"schema"`
Table string `json:"table"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
// Definition is the whole CREATE TRIGGER statement without its
// terminator: what pg_get_triggerdef writes on the catalog side, and what
// pg_query deparses on the desired side. The two renderings differ in
// places, so the diff cannot compare this text directly; see
// diff.equalTriggerDef.
Definition string `json:"definition"`
State TriggerState `json:"state"`
Comment *string `json:"comment"`
}
func (trg *Trigger) String() string {
return fmt.Sprintf("%#v", *trg)
}
// FQTN returns the qualified name of the table or view the trigger is on.
func (trg Trigger) FQTN() string {
return Ident(trg.Schema, trg.Table)
}
func (trg Trigger) SQL() string {
return trg.Definition + ";"
}
// CommentSQL renders the trigger's COMMENT ON, or an empty string when it
// carries none.
func (trg Trigger) CommentSQL() string {
if trg.Comment == nil {
return ""
}
return "COMMENT ON TRIGGER " + Ident(trg.Name) + " ON " + trg.FQTN() + " IS " + QuoteLiteral(*trg.Comment) + ";"
}
// StateSQL renders the ALTER TABLE that leaves the trigger in a non-default
// state, and "" for the default one, which CREATE TRIGGER already produces.
func (trg Trigger) StateSQL() string {
if trg.State.IsDefault() {
return ""
}
return TriggerStateSQL(trg.FQTN(), trg.Name, trg.State)
}
// TriggerStateSQL renders the ALTER TABLE that puts a trigger in the given
// state. Exported so diff can reuse it for the case StateSQL suppresses: a
// trigger whose definition did not change going back to the default state.
func TriggerStateSQL(fqtn, name string, state TriggerState) string {
return "ALTER TABLE " + fqtn + " " + state.Action() + " TRIGGER " + Ident(name) + ";"
}
package model
import (
"regexp"
"strings"
"sync"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
)
var safeIdentifierPattern = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
func Ident(names ...string) string {
var idents []string
for _, n := range names {
if n == "" {
continue
}
idents = append(idents, quoteIdent(n))
}
return strings.Join(idents, ".")
}
func quoteIdent(name string) string {
if name == "" {
return `""`
}
// A plan quotes the same names many times, and the keyword check is a
// scan through cgo, so the answer is kept per name.
if v, ok := quotedIdents.Load(name); ok {
return v.(string)
}
v := quoteIdentUncached(name)
quotedIdents.Store(name, v)
return v
}
var quotedIdents sync.Map
func quoteIdentUncached(name string) string {
if !safeIdentifierPattern.MatchString(name) {
return quote(name)
}
result, err := pg_query.Scan(name)
if err != nil || len(result.Tokens) != 1 {
return quote(name)
}
// Identifiers are emitted in ColId positions, which accept a bare
// identifier, an unreserved keyword, or a col_name keyword. Reserved and
// type_func_name keywords need quotes. The list is an allow list so a
// category added upstream is quoted until it is reviewed.
switch result.Tokens[0].KeywordKind {
case pg_query.KeywordKind_NO_KEYWORD,
pg_query.KeywordKind_UNRESERVED_KEYWORD,
pg_query.KeywordKind_COL_NAME_KEYWORD:
return name
default:
return quote(name)
}
}
// quoteParamName quotes the name of a routine parameter or RETURNS TABLE
// column. That position takes a bare identifier, an unreserved keyword or a
// type_func_name keyword, but not a col_name keyword such as time or values,
// which quoteIdent leaves bare.
func quoteParamName(name string) string {
q := quoteIdent(name)
if q != name {
return q
}
result, err := pg_query.Scan(name)
if err == nil && len(result.Tokens) == 1 && result.Tokens[0].KeywordKind == pg_query.KeywordKind_COL_NAME_KEYWORD {
return quote(name)
}
return name
}
// SplitQualifiedName splits a qualified name into its parts, respecting
// quoting: `public."my.coll"` -> [`public`, `"my.coll"`].
func SplitQualifiedName(s string) []string {
var parts []string
var current strings.Builder
inQuote := false
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case ch == '"':
if inQuote && i+1 < len(s) && s[i+1] == '"' {
current.WriteString(`""`)
i++
} else {
inQuote = !inQuote
current.WriteByte(ch)
}
case ch == '.' && !inQuote:
parts = append(parts, strings.TrimSpace(current.String()))
current.Reset()
default:
current.WriteByte(ch)
}
}
if current.Len() > 0 {
parts = append(parts, strings.TrimSpace(current.String()))
}
return parts
}
// UnquoteIdent is the inverse of quoteIdent: it strips the surrounding double
// quotes and unescapes doubled ones. An unquoted identifier is folded to lower
// case, the way PostgreSQL reads it.
func UnquoteIdent(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return strings.ReplaceAll(s[1:len(s)-1], `""`, `"`)
}
return strings.ToLower(s)
}
func quote(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func QuoteLiteral(s string) string {
return `'` + strings.ReplaceAll(s, `'`, `''`) + `'`
}
// StripTypeSchema removes a redundant "<schema>." qualifier from a type name,
// preserving any trailing "[]". The catalog reports a type in the search path
// unqualified (via format_type), while desired SQL may write it schema-
// qualified. Stripping the owning object's schema makes the two forms compare
// equal. schema is the quoted form Ident writes, since that is how a type name
// spells it. A type in a different search-path schema than its owner is not
// covered.
func StripTypeSchema(typeName, schema string) string {
if schema == "" {
return typeName
}
return strings.TrimPrefix(typeName, schema+".")
}
// joinSQL renders each value of m with f and joins the results with a blank
// line.
func joinSQL[V any](m *orderedmap.Map[string, V], f func(V) string) string {
return strings.Join(
m.TransformSlice(func(_ string, v V) string {
return f(v)
}),
"\n\n",
)
}
package model
import (
"strings"
"github.com/winebarrel/orderedmap/v2"
)
type View struct {
OID uint32 `json:"oid"`
Schema string `json:"schema"`
Name string `json:"name"`
RenameFrom *string `json:"rename_from"`
Definition string `json:"definition"`
Materialized bool `json:"materialized"`
// CheckOption is the view's WITH CHECK OPTION, "local" or "cascaded", and
// empty when the view has none. A materialized view never has one.
CheckOption string `json:"check_option"`
// WithNoData is a materialized view's WITH NO DATA, which creates it
// without running its query. Only the desired side sets it, and it only
// shapes the CREATE statement: whether a view is populated is data state
// that REFRESH changes, so it is not compared and dump does not write it.
WithNoData bool `json:"-"`
// ColumnNames is the column list of CREATE VIEW v (x, y) that the parser
// could not write onto the query's target list: a star or VALUES gives it
// no target to name. Only the desired side sets it, and it only shapes the
// CREATE statement.
ColumnNames []string `json:"-"`
// StorageParams holds the view's storage parameters, pg_class.reloptions,
// keyed by parameter name and ordered by it. check_option sits in the same
// column but is read as the view's WITH CHECK OPTION, so it is not here. A
// plain view takes security_barrier and security_invoker, a materialized
// view what a table takes, including a `toast.` parameter of its TOAST
// relation.
StorageParams *orderedmap.Map[string, string] `json:"storage_params"`
Indexes *orderedmap.Map[string, *Index] `json:"indexes"`
Triggers *orderedmap.Map[string, *Trigger] `json:"triggers"`
Comment *string `json:"comment"`
// Size is the size estimate dump --explain writes after a materialized
// view's name in the comment above it. Only dump sets it.
Size string `json:"-"`
// ColumnComments maps a column name to its comment. A column without a
// comment has no entry.
ColumnComments *orderedmap.Map[string, string] `json:"column_comments"`
// Ignore marks the view as unmanaged (set by -- pista:ignore). Ignored
// objects are not created, altered, or dropped; always false on the
// catalog side.
Ignore bool `json:"ignore"`
}
func (v View) FQVN() string {
return Ident(v.Schema, v.Name)
}
// ObjType returns the keyword a statement names the view with, VIEW or
// MATERIALIZED VIEW.
func (v View) ObjType() string {
if v.Materialized {
return "MATERIALIZED VIEW"
}
return "VIEW"
}
func (v View) SQL() string {
def := strings.TrimSpace(v.Definition)
def = strings.TrimSuffix(def, ";")
if v.Materialized {
var noData string
if v.WithNoData {
noData = "\n WITH NO DATA"
}
return "CREATE MATERIALIZED VIEW " + v.FQVN() + v.columnNamesClause() + v.storageParamsClause() + " AS\n" + def + noData + ";"
}
return "CREATE OR REPLACE VIEW " + v.FQVN() + v.columnNamesClause() + v.storageParamsClause() + " AS\n" + def + v.checkOptionClause() + ";"
}
// columnNamesClause renders the column list that follows the view name, or
// nothing for a view without one.
func (v View) columnNamesClause() string {
if len(v.ColumnNames) == 0 {
return ""
}
names := make([]string, len(v.ColumnNames))
for i, n := range v.ColumnNames {
names[i] = Ident(n)
}
return " (" + strings.Join(names, ", ") + ")"
}
// storageParamsClause renders the WITH clause that precedes AS, or nothing for
// a view without parameters. Every value is quoted, as the table renderer does.
// A replaced view carries the clause too: CREATE OR REPLACE VIEW replaces the
// options as a whole, so a parameter left off it is reset.
func (v View) storageParamsClause() string {
if w := storageParamsWith(v.StorageParams); w != "" {
return " " + w
}
return ""
}
// SetViewStorageParamsSQL returns the statement that sets the named storage
// parameters on a view, objType being what View.ObjType reports. It leaves the
// definition alone.
func SetViewStorageParamsSQL(fqvn, objType string, params []string) string {
return "ALTER " + objType + " " + fqvn + " SET (" + strings.Join(params, ", ") + ");"
}
// ResetViewStorageParamsSQL returns the statement that hands the named storage
// parameters back to their defaults. RESET is the only way to say that: the
// catalog holds no entry for a parameter that was never set, so a SET has no
// default value to name.
func ResetViewStorageParamsSQL(fqvn, objType string, names []string) string {
return "ALTER " + objType + " " + fqvn + " RESET (" + strings.Join(names, ", ") + ");"
}
// checkOptionClause renders the WITH CHECK OPTION that follows the query,
// indented the way pg_dump writes it, or nothing for a view without one.
func (v View) checkOptionClause() string {
if v.CheckOption == "" {
return ""
}
return "\n WITH " + strings.ToUpper(v.CheckOption) + " CHECK OPTION"
}
// SetCheckOptionSQL returns the statement that puts the view's check option
// at option, or resets it when option is empty. Both forms leave the
// definition alone.
func SetCheckOptionSQL(fqvn, option string) string {
if option == "" {
return "ALTER VIEW " + fqvn + " RESET (check_option);"
}
return "ALTER VIEW " + fqvn + " SET (check_option=" + QuoteLiteral(option) + ");"
}
// TrigSQL renders the view's INSTEAD OF triggers. PostgreSQL rejects
// ALTER TABLE ... DISABLE TRIGGER on a view, so there is no state to write.
func (v View) TrigSQL() string {
var stmts []string
if v.Triggers != nil {
for _, trg := range v.Triggers.CollectValues() {
stmts = append(stmts, trg.SQL())
}
}
return strings.Join(stmts, "\n")
}
func (v View) CommentSQL() string {
var stmts []string
if v.Comment != nil {
stmts = append(stmts, "COMMENT ON "+v.ObjType()+" "+v.FQVN()+" IS "+QuoteLiteral(*v.Comment)+";")
}
if v.ColumnComments != nil {
for col, comment := range v.ColumnComments.All() {
stmts = append(stmts, "COMMENT ON COLUMN "+Ident(v.Schema, v.Name, col)+" IS "+QuoteLiteral(comment)+";")
}
}
if v.Indexes != nil {
for _, idx := range v.Indexes.CollectValues() {
if s := idx.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
}
if v.Triggers != nil {
for _, trg := range v.Triggers.CollectValues() {
if s := trg.CommentSQL(); s != "" {
stmts = append(stmts, s)
}
}
}
return strings.Join(stmts, "\n")
}
func ViewToSQL(v *View) string {
parts := []string{sizedHeader(v.FQVN(), v.Size), v.SQL()}
if v.Indexes != nil {
for _, idx := range v.Indexes.CollectValues() {
parts = append(parts, idx.DumpSQL())
}
}
if s := v.TrigSQL(); s != "" {
parts = append(parts, "\n"+s)
}
if s := v.CommentSQL(); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, "\n")
}
func ViewsToSQL(views *orderedmap.Map[string, *View]) string {
return joinSQL(views, ViewToSQL)
}
package pistachio
import (
"fmt"
"maps"
"path"
"regexp"
"slices"
"strings"
)
// DefaultSearchPath is the search_path every connection is opened with unless
// --search-path says otherwise. The catalog reads the schema through
// pg_get_viewdef, pg_get_constraintdef, pg_get_expr and format_type, each of
// which drops the schema from an object the session can reach unqualified, so
// this value decides what dump writes and what the diff compares. Left to a
// server-side "ALTER ROLE ... SET search_path", one database would read
// differently depending on who connected.
//
// PostgreSQL's own default, `"$user", public`, would leave the same gap: a
// schema named after the connecting role sits on the path, so a dump taken as
// that role writes its objects without a schema and another role cannot reload
// it. The role that runs migrations is often not the role the application
// connects as, so this drops the "$user" entry and keeps public alone. Pre-SQL
// runs under this setting.
//
// kong defaults the Options field to this, and connect falls back to it, so a
// library caller that builds Options directly connects the same way the CLI
// does.
const DefaultSearchPath = "public"
// ConnOptions is how a command reaches the database. It decides nothing about
// what is read once connected, so a command that takes its scope from
// somewhere else than the command line (apply-from, which reads it from the
// plan file) embeds this alone.
type ConnOptions struct {
ConnString string `short:"c" env:"PISTA_CONN_STR" default:"postgres://postgres@localhost/postgres" help:"PostgreSQL connection string. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING"`
DBName string `name:"dbname" short:"d" env:"PISTA_DBNAME" help:"PostgreSQL database name. Overrides the dbname in --conn-string."`
Password string `env:"PISTA_PASSWORD" help:"PostgreSQL password."`
}
// ScopeOptions decides the shape of the current-side schema a run reads: which
// schemas the catalog is read from, how its output is qualified, and which of
// the objects it returns are managed. Two runs that agree on every field here
// read the same model out of an unchanged database, which is what the plan
// file's state hash compares, so a plan file records this struct whole and
// apply-from restores it rather than taking it from the command line.
//
// A new option that changes what the diff reads belongs here. One that only
// shapes the statements the diff writes (--bulk-alter, the pre-SQL, the drop
// policy) does not: the plan file holds those statements already.
//
// No connection field belongs here either. The struct is written to the plan
// file as it stands, and a password cannot be written to a file it never
// reaches.
type ScopeOptions struct {
Schemas []string `short:"n" env:"PISTA_SCHEMAS" default:"public" json:"schemas" help:"Schemas to inspect and modify."`
SchemaMap map[string]string `short:"m" json:"schema_map" help:"Schema name mapping (e.g. -m old=new)."`
// SearchPath is a pointer so that an empty value is a path of its own,
// under which the catalog qualifies everything, rather than a request for
// the default. nil means the default.
SearchPath *string `env:"PISTA_SEARCH_PATH" default:"public" json:"search_path" help:"search_path for the database connection. The catalog reports an object reachable through it without its schema, so this decides how dump writes that object. Pass an empty value to qualify everything."`
// Engine is here because dsql changes what the current side reads: the
// catalog's values are brought to the form the desired side uses.
Engine Engine `enum:"postgres,dsql" default:"postgres" env:"PISTA_ENGINE" json:"engine" help:"Server the schema is applied to: postgres, or dsql for Amazon Aurora DSQL."`
FilterOptions
}
type Options struct {
ConnOptions
ScopeOptions
}
type FilterOptions struct {
Include []string `short:"I" env:"PISTA_INCLUDE" json:"include" help:"Include only tables/views/enums/domains/composite types/sequences/routines matching the pattern (wildcard: *, ?; /re/ for a regular expression)."`
Exclude []string `short:"E" env:"PISTA_EXCLUDE" json:"exclude" help:"Exclude tables/views/enums/domains/composite types/sequences/routines matching the pattern (wildcard: *, ?; /re/ for a regular expression)."`
Enable []string `enum:"table,view,enum,domain,composite_type,sequence,routine" env:"PISTA_ENABLE" json:"enable" help:"Enable only specified object types (can be repeated)."`
Disable []string `enum:"table,view,enum,domain,composite_type,sequence,routine" env:"PISTA_DISABLE" json:"disable" help:"Disable specified object types (can be repeated)."`
// ManageRoutine opts into functions and procedures. They are unmanaged by
// default: a schema that has been maintained with -- pista:execute holds
// routines the desired schema does not declare, and reading pg_proc
// unasked would report every one of them as a drop.
ManageRoutine bool `env:"PISTA_MANAGE_ROUTINE" json:"manage_routine" help:"Manage functions and procedures. Off by default; --allow-drop routine still gates dropping them."`
// ManageStorageParam opts into the storage parameters of a table and a
// materialized view, the WITH clause. They are unmanaged by default: the
// autovacuum settings a relation is tuned with are usually set on the
// database, not written in the schema file, and reading them unasked would
// RESET every parameter the desired schema does not name. A plain view's
// two parameters are managed either way; they decide what the view means
// rather than how it is stored.
ManageStorageParam bool `env:"PISTA_MANAGE_STORAGE_PARAM" json:"manage_storage_param" help:"Manage the storage parameters of a table and a materialized view, the WITH (...) clause. Off by default; without it the clause is ignored on both sides and dump does not write it. A plain view's security_barrier and security_invoker are managed either way."`
// SkipPartitionChild leaves the partitions of a partitioned table
// unmanaged. Where another tool creates them, their names follow no
// pattern --exclude can state, and a schema file that declares the parent
// alone would plan a DROP for each one.
SkipPartitionChild bool `xor:"partition-child" env:"PISTA_SKIP_PARTITION_CHILD" json:"skip_partition_child" help:"Manage a partitioned table without its partitions. For a schema whose partitions another tool creates. An INHERITS child is unaffected."`
}
// IsTypeEnabled returns true if the given object type should be included.
// Enable takes precedence: if set, only listed types are enabled.
// Disable excludes listed types (ignored when Enable is set).
// If neither is set, all types are enabled.
func (f *FilterOptions) IsTypeEnabled(typeName string) bool {
if len(f.Enable) > 0 {
return slices.Contains(f.Enable, typeName)
}
return !slices.Contains(f.Disable, typeName)
}
// regexpPattern returns the expression inside a /.../ pattern. An unquoted
// identifier cannot hold a slash, so the delimiters do not collide with real
// names.
func regexpPattern(pattern string) (string, bool) {
if len(pattern) >= 2 && strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
return pattern[1 : len(pattern)-1], true
}
return "", false
}
// matchPattern reports whether name matches one --include / --exclude pattern.
// A pattern wrapped in slashes is a regular expression, which matches anywhere
// in the name unless it is anchored. Anything else is a wildcard and has to
// match the whole name.
func matchPattern(pattern, name string) (bool, error) {
if expr, ok := regexpPattern(pattern); ok {
re, err := regexp.Compile(expr)
if err != nil {
return false, err
}
return re.MatchString(name), nil
}
return path.Match(pattern, name)
}
func (f *FilterOptions) MatchName(name string) bool {
match := func(pattern string) bool {
ok, _ := matchPattern(pattern, name)
return ok
}
if len(f.Include) > 0 && !slices.ContainsFunc(f.Include, match) {
return false
}
return !slices.ContainsFunc(f.Exclude, match)
}
func (f *FilterOptions) AfterApply() error {
for i, pattern := range f.Include {
f.Include[i] = strings.TrimSpace(pattern)
}
for i, pattern := range f.Exclude {
f.Exclude[i] = strings.TrimSpace(pattern)
}
return f.ValidatePatterns()
}
func (f *FilterOptions) ValidatePatterns() error {
for _, pattern := range f.Include {
if _, err := matchPattern(pattern, ""); err != nil {
return fmt.Errorf("invalid --include pattern %q: %w", pattern, err)
}
}
for _, pattern := range f.Exclude {
if _, err := matchPattern(pattern, ""); err != nil {
return fmt.Errorf("invalid --exclude pattern %q: %w", pattern, err)
}
}
return nil
}
// AfterApply trims each schema name, since -n 'public, billing' reaches kong
// as ["public", " billing"] and -m 'old=new; other=third' with " other" as
// the second source, then validates the schema map. Two sources that differ
// only in whitespace are an error rather than one entry with either
// destination.
func (o *ScopeOptions) AfterApply() error {
for i, s := range o.Schemas {
o.Schemas[i] = strings.TrimSpace(s)
}
if len(o.SchemaMap) > 0 {
trimmed := make(map[string]string, len(o.SchemaMap))
raw := make(map[string]string, len(o.SchemaMap))
for _, from := range slices.Sorted(maps.Keys(o.SchemaMap)) {
key := strings.TrimSpace(from)
if prev, ok := raw[key]; ok {
return fmt.Errorf("duplicate schema-map source %q: both %q and %q name it", key, prev, from)
}
raw[key] = from
trimmed[key] = strings.TrimSpace(o.SchemaMap[from])
}
o.SchemaMap = trimmed
}
return o.ValidateSchemaMap()
}
// ValidateSchemaMap rejects an empty source or destination, which model.Ident
// would drop and leave the object unqualified, and two sources mapping to one
// destination, which would leave ReverseRemapSchema no way to pick between
// them. The sources are walked in name order so the pair the message names is
// the same on every run; a Go map iterates in a random one, and three sources
// on one destination then produced a different message each time the same
// command was run.
func (o *ScopeOptions) ValidateSchemaMap() error {
seen := make(map[string]string, len(o.SchemaMap))
for _, from := range slices.Sorted(maps.Keys(o.SchemaMap)) {
to := o.SchemaMap[from]
if from == "" {
return fmt.Errorf("schema-map source for %q is empty", to)
}
if to == "" {
return fmt.Errorf("schema-map destination for %q is empty", from)
}
if prev, ok := seen[to]; ok {
return fmt.Errorf("duplicate schema-map destination %q: both %q and %q map to it", to, prev, from)
}
seen[to] = from
}
return nil
}
// searchPath returns the search_path the connection is opened with: the value
// given, which may be empty, or the default when none was.
func (o *ScopeOptions) searchPath() string {
if o.SearchPath != nil {
return *o.SearchPath
}
return DefaultSearchPath
}
func (o *ScopeOptions) RemapSchema(schema string) string {
if mapped, ok := o.SchemaMap[schema]; ok {
return mapped
}
return schema
}
func (o *ScopeOptions) ReverseRemapSchema(schema string) string {
for k, v := range o.SchemaMap {
if v == schema {
return k
}
}
return schema
}
package pistachio
import (
"github.com/winebarrel/pistachio/parser"
)
// ParseSchema parses the desired schema files without connecting to a
// database. Unqualified names are qualified with the first schema in
// Options.Schemas, the same way Plan and Apply read their input.
func (client *Client) ParseSchema(files []string) (*parser.ParseResult, error) {
if err := client.validateSchemas(); err != nil {
return nil, err
}
// Not wrapped: the parser's message already names the file.
return parser.ParseSQLFilesWithSchema(files, client.Schemas[0])
}
package parser
import (
"errors"
"fmt"
"regexp"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// Each anchored pattern ends with \r? so a file written with CRLF line
// endings reads the same as one written with LF. The carriage return belongs
// to the line ending, not to the directive, and without this a directive on
// such a file was silently ignored: it still carries a known name, so
// validateDirectives raised nothing either.
var (
renameDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:renamed-from[ \t]+(.+?)[ \t]*\r?$`)
// Matches -- pista:renamed-from with no name (invalid usage). The pattern
// above needs a name, so without this a bare directive matched nothing
// and the object was dropped and created instead of renamed.
renameWithoutArgPattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:renamed-from[ \t]*\r?$`)
retypeUsingDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:retype-using[ \t]+(.+?)[ \t]*\r?$`)
// Matches -- pista:retype-using with no expression (invalid usage).
retypeUsingWithoutArgPattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:retype-using[ \t]*\r?$`)
// execute-first shares a prefix with execute. The execute pattern accepts
// only whitespace or end-of-line after the name, so an execute-first
// comment never matches it.
executeDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:execute(?:[ \t]+(.+?))?[ \t]*\r?$`)
executeFirstDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:execute-first(?:[ \t]+(.+?))?[ \t]*\r?$`)
concurrentlyDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:concurrently[ \t]*\r?$`)
// Matches -- pista:concurrently with trailing content (invalid usage).
concurrentlyWithArgsPattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:concurrently[ \t]+\S`)
bulkAlterDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:bulk-alter[ \t]*\r?$`)
// Matches -- pista:bulk-alter with trailing content (invalid usage).
bulkAlterWithArgsPattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:bulk-alter[ \t]+\S`)
ignoreDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:ignore[ \t]*\r?$`)
// Matches -- pista:lint-ignore and captures what follows it, if anything.
lintIgnoreDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:lint-ignore(?:[ \t]+(.*?))?[ \t]*\r?$`)
// Matches -- pista:ignore with trailing content (invalid usage).
ignoreWithArgsPattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:ignore[ \t]+\S`)
// Matches any -- pista: directive, capturing the name (if any) after the colon.
anyDirectivePattern = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*pista:[ \t]*(\S*)`)
)
// knownDirectives lists all recognized directive names.
var knownDirectives = map[string]bool{
"renamed-from": true,
"execute": true,
"execute-first": true,
"concurrently": true,
"bulk-alter": true,
"ignore": true,
"retype-using": true,
"lint-ignore": true,
}
// validateDirectives checks for unknown -- pista: directives in the raw SQL
// and returns an error if any are found.
func validateDirectives(rawSQL string) error {
// Every pattern below needs this text, and most files hold no directive,
// so this skips the regexp scans of the whole file.
if !strings.Contains(rawSQL, "pista:") {
return nil
}
matches := anyDirectivePattern.FindAllStringSubmatchIndex(rawSQL, -1)
for _, m := range matches {
// m[0] is the match start, m[2]:m[3] the name group.
raw := rawSQL[m[2]:m[3]]
name := strings.TrimSpace(raw)
if name == "" {
return &locatedError{msg: "invalid directive: -- pista: (missing directive name)", offset: m[0]}
}
if !knownDirectives[name] {
return &locatedError{msg: fmt.Sprintf("unknown directive: -- pista:%s", name), offset: m[2]}
}
// The patterns that apply a directive need the name right after the
// colon, so without this "-- pista: ignore" was silently ignored.
// The name group can start with a non-ASCII space such as U+00A0,
// which \S does not exclude and TrimSpace removes.
if c := rawSQL[m[2]-1]; c == ' ' || c == '\t' || !strings.HasPrefix(raw, name) {
return &locatedError{msg: fmt.Sprintf("invalid directive: no space is allowed after -- pista: (write -- pista:%s)", name), offset: m[0]}
}
}
if m := renameWithoutArgPattern.FindStringIndex(rawSQL); m != nil {
return &locatedError{msg: "-- pista:renamed-from requires an argument", offset: m[0]}
}
if m := retypeUsingWithoutArgPattern.FindStringIndex(rawSQL); m != nil {
return &locatedError{msg: "-- pista:retype-using requires an argument", offset: m[0]}
}
if m := concurrentlyWithArgsPattern.FindStringIndex(rawSQL); m != nil {
return &locatedError{msg: "-- pista:concurrently does not accept arguments", offset: m[0]}
}
if m := bulkAlterWithArgsPattern.FindStringIndex(rawSQL); m != nil {
return &locatedError{msg: "-- pista:bulk-alter does not accept arguments", offset: m[0]}
}
if m := ignoreWithArgsPattern.FindStringIndex(rawSQL); m != nil {
return &locatedError{msg: "-- pista:ignore does not accept arguments", offset: m[0]}
}
return nil
}
// directiveCommentPattern matches a line comment that holds a directive.
var directiveCommentPattern = regexp.MustCompile(`^--[ \t]*pista:`)
// validateDirectivePlacement rejects a directive that would bind to a
// statement the user did not put it before. Every directive scan reads the
// text before a statement, which starts right after the previous semicolon,
// so a directive after code on the same line went to the next statement.
// The files are joined into one input, so a directive at the end of a file
// went to the first statement of the next file. spans says where each file
// starts; with none, sql is one file.
func validateDirectivePlacement(sql string, spans []fileSpan) error {
if !strings.Contains(sql, "pista:") {
return nil
}
// The caller has parsed sql already, so the lexer accepts it.
scan, err := pg_query.Scan(sql)
if err != nil {
return fmt.Errorf("failed to scan SQL: %w", err)
}
// fileEnd returns the offset where the file holding pos ends.
fileEnd := func(pos int) int {
for _, s := range spans {
if s.start > pos {
return s.start
}
}
return len(sql)
}
for i, tok := range scan.Tokens {
if tok.Token != pg_query.Token_SQL_COMMENT {
continue
}
start := int(tok.Start)
if !directiveCommentPattern.MatchString(sql[start:tok.End]) {
continue
}
if !atLineStart(sql, start) {
return &locatedError{msg: "directive must be on its own line", offset: start}
}
// A bare semicolon is an empty statement, which parses to nothing,
// so it does not count as the statement the directive binds to.
followed := false
for _, next := range scan.Tokens[i+1:] {
if next.Token != pg_query.Token_SQL_COMMENT && next.Token != pg_query.Token_C_COMMENT {
followed = next.Token != pg_query.Token_ASCII_59 && int(next.Start) < fileEnd(start)
break
}
}
if !followed {
return &locatedError{msg: "directive must be followed by a statement in the same file", offset: start}
}
}
return nil
}
// ExecuteStmt represents an arbitrary SQL statement marked with
// -- pista:execute or -- pista:execute-first.
type ExecuteStmt struct {
SQL string `json:"sql"` // The SQL statement to execute
CheckSQL string `json:"check_sql"` // Optional condition check SQL (empty = always execute)
// First reports whether the statement came from -- pista:execute-first,
// which runs before the managed DDL instead of after it. Its check SQL is
// therefore evaluated against the pre-change schema.
First bool `json:"first"`
}
// extractExecuteDirectives scans raw SQL for `-- pista:execute [<check SQL>]`
// and `-- pista:execute-first [<check SQL>]` comments and pairs them with the
// following SQL statement.
// Returns the execute statements and a set of statement locations to skip
// during normal parsing.
func extractExecuteDirectives(rawSQL string, stmts []*pg_query.RawStmt) ([]*ExecuteStmt, map[int32]bool, error) {
var executeStmts []*ExecuteStmt
skipLocations := make(map[int32]bool)
for _, stmt := range stmts {
loc := stmt.StmtLocation
leading := leadingDirectiveText(stmtRegion(rawSQL, stmt))
// The two directives put the statement on opposite sides of the
// managed DDL, so carrying both is a contradiction rather than a
// preference. Repeating one directive still takes the last match.
matches := executeDirectivePattern.FindAllStringSubmatchIndex(leading, -1)
firstMatches := executeFirstDirectivePattern.FindAllStringSubmatchIndex(leading, -1)
if len(matches) > 0 && len(firstMatches) > 0 {
return nil, nil, errors.New("-- pista:execute and -- pista:execute-first cannot both apply to one statement")
}
first := len(firstMatches) > 0
if first {
matches = firstMatches
}
if len(matches) == 0 {
continue
}
// Deparse the statement to get canonical SQL
deparsed, err := pg_query.Deparse(&pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{stmt},
})
if err != nil {
return nil, nil, fmt.Errorf("failed to deparse execute statement: %w", err)
}
// Use the last match (closest to the actual SQL statement)
lastMatch := matches[len(matches)-1]
checkSQL := ""
if lastMatch[2] >= 0 {
checkSQL = strings.TrimSpace(leading[lastMatch[2]:lastMatch[3]])
// Remove trailing semicolons; pgx extended protocol doesn't allow them
checkSQL = strings.TrimRight(checkSQL, ";")
checkSQL = strings.TrimSpace(checkSQL)
}
executeStmts = append(executeStmts, &ExecuteStmt{
SQL: deparsed,
CheckSQL: checkSQL,
First: first,
})
skipLocations[loc] = true
}
return executeStmts, skipLocations, nil
}
// FormatExecuteStmt formats an ExecuteStmt as SQL with the directive comment.
func FormatExecuteStmt(es *ExecuteStmt) string {
return FormatExecuteStmtWithNote(es, "")
}
// FormatExecuteStmtWithNote formats an ExecuteStmt with an extra comment line
// between the directive and the SQL. note is used by plan to record that the
// check SQL could not be evaluated, so the statement is shown without a
// verdict. Newlines in note are folded to keep the comment on one line.
func FormatExecuteStmtWithNote(es *ExecuteStmt, note string) string {
directive := "-- pista:execute"
if es.First {
directive += "-first"
}
if es.CheckSQL != "" {
directive += " " + es.CheckSQL
}
if note != "" {
directive += "\n-- " + strings.Join(strings.Fields(note), " ")
}
sql := strings.TrimRight(es.SQL, " \t\r\n")
if !strings.HasSuffix(sql, ";") {
sql += ";"
}
return fmt.Sprintf("%s\n%s", directive, sql)
}
// qualifyRenameFrom qualifies a renamed-from value with the default schema
// if it does not already contain a schema. Quoted identifiers containing
// dots (e.g. `"a.b"`) are treated as a single identifier.
func qualifyRenameFrom(value, defaultSchema string) string {
parts := model.SplitQualifiedName(value)
for i, p := range parts {
parts[i] = model.UnquoteIdent(p)
}
if len(parts) >= 2 {
return model.Ident(parts...)
}
return model.Ident(defaultSchema, parts[0])
}
// normalizeUnqualifiedDirective normalizes a renamed-from directive value
// for unqualified names (columns, constraints, indexes, foreign keys)
// by unquoting the identifier. If a schema-qualified name is provided
// (e.g. "public.old_idx"), only the last part is used.
// The result matches the unquoted name used as orderedmap keys by the parser.
func normalizeUnqualifiedDirective(s string) string {
parts := model.SplitQualifiedName(s)
// Use the last part (the actual name, ignoring any schema prefix)
last := parts[len(parts)-1]
return model.UnquoteIdent(last)
}
// extractStmtDirectives scans raw SQL for `-- pista:renamed-from <name>` comments
// that appear in each statement's raw text region (including leading comments).
// pg_query includes leading comments in StmtLocation/StmtLen, so we scan the
// raw text of each statement for the directive.
// Returns a map from StmtLocation to the old name string.
func extractStmtDirectives(rawSQL string, stmts []*pg_query.RawStmt) map[int32]string {
directives := make(map[int32]string)
for _, stmt := range stmts {
loc := stmt.StmtLocation
leading := leadingDirectiveText(stmtRegion(rawSQL, stmt))
matches := renameDirectivePattern.FindAllStringSubmatch(leading, -1)
if len(matches) > 0 {
// Use the last match (closest to the actual SQL statement)
renameFrom := strings.TrimSpace(matches[len(matches)-1][1])
if renameFrom != "" {
directives[loc] = renameFrom
}
}
}
return directives
}
// extractConcurrentlyDirectives scans raw SQL for `-- pista:concurrently` comments
// that appear in each statement's leading comment region.
// Returns a set of StmtLocations that have the directive.
func extractConcurrentlyDirectives(rawSQL string, stmts []*pg_query.RawStmt) map[int32]bool {
return extractFlagDirectives(concurrentlyDirectivePattern, rawSQL, stmts)
}
// extractBulkAlterDirectives scans raw SQL for `-- pista:bulk-alter` comments
// that appear in each statement's leading comment region.
// Returns a set of StmtLocations that have the directive.
func extractBulkAlterDirectives(rawSQL string, stmts []*pg_query.RawStmt) map[int32]bool {
return extractFlagDirectives(bulkAlterDirectivePattern, rawSQL, stmts)
}
// extractIgnoreDirectives scans raw SQL for `-- pista:ignore` comments that
// appear in each statement's leading comment region.
// Returns a set of StmtLocations that have the directive.
func extractIgnoreDirectives(rawSQL string, stmts []*pg_query.RawStmt) map[int32]bool {
return extractFlagDirectives(ignoreDirectivePattern, rawSQL, stmts)
}
// extractLintIgnoreDirectives scans each statement's leading comment region
// for `-- pista:lint-ignore <rule>...` comments. Every such line counts.
// Returns a map from StmtLocation to the rule names.
func extractLintIgnoreDirectives(rawSQL string, stmts []*pg_query.RawStmt) map[int32][]string {
directives := make(map[int32][]string)
for _, stmt := range stmts {
leading := leadingDirectiveText(stmtRegion(rawSQL, stmt))
for _, m := range lintIgnoreDirectivePattern.FindAllStringSubmatch(leading, -1) {
directives[stmt.StmtLocation] = append(directives[stmt.StmtLocation], lintIgnoreNames(m[1])...)
}
}
return directives
}
// lintIgnoreReason matches the reason that may follow the rule names of
// -- pista:lint-ignore: a "--" after a space, and the rest of the line.
var lintIgnoreReason = regexp.MustCompile(`[ \t]--.*$`)
// lintIgnoreNames returns the rule names a -- pista:lint-ignore argument
// turns off. With no name, it turns off every rule, which LintIgnoreAll
// stands for.
func lintIgnoreNames(arg string) []string {
if names := splitRuleNames(arg); len(names) > 0 {
return names
}
return []string{LintIgnoreAll}
}
// splitRuleNames splits the argument of -- pista:lint-ignore into rule names,
// leaving out the reason after " --". Commas and spaces separate the names.
func splitRuleNames(s string) []string {
s = lintIgnoreReason.ReplaceAllString(" "+s, "")
return strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t'
})
}
// extractFlagDirectives scans raw SQL for argument-less directive comments
// matching pattern in each statement's leading comment region.
// Returns a set of StmtLocations that have the directive.
func extractFlagDirectives(pattern *regexp.Regexp, rawSQL string, stmts []*pg_query.RawStmt) map[int32]bool {
directives := make(map[int32]bool)
for _, stmt := range stmts {
loc := stmt.StmtLocation
leading := leadingDirectiveText(stmtRegion(rawSQL, stmt))
if pattern.MatchString(leading) {
directives[loc] = true
}
}
return directives
}
// findLeadingCommentEnd returns the byte offset where the whitespace and
// comments before a statement end and the statement begins. Both comment forms
// count, so a directive written after a `/* ... */` block is still part of the
// leading text. A string of nothing but comments returns its length, so the
// offset always stays within the string.
func findLeadingCommentEnd(s string) int {
offset := 0
for offset < len(s) {
switch {
case isSQLSpace(s[offset]):
offset++
case strings.HasPrefix(s[offset:], "--"):
i := strings.IndexByte(s[offset:], '\n')
if i < 0 {
return len(s)
}
offset += i + 1
case strings.HasPrefix(s[offset:], "/*"):
end := blockCommentEnd(s, offset)
if end < 0 {
return len(s)
}
offset = end
default:
return offset
}
}
return offset
}
func isSQLSpace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'
}
// leadingDirectiveText returns the whitespace and comments before a statement,
// with the body of every block comment blanked out. A directive inside a block
// comment is commented out like anything else, so a scan must not see it, while
// blanking rather than removing keeps the offsets extractExecuteDirectives
// slices a check SQL out of.
func leadingDirectiveText(region string) string {
return blankBlockComments(region[:findLeadingCommentEnd(region)])
}
// blankBlockComments replaces every byte of a block comment, its delimiters
// included, with a space, leaving newlines in place so line-anchored patterns
// still see the same lines.
func blankBlockComments(s string) string {
if !strings.Contains(s, "/*") {
return s
}
out := []byte(s)
for i := 0; i < len(s); {
switch {
case strings.HasPrefix(s[i:], "--"):
// A line comment runs to the end of the line, so a `/*` in it
// opens nothing.
j := strings.IndexByte(s[i:], '\n')
if j < 0 {
i = len(s)
} else {
i += j + 1
}
case strings.HasPrefix(s[i:], "/*"):
end := blockCommentEnd(s, i)
if end < 0 {
end = len(s)
}
for k := i; k < end; k++ {
if out[k] != '\n' {
out[k] = ' '
}
}
i = end
default:
i++
}
}
return string(out)
}
// blockCommentEnd returns the offset just past the block comment that starts at
// pos, or -1 when it is never closed. PostgreSQL nests block comments, so an
// inner `/*` takes a `*/` of its own to close.
func blockCommentEnd(s string, pos int) int {
depth := 0
for i := pos; i+1 < len(s); {
switch {
case s[i] == '/' && s[i+1] == '*':
depth++
i += 2
case s[i] == '*' && s[i+1] == '/':
depth--
i += 2
if depth == 0 {
return i
}
default:
i++
}
}
return -1
}
// inlineDirectives holds the directives for columns and constraints within a CREATE TABLE.
type inlineDirectives struct {
Columns map[string]string // new column name -> old column name
Constraints map[string]string // new constraint name -> old constraint name
RetypeUsing map[string]string // column name -> USING expression
}
// extractInlineDirectives scans the raw text of a CREATE TABLE statement for
// `-- pista:renamed-from <old_name>` and `-- pista:retype-using <expression>`
// directives that appear on lines immediately before column or constraint
// definitions. retype-using applies to a column only.
func extractInlineDirectives(rawCreateTableSQL string) *inlineDirectives {
result := &inlineDirectives{
Columns: make(map[string]string),
Constraints: make(map[string]string),
RetypeUsing: make(map[string]string),
}
// Every directive needs this text, and most statements hold none.
if !strings.Contains(rawCreateTableSQL, "pista:") {
return result
}
// Only scan lines inside the column/constraint list (after the opening parenthesis)
parenIdx := strings.Index(rawCreateTableSQL, "(")
if parenIdx < 0 {
return result
}
body := rawCreateTableSQL[parenIdx:]
lines := strings.Split(body, "\n")
var pendingRename, pendingRetype string
for _, line := range lines {
// A leading comma belongs to the previous element, so the name that
// follows it is the one the directive renames.
trimmed := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), ","))
if m := renameDirectivePattern.FindStringSubmatch(line); m != nil {
pendingRename = normalizeUnqualifiedDirective(m[1])
continue
}
if m := retypeUsingDirectivePattern.FindStringSubmatch(line); m != nil {
pendingRetype = m[1]
continue
}
// Read by extractInlineLintIgnores.
if lintIgnoreDirectivePattern.MatchString(line) {
continue
}
// Blank lines and other comments keep the pending directives.
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if pendingRename != "" || pendingRetype != "" {
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "CONSTRAINT ") {
conName := extractConstraintName(trimmed)
if conName != "" && pendingRename != "" {
result.Constraints[conName] = pendingRename
}
} else {
colName := extractColumnName(trimmed)
if colName != "" && pendingRename != "" {
result.Columns[colName] = pendingRename
}
if colName != "" && pendingRetype != "" {
result.RetypeUsing[colName] = pendingRetype
}
}
}
pendingRename = ""
pendingRetype = ""
}
return result
}
// extractInlineLintIgnores scans the element list of a CREATE TABLE statement
// for -- pista:lint-ignore lines. It maps the offset of the element that
// follows to the rule names, so the parser can match the directive to a
// column or a constraint by where it is written, whatever its name. base is
// the offset of rawCreateTableSQL in the parsed input.
func extractInlineLintIgnores(rawCreateTableSQL string, base int32) map[int32][]string {
result := make(map[int32][]string)
if !strings.Contains(rawCreateTableSQL, "pista:") {
return result
}
parenIdx := strings.Index(rawCreateTableSQL, "(")
if parenIdx < 0 {
return result
}
var pending []string
offset := parenIdx
for _, line := range strings.SplitAfter(rawCreateTableSQL[parenIdx:], "\n") {
lineStart := offset
offset += len(line)
if m := lintIgnoreDirectivePattern.FindStringSubmatch(line); m != nil {
pending = append(pending, lintIgnoreNames(m[1])...)
continue
}
// The opening parenthesis and a leading comma belong to no element.
trimmed := strings.TrimLeft(line, " \t(,")
if strings.TrimSpace(trimmed) == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if len(pending) > 0 {
result[base+int32(lineStart+len(line)-len(trimmed))] = pending
pending = nil
}
}
return result
}
// extractEnumValueDirectives scans a CREATE TYPE ... AS ENUM statement for
// `-- pista:renamed-from <old_value>` directives on the line before a value
// literal. The statement is tokenized with the pg_query lexer, so any literal
// form (escapes, dollar quoting, multi-line) is recognized. The old value may
// be written as a quoted literal or bare. Returns a map from value index to
// old value.
func extractEnumValueDirectives(rawCreateEnumSQL string) (map[int]string, error) {
result := make(map[int]string)
scan, err := pg_query.Scan(rawCreateEnumSQL)
if err != nil {
return nil, fmt.Errorf("failed to scan CREATE TYPE statement: %w", err)
}
inValueList := false
pending := ""
hasPending := false
valueIdx := 0
for _, tok := range scan.Tokens {
switch tok.Token {
case pg_query.Token_ASCII_40: // "("
// Directives before the value list rename the type, not a value.
inValueList = true
case pg_query.Token_SQL_COMMENT:
if !inValueList || !atLineStart(rawCreateEnumSQL, int(tok.Start)) {
continue
}
text := rawCreateEnumSQL[tok.Start:tok.End]
if m := renameDirectivePattern.FindStringSubmatch(text); m != nil {
pending = unquoteEnumLiteral(strings.TrimSpace(m[1]))
hasPending = true
}
case pg_query.Token_SCONST, pg_query.Token_USCONST:
if hasPending {
result[valueIdx] = pending
hasPending = false
}
valueIdx++
}
}
return result, nil
}
// atLineStart reports whether only whitespace precedes pos on its line.
func atLineStart(s string, pos int) bool {
for i := pos - 1; i >= 0; i-- {
switch s[i] {
case '\n':
return true
case ' ', '\t', '\r':
continue
default:
return false
}
}
return true
}
// unquoteEnumLiteral strips surrounding single quotes from an enum value and
// unescapes doubled quotes (two quotes become one). Bare values are returned
// as-is; enum values are literals, so no case folding is applied.
func unquoteEnumLiteral(s string) string {
if val, ok := scanQuoted(s, '\''); ok {
return val
}
return s
}
// scanQuotedIdent scans a quoted identifier from the start of s, handling ""
// escape sequences. Returns the unquoted name and true if successful, and an
// empty name and false otherwise.
func scanQuotedIdent(s string) (string, bool) {
return scanQuoted(s, '"')
}
// scanQuoted scans a string quoted with q from the start of s, handling
// doubled-quote escape sequences. Returns the unquoted value and true if
// successful, and an empty value and false otherwise.
func scanQuoted(s string, q byte) (string, bool) {
if len(s) == 0 || s[0] != q {
return "", false
}
var val strings.Builder
for i := 1; i < len(s); i++ {
if s[i] == q {
if i+1 < len(s) && s[i+1] == q {
// Escaped quote
val.WriteByte(q)
i++
} else {
// End of quoted string
return val.String(), true
}
} else {
val.WriteByte(s[i])
}
}
return "", false
}
// extractConstraintName extracts the constraint name from a CONSTRAINT line.
// e.g. "CONSTRAINT users_pkey PRIMARY KEY (id)" -> "users_pkey"
func extractConstraintName(line string) string {
line = strings.TrimSpace(line)
upper := strings.ToUpper(line)
if !strings.HasPrefix(upper, "CONSTRAINT ") {
return ""
}
rest := strings.TrimSpace(line[len("CONSTRAINT "):])
if strings.HasPrefix(rest, `"`) {
name, _ := scanQuotedIdent(rest)
return name
}
fields := strings.Fields(rest)
if len(fields) == 0 {
return ""
}
return strings.ToLower(fields[0])
}
// extractColumnName extracts the column name from a column definition line.
// Handles both unquoted identifiers and quoted identifiers ("My Column").
func extractColumnName(line string) string {
line = strings.TrimSpace(line)
// Skip CONSTRAINT lines
upper := strings.ToUpper(line)
if strings.HasPrefix(upper, "CONSTRAINT ") {
return ""
}
if strings.HasPrefix(line, `"`) {
name, _ := scanQuotedIdent(line)
return name
}
// Unquoted identifier: first word, folded to lowercase per PostgreSQL behavior
fields := strings.Fields(line)
if len(fields) == 0 {
return ""
}
name := fields[0]
// Remove trailing comma if present
name = strings.TrimSuffix(name, ",")
return strings.ToLower(name)
}
package parser
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"unicode/utf8"
pg_query "github.com/pganalyze/pg_query_go/v6"
pgparser "github.com/pganalyze/pg_query_go/v6/parser"
)
// stmtRegion returns the raw text of a statement, the blank lines and comments
// pg_query counts as part of it included. Every directive scan reads it, so a
// region cut short loses the directives in it.
func stmtRegion(sql string, rawStmt *pg_query.RawStmt) string {
start := rawStmt.StmtLocation
end := start + rawStmt.StmtLen
// pg_query leaves StmtLen at 0 for a final statement with no semicolon,
// so the region runs to the end of the input.
if rawStmt.StmtLen == 0 || end > int32(len(sql)) {
end = int32(len(sql))
}
return sql[start:end]
}
// stmtStart returns the byte offset of a statement's first token. pg_query
// counts the blank lines and comments before a statement as part of it, so
// StmtLocation on its own can point at whitespace rather than at the keyword.
func stmtStart(sql string, rawStmt *pg_query.RawStmt) int32 {
return rawStmt.StmtLocation + int32(findLeadingCommentEnd(stmtRegion(sql, rawStmt)))
}
// fileSpan records where a desired-schema file starts in the SQL handed to
// the parser, which parses every file joined into one input.
type fileSpan struct {
path string
start int // byte offset in the joined SQL
}
// locatedError is an error that knows the byte offset in the parsed SQL it
// refers to. Directive validation returns it; pg_query errors carry their own
// cursor position instead.
type locatedError struct {
msg string
offset int
}
func (e *locatedError) Error() string {
return e.msg
}
// annotateError appends the file, line and column an error points at, along
// with the line itself and a caret under the offending column:
//
// failed to parse SQL: syntax error at or near "TABEL"
// --> schema/items.sql:6:8
// |
// 6 | CREATE TABEL public.items (
// | ^
//
// The position comes from a pg_query error's cursor (a 1-based character
// index) or a locatedError's byte offset. An error carrying neither is
// returned as it is.
func annotateError(err error, sql string, spans []fileSpan) error {
offset := -1
if pgErr, ok := errors.AsType[*pgparser.Error](err); ok && pgErr.Cursorpos > 0 {
offset = runeOffsetToByte(sql, pgErr.Cursorpos-1)
} else if locErr, ok := errors.AsType[*locatedError](err); ok {
offset = locErr.offset
}
pos, ok := locate(sql, spans, offset)
if !ok {
return err
}
// The caret pad mirrors the runes before the column, keeping tabs so the
// caret lands under the column however the line is indented.
var pad strings.Builder
for _, r := range sql[pos.lineStart:pos.offset] {
if r == '\t' {
pad.WriteByte('\t')
} else {
pad.WriteByte(' ')
}
}
num := strconv.Itoa(pos.line)
gutter := strings.Repeat(" ", len(num))
return fmt.Errorf("%s\n%s--> %s\n%s |\n%s | %s\n%s | %s^",
err.Error(),
gutter, pos,
gutter,
num, sql[pos.lineStart:pos.lineEnd],
gutter, pad.String())
}
// sourcePos is a byte offset in the parsed SQL resolved to the file it came
// from and the line and column within it.
type sourcePos struct {
path string
line int
col int
// offset is the resolved offset, clamped to the input; lineStart and
// lineEnd bound the line holding it.
offset int
lineStart int
lineEnd int
}
func (p sourcePos) String() string {
return fmt.Sprintf("%s:%d:%d", p.path, p.line, p.col)
}
// locate resolves a byte offset in the SQL handed to the parser against the
// files it was joined from. It reports false when there is no position, or no
// file to name, as when a string was parsed rather than files.
func locate(sql string, spans []fileSpan, offset int) (sourcePos, bool) {
return newLocator(sql, spans).locate(offset)
}
// locator resolves offsets the way locate does, for a caller resolving many
// of them in the same SQL. It finds the newlines once, so a line number is a
// binary search rather than a count from the start of the file.
type locator struct {
sql string
spans []fileSpan
newlines []int
}
func newLocator(sql string, spans []fileSpan) *locator {
l := &locator{sql: sql, spans: spans}
if len(spans) == 0 {
return l
}
for i := 0; ; {
j := strings.IndexByte(sql[i:], '\n')
if j < 0 {
break
}
i += j
l.newlines = append(l.newlines, i)
i++
}
return l
}
// newlinesBefore returns the number of newlines before offset.
func (l *locator) newlinesBefore(offset int) int {
n, _ := slices.BinarySearch(l.newlines, offset)
return n
}
func (l *locator) locate(offset int) (sourcePos, bool) {
sql, spans := l.sql, l.spans
if offset < 0 || len(spans) == 0 {
return sourcePos{}, false
}
offset = min(offset, len(sql))
span := spans[0]
for _, s := range spans[1:] {
if s.start > offset {
break
}
span = s
}
lineStart := strings.LastIndexByte(sql[:offset], '\n') + 1
lineEnd := len(sql)
if i := strings.IndexByte(sql[offset:], '\n'); i >= 0 {
lineEnd = offset + i
}
return sourcePos{
path: span.path,
line: 1 + l.newlinesBefore(offset) - l.newlinesBefore(span.start),
col: 1 + utf8.RuneCountInString(sql[lineStart:offset]),
offset: offset,
lineStart: lineStart,
lineEnd: lineEnd,
}, true
}
// runeOffsetToByte returns the byte offset of the n-th rune (0-based), or
// len(sql) when the input has fewer runes.
func runeOffsetToByte(sql string, n int) int {
runes := 0
for i := range sql {
if runes == n {
return i
}
runes++
}
return len(sql)
}
package parser
import (
"errors"
"fmt"
"io"
"maps"
"os"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
type ParseResult struct {
Tables *orderedmap.Map[string, *model.Table] `json:"tables"`
Views *orderedmap.Map[string, *model.View] `json:"views"`
Enums *orderedmap.Map[string, *model.Enum] `json:"enums"`
Domains *orderedmap.Map[string, *model.Domain] `json:"domains"`
CompositeTypes *orderedmap.Map[string, *model.CompositeType] `json:"composite_types"`
Sequences *orderedmap.Map[string, *model.Sequence] `json:"sequences"`
Routines *orderedmap.Map[string, *model.Routine] `json:"routines"`
ExecuteStmts []*ExecuteStmt `json:"execute_stmts"`
// LintIgnores holds the rules that -- pista:lint-ignore turns off, by the
// object it is written before. It is kept out of the model and of the
// JSON document: it means nothing to plan or apply, and the state hash
// that a plan file records is taken over the model.
LintIgnores map[LintTarget][]string `json:"-"`
// LintIgnoreDirectives lists each -- pista:lint-ignore with every object
// it reaches. A directive before a column reaches the column and a
// foreign key written on it, so pista lint checks its names against the
// rules of both kinds at once.
LintIgnoreDirectives []LintIgnoreDirective `json:"-"`
// Positions holds where each table, column, index and foreign key is
// declared, for pista lint to report. It is empty for SQL that came from
// no file. Like LintIgnores, it is kept out of the model and the document.
Positions map[LintTarget]Position `json:"-"`
// AutoNamed holds the indexes and constraints that the files declare
// without a name, which pistachio names the way PostgreSQL would. Like
// LintIgnores, it is kept out of the model.
AutoNamed map[AutoNamedObject]bool `json:"-"`
}
// AutoNamedObject names an index or a constraint in AutoNamed. Table is the
// qualified name of its table or materialized view. Index tells an index from
// a constraint, since the two do not share a namespace.
type AutoNamedObject struct {
Index bool
Table string
Name string
}
// Position is a place in a schema file.
type Position struct {
File string
Line int
Column int
}
func (p Position) String() string {
return fmt.Sprintf("%s:%d:%d", p.File, p.Line, p.Column)
}
// LintIgnoreDirective is one -- pista:lint-ignore, or several lines of it
// before one element, with the objects it reaches.
type LintIgnoreDirective struct {
Targets []LintTarget
Rules []string
}
// LintIgnoreAll is the name in LintIgnores that stands for every rule. A
// -- pista:lint-ignore with no rule name gives it. No rule can have the empty
// name.
const LintIgnoreAll = ""
// LintKind is a kind of object a lint rule checks.
type LintKind string
const (
LintTable LintKind = "table"
LintColumn LintKind = "column"
LintIndex LintKind = "index"
LintForeignKey LintKind = "foreign_key"
)
// LintTarget names one object a lint rule checks. Table is the qualified name
// of the table, or of the materialized view an index is on. Name is the name
// of the column, index or foreign key, and empty for a table.
type LintTarget struct {
Kind LintKind
Table string
Name string
}
// warnWriter receives warnings about statements pistachio does not support and
// silently ignores. Tests swap it via setWarnWriter.
var warnWriter io.Writer = os.Stderr
// ignoredStmtSnippet returns the raw text of the ignored statement. Deparsing
// drops the comments and blank lines that surround the statement in the file.
// The raw slice is a fallback for the rare statement pg_query can parse but not
// deparse. The caller collapses whitespace, so a multi-line body from either
// path becomes one line.
func ignoredStmtSnippet(sql string, rawStmt *pg_query.RawStmt) string {
single := &pg_query.ParseResult{Version: pgast.ParseTreeVersion, Stmts: []*pg_query.RawStmt{rawStmt}}
if deparsed, err := pg_query.Deparse(single); err == nil {
return deparsed
}
return stmtRegion(sql, rawStmt)
}
// txHint suggests the flags that wrap the apply in a transaction, but only for
// the statements that open or close a whole-file transaction. ROLLBACK and
// SAVEPOINT reach here too, and neither is answered by wrapping the apply.
func txHint(rawStmt *pg_query.RawStmt) string {
ts := rawStmt.Stmt.GetTransactionStmt()
if ts == nil {
return ""
}
switch ts.Kind {
case pg_query.TransactionStmtKind_TRANS_STMT_BEGIN,
pg_query.TransactionStmtKind_TRANS_STMT_START,
pg_query.TransactionStmtKind_TRANS_STMT_COMMIT:
return " (use --with-tx or --try-tx to run the apply in a transaction)"
default:
return ""
}
}
// warnIgnoredStmt reports a statement type that no parser case handles. The
// snippet is collapsed to one line and truncated on a rune boundary, so the
// warning stays short and valid UTF-8 even for a large multi-line body.
//
// The statement's file, line and column lead the message when the parse came
// from files:
//
// pista: schema/items.sql:12:1: ignored unsupported statement: DROP TABLE public.items
func warnIgnoredStmt(sql string, loc *locator, rawStmt *pg_query.RawStmt) {
snippet := strings.Join(strings.Fields(ignoredStmtSnippet(sql, rawStmt)), " ")
if snippet == "" {
return
}
const maxRunes = 200
if runes := []rune(snippet); len(runes) > maxRunes {
snippet = string(runes[:maxRunes]) + "..."
}
at := ""
if pos, ok := loc.locate(int(stmtStart(sql, rawStmt))); ok {
at = pos.String() + ": "
}
fmt.Fprintf(warnWriter, "pista: %signored unsupported statement: %s%s\n", at, snippet, txHint(rawStmt)) //nolint:errcheck
}
// createTableLikeError rejects a LIKE clause, which the parser does not
// expand. The columns it would copy would read as absent from the desired
// schema, so a table created this way would plan a DROP COLUMN for each of
// them, or be created with no columns.
func createTableLikeError(cs *pg_query.CreateStmt, fqtn string, offset int32) error {
for _, elt := range cs.TableElts {
if elt.GetTableLikeClause() != nil {
return &locatedError{msg: "CREATE TABLE " + fqtn + ": LIKE is not supported (list the columns instead)", offset: int(offset)}
}
}
return nil
}
// alterTableSupportedCmds lists the ALTER TABLE actions the parser reads into
// the model: constraints (parseAlterTableConstraints), the row-level security
// toggles (applyAlterTableRLS), the trigger states
// (applyAlterTableTriggerState) and the column storage and compression
// (applyAlterTableColumnStorage). Anything else is dropped, so warning is
// driven off this list rather than off a list of the actions to reject: an
// action PostgreSQL adds later warns instead of vanishing.
var alterTableSupportedCmds = map[pg_query.AlterTableType]bool{
pg_query.AlterTableType_AT_AddConstraint: true,
pg_query.AlterTableType_AT_EnableRowSecurity: true,
pg_query.AlterTableType_AT_DisableRowSecurity: true,
pg_query.AlterTableType_AT_ForceRowSecurity: true,
pg_query.AlterTableType_AT_NoForceRowSecurity: true,
pg_query.AlterTableType_AT_EnableTrig: true,
pg_query.AlterTableType_AT_DisableTrig: true,
pg_query.AlterTableType_AT_EnableAlwaysTrig: true,
pg_query.AlterTableType_AT_EnableReplicaTrig: true,
pg_query.AlterTableType_AT_SetStorage: true,
pg_query.AlterTableType_AT_SetCompression: true,
}
// commentTargetSupported lists the COMMENT ON targets parseCommentStmt reads
// into the model. A comment on any other object is dropped, and warns.
var commentTargetSupported = map[pg_query.ObjectType]bool{
pg_query.ObjectType_OBJECT_TABLE: true,
pg_query.ObjectType_OBJECT_VIEW: true,
pg_query.ObjectType_OBJECT_MATVIEW: true,
pg_query.ObjectType_OBJECT_COLUMN: true,
pg_query.ObjectType_OBJECT_INDEX: true,
pg_query.ObjectType_OBJECT_SEQUENCE: true,
pg_query.ObjectType_OBJECT_TYPE: true,
pg_query.ObjectType_OBJECT_DOMAIN: true,
pg_query.ObjectType_OBJECT_FUNCTION: true,
pg_query.ObjectType_OBJECT_PROCEDURE: true,
pg_query.ObjectType_OBJECT_TABCONSTRAINT: true,
pg_query.ObjectType_OBJECT_DOMCONSTRAINT: true,
pg_query.ObjectType_OBJECT_TRIGGER: true,
pg_query.ObjectType_OBJECT_POLICY: true,
}
// warnIgnoredAlterTableCmds warns about the ALTER TABLE actions no handler
// reads. ALTER TABLE as a statement is supported, so such an action never
// reaches the unsupported-statement warning; dropping it in silence would let
// a column added this way read as absent from the desired schema and plan as
// a DROP COLUMN.
//
// The warning carries a statement rebuilt from the ignored actions alone, so
// one that mixes a supported action with an unsupported one reports only the
// latter. The rebuilt statement keeps the original location, which is what
// the snippet falls back to when deparse fails.
func warnIgnoredAlterTableCmds(sql string, loc *locator, rawStmt *pg_query.RawStmt, as *pg_query.AlterTableStmt) {
var ignored []*pg_query.Node
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil || alterTableSupportedCmds[cmd.Subtype] {
continue
}
ignored = append(ignored, cmdNode)
}
if len(ignored) == 0 {
return
}
warnIgnoredStmt(sql, loc, &pg_query.RawStmt{
Stmt: &pg_query.Node{
Node: &pg_query.Node_AlterTableStmt{
AlterTableStmt: &pg_query.AlterTableStmt{
Relation: as.Relation,
Objtype: as.Objtype,
MissingOk: as.MissingOk,
Cmds: ignored,
},
},
},
StmtLocation: rawStmt.StmtLocation,
StmtLen: rawStmt.StmtLen,
})
}
// setUnique records v under key and rejects a name the map already holds.
// on names the object the key belongs to, empty for a name that stands on its
// own in a schema. offset is where in the parsed SQL the message points, so
// the error can name the file, line and column of the repeat.
func setUnique[V any](m *orderedmap.Map[string, V], key, kind string, v V, on string, offset int32) error {
if _, ok := m.GetOk(key); ok {
scope := ""
if on != "" {
scope = " on " + on
}
return &locatedError{
msg: fmt.Sprintf("duplicate %s: %s%s", kind, key, scope),
offset: int(offset),
}
}
m.Set(key, v)
return nil
}
func readSQLFile(path string) (string, error) {
var data []byte
var err error
if path == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(path)
}
if err != nil {
if path == "-" {
return "", fmt.Errorf("failed to read SQL from stdin: %w", err)
}
return "", fmt.Errorf("failed to read SQL file: %w", err)
}
return string(data), nil
}
func ParseSQLFilesWithSchema(paths []string, defaultSchema string) (*ParseResult, error) {
sources, err := ReadSQLFiles(paths)
if err != nil {
return nil, err
}
return ParseSQLSourcesWithSchema(sources, defaultSchema)
}
// ReadSQLFiles reads the files ParseSQLFilesWithSchema parses, "-" being
// stdin, for a caller that changes the SQL before it is parsed.
func ReadSQLFiles(paths []string) ([]Source, error) {
sources := make([]Source, 0, len(paths))
for _, path := range paths {
sql, err := readSQLFile(path)
if err != nil {
return nil, err
}
name := path
if path == "-" {
name = "<stdin>"
}
sources = append(sources, Source{Name: name, SQL: sql})
}
return sources, nil
}
// Source is one piece of desired-schema SQL and the name a message calls it
// by. A file's name is its path; SQL read out of a git repository carries the
// revision as well, so an error says which version of the file it is in.
type Source struct {
Name string
SQL string
}
// ParseSQLSourcesWithSchema parses SQL that is already in memory, the way
// ParseSQLFilesWithSchema parses the files it reads. A caller that did not
// read a file uses it to keep the file name in an error and a warning.
func ParseSQLSourcesWithSchema(sources []Source, defaultSchema string) (*ParseResult, error) {
sqls := make([]string, 0, len(sources))
spans := make([]fileSpan, 0, len(sources))
offset := 0
missing := -1
for _, source := range sources {
spans = append(spans, fileSpan{path: source.Name, start: offset})
sqls = append(sqls, source.SQL)
if end := unterminatedEnd(source.SQL); missing < 0 && end >= 0 {
missing = offset + end
}
offset += len(source.SQL) + 1 // the "\n" the join puts between sources
}
joined := strings.Join(sqls, "\n")
if missing >= 0 {
return nil, annotateError(&locatedError{msg: "statement must end with a semicolon", offset: missing}, joined, spans)
}
result, err := parseSQLWithSchema(joined, defaultSchema, spans)
if err != nil {
return nil, annotateError(err, joined, spans)
}
return result, nil
}
// unterminatedEnd returns the byte offset just past the last token of a file
// whose last statement has no semicolon, or -1. The files are joined into one
// input, so such a statement would run into the first one of the next file.
// SQL that does not parse on its own is left to the parser to report, so a
// statement cut short reads as the syntax error it is.
func unterminatedEnd(sql string) int {
// The semicolon ending the last line is the last token unless a quote
// left open holds it, and the parser reports that. A comment could hide
// one, so a line with a comment marker is scanned.
trimmed := strings.TrimRight(sql, " \t\r\n")
lastLine := trimmed[strings.LastIndexByte(trimmed, '\n')+1:]
if strings.HasSuffix(lastLine, ";") && !strings.Contains(lastLine, "--") && !strings.Contains(lastLine, "/*") && !strings.Contains(lastLine, "*/") {
return -1
}
result, err := pg_query.Scan(sql)
if err != nil {
return -1
}
for _, tok := range slices.Backward(result.Tokens) {
switch tok.Token {
case pg_query.Token_SQL_COMMENT, pg_query.Token_C_COMMENT:
continue
case pg_query.Token_ASCII_59:
return -1
default:
if _, err := pg_query.Parse(sql); err != nil {
return -1
}
return int(tok.End)
}
}
return -1
}
// parseSQLWithSchema parses the SQL every desired-schema file was joined
// into. spans says where each file starts, so a warning can name the file a
// statement is in. A caller parsing a string passes none, and the warning
// carries no position.
func parseSQLWithSchema(sql string, defaultSchema string, spans []fileSpan) (*ParseResult, error) {
if err := validateDirectives(sql); err != nil {
return nil, err
}
result, err := pg_query.Parse(sql)
if err != nil {
return nil, fmt.Errorf("failed to parse SQL: %w", err)
}
if err := validateDirectivePlacement(sql, spans); err != nil {
return nil, err
}
tables := orderedmap.New[string, *model.Table]()
views := orderedmap.New[string, *model.View]()
enums := orderedmap.New[string, *model.Enum]()
domains := orderedmap.New[string, *model.Domain]()
compositeTypes := orderedmap.New[string, *model.CompositeType]()
sequences := orderedmap.New[string, *model.Sequence]()
routines := orderedmap.New[string, *model.Routine]()
stmtDirectives := extractStmtDirectives(sql, result.Stmts)
concurrentlyDirectives := extractConcurrentlyDirectives(sql, result.Stmts)
bulkAlterDirectives := extractBulkAlterDirectives(sql, result.Stmts)
ignoreDirectives := extractIgnoreDirectives(sql, result.Stmts)
lintIgnoreDirectives := extractLintIgnoreDirectives(sql, result.Stmts)
lintIgnores := map[LintTarget][]string{}
var lintIgnoreList []LintIgnoreDirective
addLintIgnores := func(targets []LintTarget, rules []string) {
if len(rules) == 0 || len(targets) == 0 {
return
}
for _, target := range targets {
lintIgnores[target] = append(lintIgnores[target], rules...)
}
lintIgnoreList = append(lintIgnoreList, LintIgnoreDirective{Targets: targets, Rules: rules})
}
positions := map[LintTarget]Position{}
autoNamed := map[AutoNamedObject]bool{}
loc := newLocator(sql, spans)
addPosition := func(target LintTarget, offset int32) {
if pos, ok := loc.locate(int(offset)); ok {
positions[target] = Position{File: pos.path, Line: pos.line, Column: pos.col}
}
}
executeStmts, executeSkipLocations, err := extractExecuteDirectives(sql, result.Stmts)
if err != nil {
return nil, err
}
for _, rawStmt := range result.Stmts {
// Skip statements marked with -- pista:execute
if executeSkipLocations[rawStmt.StmtLocation] {
continue
}
node := rawStmt.Stmt
renameFrom := stmtDirectives[rawStmt.StmtLocation]
ignore := ignoreDirectives[rawStmt.StmtLocation]
lintIgnore := lintIgnoreDirectives[rawStmt.StmtLocation]
// Where a duplicate-name error points when the repeat is the
// statement itself rather than a part of one.
stmtOffset := stmtStart(sql, rawStmt)
switch {
case node.GetCreateEnumStmt() != nil:
enum, err := parseCreateEnumStmt(node.GetCreateEnumStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
enum.RenameFrom = &qualified
}
// Extract value-level rename directives from raw SQL
rawStmtSQL := stmtRegion(sql, rawStmt)
valueDirectives, err := extractEnumValueDirectives(rawStmtSQL)
if err != nil {
return nil, err
}
for idx, oldVal := range valueDirectives {
if idx < len(enum.Values) {
if enum.ValueRenameFrom == nil {
enum.ValueRenameFrom = make(map[string]string)
}
enum.ValueRenameFrom[enum.Values[idx]] = oldVal
}
}
enum.Ignore = ignore
if err := setUnique(enums, enum.FQEN(), "enum", enum, "", stmtOffset); err != nil {
return nil, err
}
case node.GetCreateDomainStmt() != nil:
domain, err := parseCreateDomainStmt(node.GetCreateDomainStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
domain.RenameFrom = &qualified
}
domain.Ignore = ignore
if err := setUnique(domains, domain.FQDN(), "domain", domain, "", stmtOffset); err != nil {
return nil, err
}
case node.GetCompositeTypeStmt() != nil:
compositeType, err := parseCompositeTypeStmt(node.GetCompositeTypeStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
compositeType.RenameFrom = &qualified
}
// Extract attribute-level rename directives from raw SQL. The
// composite type body is a column-like list, so the CREATE TABLE
// inline-directive scanner applies unchanged.
rawStmtSQL := stmtRegion(sql, rawStmt)
attrDirectives := extractInlineDirectives(rawStmtSQL)
for _, attr := range compositeType.Attributes {
if oldName, ok := attrDirectives.Columns[attr.Name]; ok {
attr.RenameFrom = &oldName
}
}
compositeType.Ignore = ignore
if err := setUnique(compositeTypes, compositeType.FQCN(), "composite type", compositeType, "", stmtOffset); err != nil {
return nil, err
}
case node.GetCreateStmt() != nil:
unnamed := unnamedConstraints(node.GetCreateStmt())
table, err := parseCreateStmt(node.GetCreateStmt(), defaultSchema)
if err != nil {
return nil, err
}
addAutoNamed(autoNamed, table.FQTN(), unnamed)
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
table.RenameFrom = &qualified
}
if bulkAlterDirectives[rawStmt.StmtLocation] {
table.BulkAlter = true
}
// Extract column/constraint-level directives from raw SQL
rawStmtSQL := stmtRegion(sql, rawStmt)
inlineDirectives := extractInlineDirectives(rawStmtSQL)
for colName, oldName := range inlineDirectives.Columns {
if col, ok := table.Columns.GetOk(colName); ok {
col.RenameFrom = &oldName
}
}
for colName, expr := range inlineDirectives.RetypeUsing {
if col, ok := table.Columns.GetOk(colName); ok {
col.RetypeUsing = &expr
}
}
for conName, oldName := range inlineDirectives.Constraints {
if con, ok := table.Constraints.GetOk(conName); ok {
con.RenameFrom = &oldName
} else if fk, ok := table.ForeignKeys.GetOk(conName); ok {
fk.RenameFrom = &oldName
}
}
addLintIgnores([]LintTarget{{Kind: LintTable, Table: table.FQTN()}}, lintIgnore)
addCreateTablePositions(addPosition, node.GetCreateStmt(), table, stmtOffset)
addInlineLintIgnores(addLintIgnores, node.GetCreateStmt(), table, extractInlineLintIgnores(rawStmtSQL, rawStmt.StmtLocation))
table.Ignore = ignore
if !table.Ignore {
if err := createTableLikeError(node.GetCreateStmt(), table.FQTN(), stmtOffset); err != nil {
return nil, err
}
}
if err := setUnique(tables, table.FQTN(), "table", table, "", stmtOffset); err != nil {
return nil, err
}
case node.GetViewStmt() != nil:
view, err := parseViewStmt(node.GetViewStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
view.RenameFrom = &qualified
}
view.Ignore = ignore
if err := setUnique(views, view.FQVN(), "view", view, "", stmtOffset); err != nil {
return nil, err
}
case node.GetCreateTableAsStmt() != nil:
as := node.GetCreateTableAsStmt()
// CREATE TABLE AS shares this statement type and is not read.
if as.Objtype != pg_query.ObjectType_OBJECT_MATVIEW {
warnIgnoredStmt(sql, loc, rawStmt)
break
}
view, err := parseCreateMatViewStmt(as, defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
view.RenameFrom = &qualified
}
view.Ignore = ignore
if err := setUnique(views, view.FQVN(), "materialized view", view, "", stmtOffset); err != nil {
return nil, err
}
case node.GetIndexStmt() != nil:
unnamed := node.GetIndexStmt().Idxname == ""
idx, err := parseIndexStmt(node.GetIndexStmt(), rawStmt, defaultSchema)
if err != nil {
return nil, err
}
if unnamed {
autoNamed[AutoNamedObject{Index: true, Table: idx.FQTN(), Name: idx.Name}] = true
}
if renameFrom != "" {
unquoted := normalizeUnqualifiedDirective(renameFrom)
idx.RenameFrom = &unquoted
}
if concurrentlyDirectives[rawStmt.StmtLocation] {
idx.Concurrently = true
}
fqtn := idx.FQTN()
target := LintTarget{Kind: LintIndex, Table: fqtn, Name: idx.Name}
addLintIgnores([]LintTarget{target}, lintIgnore)
addPosition(target, stmtOffset)
if t, ok := tables.GetOk(fqtn); ok {
if err := setUnique(t.Indexes, idx.Name, "index", idx, fqtn, stmtOffset); err != nil {
return nil, err
}
} else if v, ok := views.GetOk(fqtn); ok {
// PostgreSQL refuses an index on a plain view.
if !v.Materialized {
return nil, &locatedError{msg: "CREATE INDEX " + idx.Name + ": " + fqtn + " is a view, which cannot hold an index", offset: int(stmtOffset)}
}
if err := setUnique(v.Indexes, idx.Name, "index", idx, fqtn, stmtOffset); err != nil {
return nil, err
}
} else {
return nil, undeclared("CREATE INDEX "+idx.Name, "table or materialized view", fqtn, stmtOffset)
}
case node.GetAlterTableStmt() != nil:
as := node.GetAlterTableStmt()
schema := as.Relation.Schemaname
if schema == "" {
schema = defaultSchema
}
fqtn := model.Ident(schema, as.Relation.Relname)
t, ok := tables.GetOk(fqtn)
if !ok {
// ALTER INDEX, ALTER VIEW and ALTER MATERIALIZED VIEW share
// this statement type, name no table, and are not read. Nor
// is ALTER TABLE on a view, a materialized view or a
// sequence, which PostgreSQL accepts and pg_dump writes for
// a view column's default. IF EXISTS is about the database,
// not the file, so it does not excuse a missing declaration.
_, isView := views.GetOk(fqtn)
_, isSeq := sequences.GetOk(fqtn)
if as.Objtype != pg_query.ObjectType_OBJECT_TABLE || isView || isSeq {
warnIgnoredStmt(sql, loc, rawStmt)
continue
}
return nil, undeclared("ALTER TABLE "+fqtn, "table", fqtn, stmtOffset)
}
// A table marked -- pista:ignore is out of the diff, so an
// action dropped from it, or a trigger or column it does not
// declare, cannot mislead the plan.
if !t.Ignore {
if err := checkAlterTableTargets(as, t, fqtn, stmtOffset); err != nil {
return nil, err
}
warnIgnoredAlterTableCmds(sql, loc, rawStmt, as)
}
// RLS toggles, trigger states, column storage and constraint
// subcommands can coexist in one ALTER TABLE statement. Each
// helper picks up only its own subtypes and walks the cmd list
// independently, so run them all.
applyAlterTableRLS(as, t)
applyAlterTableTriggerState(as, t)
applyAlterTableColumnStorage(as, t)
unnamed := unnamedConstraints(as)
cons, fks, err := parseAlterTableConstraints(as, defaultSchema)
if err != nil {
return nil, err
}
addAutoNamed(autoNamed, fqtn, unnamed)
// A rename directive names a single old object, so it cannot be
// applied when one statement declares several constraints.
if renameFrom != "" && len(cons)+len(fks) > 1 {
return nil, fmt.Errorf("pista:renamed-from is ambiguous: ALTER TABLE %s adds %d constraints in one statement", fqtn, len(cons)+len(fks))
}
var fkTargets []LintTarget
for _, fk := range fks {
if renameFrom != "" {
unquoted := normalizeUnqualifiedDirective(renameFrom)
fk.RenameFrom = &unquoted
}
if err := setUnique(t.ForeignKeys, fk.Name, "foreign key", fk, fqtn, stmtOffset); err != nil {
return nil, err
}
target := LintTarget{Kind: LintForeignKey, Table: fqtn, Name: fk.Name}
fkTargets = append(fkTargets, target)
addPosition(target, stmtOffset)
}
addLintIgnores(fkTargets, lintIgnore)
for _, con := range cons {
if renameFrom != "" {
unquoted := normalizeUnqualifiedDirective(renameFrom)
con.RenameFrom = &unquoted
}
if err := setUnique(t.Constraints, con.Name, "constraint", con, fqtn, stmtOffset); err != nil {
return nil, err
}
applyPrimaryKeyNotNull(t, con)
}
case node.GetCreatePolicyStmt() != nil:
policy, err := parseCreatePolicyStmt(node.GetCreatePolicyStmt(), defaultSchema, tables, stmtOffset)
if err != nil {
return nil, err
}
if renameFrom != "" {
unquoted := normalizeUnqualifiedDirective(renameFrom)
policy.RenameFrom = &unquoted
}
case node.GetCreateTrigStmt() != nil:
trg, err := parseCreateTrigStmt(node.GetCreateTrigStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
unquoted := normalizeUnqualifiedDirective(renameFrom)
trg.RenameFrom = &unquoted
}
if err := attachTrigger(trg, tables, views, stmtOffset); err != nil {
return nil, err
}
case node.GetCreateSeqStmt() != nil:
seq, err := parseCreateSeqStmt(node.GetCreateSeqStmt(), defaultSchema)
if err != nil {
return nil, err
}
if renameFrom != "" {
qualified := qualifyRenameFrom(renameFrom, defaultSchema)
seq.RenameFrom = &qualified
}
seq.Ignore = ignore
if err := setUnique(sequences, seq.FQN(), "sequence", seq, "", stmtOffset); err != nil {
return nil, err
}
case node.GetAlterSeqStmt() != nil:
seq, err := applyAlterSeqOwnedBy(node.GetAlterSeqStmt(), defaultSchema, sequences, stmtOffset)
if err != nil {
return nil, err
}
// A sequence marked -- pista:ignore is out of the diff, so an
// option dropped from it cannot mislead the plan.
if seq != nil && !seq.Ignore {
warnIgnoredAlterSeqOptions(sql, loc, rawStmt, node.GetAlterSeqStmt())
}
case node.GetCreateFunctionStmt() != nil:
routine, err := parseCreateFunctionStmt(node.GetCreateFunctionStmt(), defaultSchema)
if errors.Is(err, ErrUnsupportedRoutine) {
// A routine pistachio reads but does not manage. The catalog
// skips the same ones, so warning and dropping it here keeps
// both sides of the diff in step.
warnIgnoredStmt(sql, loc, rawStmt)
continue
}
if err != nil {
return nil, err
}
if renameFrom != "" {
return nil, fmt.Errorf("pista:renamed-from is not supported for routines: %s", routine.FQRN())
}
// The parser sets Ignore itself for a routine it reads but cannot
// compare, so this must not clear it.
routine.Ignore = routine.Ignore || ignore
if err := setUnique(routines, routine.FQRN(), "routine", routine, "", stmtOffset); err != nil {
return nil, err
}
case node.GetAlterDomainStmt() != nil:
ok, err := parseAlterDomainAddCheck(node.GetAlterDomainStmt(), defaultSchema, domains, stmtOffset)
if err != nil {
return nil, err
}
if !ok {
warnIgnoredStmt(sql, loc, rawStmt)
}
case node.GetCommentStmt() != nil:
cs := node.GetCommentStmt()
if !commentTargetSupported[cs.Objtype] {
warnIgnoredStmt(sql, loc, rawStmt)
break
}
parseCommentStmt(cs, defaultSchema, tables, views, enums, domains, compositeTypes, sequences, routines)
default:
// A statement type no case above handles. It is dropped from the
// desired schema, so warn instead of failing silently.
warnIgnoredStmt(sql, loc, rawStmt)
}
}
if err := applyUsingIndexPrimaryKeyNotNull(tables); err != nil {
return nil, err
}
if err := fillFKRefColumns(tables); err != nil {
return nil, err
}
if err := validatePartitionCycles(tables); err != nil {
return nil, err
}
if err := validateColumnRefs(tables); err != nil {
return nil, err
}
parsed := &ParseResult{Tables: tables, Views: views, Enums: enums, Domains: domains, CompositeTypes: compositeTypes, Sequences: sequences, Routines: routines, ExecuteStmts: executeStmts, LintIgnores: lintIgnores, LintIgnoreDirectives: lintIgnoreList, Positions: positions, AutoNamed: autoNamed}
if err := validateNamespaces(parsed); err != nil {
return nil, err
}
return parsed, nil
}
func parseCreateStmt(cs *pg_query.CreateStmt, defaultSchema string) (*model.Table, error) {
schema := cs.Relation.Schemaname
if schema == "" {
schema = defaultSchema
}
table := &model.Table{
Schema: schema,
Name: cs.Relation.Relname,
Unlogged: cs.Relation.Relpersistence == "u",
Partitioned: cs.Partspec != nil,
Columns: orderedmap.New[string, *model.Column](),
Constraints: orderedmap.New[string, *model.Constraint](),
ForeignKeys: orderedmap.New[string, *model.ForeignKey](),
Indexes: orderedmap.New[string, *model.Index](),
Policies: orderedmap.New[string, *model.Policy](),
Triggers: orderedmap.New[string, *model.Trigger](),
}
if cs.Tablespacename != "" {
ts := cs.Tablespacename
table.TableSpace = &ts
}
table.StorageParams = parseStorageParams(cs.Options)
if cs.Partspec != nil {
def, err := deparsePartitionSpec(cs)
if err != nil {
return nil, err
}
table.PartitionDef = &def
}
if len(cs.InhRelations) > 0 {
rv := cs.InhRelations[0].GetRangeVar()
if rv != nil {
parentSchema := rv.Schemaname
if parentSchema == "" {
parentSchema = defaultSchema
}
parent := model.Ident(parentSchema, rv.Relname)
table.PartitionOf = &parent
if cs.Partbound != nil {
bound, err := deparsePartitionBound(cs)
if err != nil {
return nil, err
}
table.PartitionBound = &bound
}
}
}
for _, elt := range cs.TableElts {
switch {
case elt.GetColumnDef() != nil:
cd := elt.GetColumnDef()
col, err := parseColumnDef(cd)
if err != nil {
return nil, err
}
if err := setUnique(table.Columns, col.Name, "column", col, table.FQTN(), cd.Location); err != nil {
return nil, err
}
// Extract column-level constraints (PRIMARY KEY, UNIQUE, CHECK, FK).
if err := extractColumnConstraints(cd, table, schema, defaultSchema); err != nil {
return nil, err
}
case elt.GetConstraint() != nil:
con := elt.GetConstraint()
if con.Contype == pg_query.ConstrType_CONSTR_FOREIGN {
fk, err := parseInlineForeignKey(con, schema, cs.Relation.Relname, defaultSchema)
if err != nil {
return nil, err
}
if err := setUnique(table.ForeignKeys, fk.Name, "foreign key", fk, table.FQTN(), con.Location); err != nil {
return nil, err
}
} else {
constraint, err := parseTableConstraint(con, table.Name)
if err != nil {
return nil, err
}
if constraint != nil {
if err := setUnique(table.Constraints, constraint.Name, "constraint", constraint, table.FQTN(), con.Location); err != nil {
return nil, err
}
applyPrimaryKeyNotNull(table, constraint)
}
}
}
}
return table, nil
}
// collationFromClause builds the canonical collation form (quoted, ready to
// follow COLLATE) from a COLLATE clause. It returns nil for the default
// collation, which the catalog never reports, so writing it explicitly does
// not read as a change.
func collationFromClause(cc *pg_query.CollateClause) *string {
if cc == nil || len(cc.Collname) == 0 {
return nil
}
var parts []string
for _, n := range cc.Collname {
if str := n.GetString_(); str != nil {
parts = append(parts, str.Sval)
}
}
if len(parts) == 0 || parts[len(parts)-1] == "default" {
return nil
}
collation := model.Ident(parts...)
return &collation
}
// applyPrimaryKeyNotNull marks the key columns of a primary key NOT NULL.
// PostgreSQL sets the flag itself whichever way the key arrives, inline with
// the table or in a later ALTER TABLE, and the catalog reports it, so the
// desired side has to read both forms the same way. Reading only the inline
// one planned a DROP NOT NULL that PostgreSQL then refuses with "column is in
// a primary key".
func applyPrimaryKeyNotNull(table *model.Table, con *model.Constraint) {
if !con.Type.IsPrimaryKeyConstraint() {
return
}
for _, colName := range con.Columns {
if col, ok := table.Columns.GetOk(colName); ok {
col.NotNull = true
}
}
}
// applyUsingIndexPrimaryKeyNotNull marks the columns of a primary key written
// USING INDEX NOT NULL. The key lists no columns, so they are read from its
// index, and the index must be declared. The index can come later in the
// files, so this runs after every statement is read. A table marked
// -- pista:ignore is out of the diff, so it is skipped.
func applyUsingIndexPrimaryKeyNotNull(tables *orderedmap.Map[string, *model.Table]) error {
for fqtn, t := range tables.All() {
if t.Ignore {
continue
}
for con := range t.Constraints.Values() {
if !con.Type.IsPrimaryKeyConstraint() || con.IndexName == "" {
continue
}
if _, ok := t.Indexes.GetOk(con.IndexName); !ok {
return fmt.Errorf("index %s used by primary key %s on table %s is not declared", model.Ident(con.IndexName), model.Ident(con.Name), fqtn)
}
for _, colName := range primaryKeyColumns(t) {
if col, ok := t.Columns.GetOk(colName); ok {
col.NotNull = true
}
}
}
}
return nil
}
func parseColumnDef(cd *pg_query.ColumnDef) (*model.Column, error) {
col := &model.Column{
Name: cd.Colname,
}
if cd.TypeName != nil {
typeName, err := deparseTypeName(cd.TypeName)
if err != nil {
return nil, fmt.Errorf("failed to deparse type for column %s: %w", cd.Colname, err)
}
col.TypeName = typeName
}
// serial expands to a NOT NULL column plus a sequence and a default, so a
// declaration that leaves the words off still describes a NOT NULL column.
// The catalog reports it that way, so reading it as nullable planned a
// DROP NOT NULL right after the table was created.
if model.IsSerialType(col.TypeName) {
col.NotNull = true
}
col.Collation = collationFromClause(cd.CollClause)
col.StorageType = normalizeStorageKeyword(cd.StorageName)
col.Compression = normalizeStorageKeyword(cd.Compression)
for _, conNode := range cd.Constraints {
con := conNode.GetConstraint()
if con == nil {
continue
}
switch con.Contype {
case pg_query.ConstrType_CONSTR_NOTNULL:
col.NotNull = true
if con.Conname != "" {
name := con.Conname
col.NotNullName = &name
}
case pg_query.ConstrType_CONSTR_DEFAULT:
if con.RawExpr != nil {
def, err := deparseExpr(con.RawExpr)
if err != nil {
return nil, fmt.Errorf("failed to deparse default for column %s: %w", cd.Colname, err)
}
def = parenthesizeDefault(def)
col.Default = &def
}
case pg_query.ConstrType_CONSTR_IDENTITY:
switch con.GeneratedWhen {
case "a":
col.Identity = model.ColumnIdentity('a')
case "d":
col.Identity = model.ColumnIdentity('d')
}
if col.Identity.IsIdentityColumn() {
// PostgreSQL takes the sequence type from the column and
// rejects an AS option here, so the bounds resolve against the
// column type.
p, err := parseSeqOptions(con.Options, col.TypeName)
if err != nil {
return nil, fmt.Errorf("failed to parse identity options for column %s: %w", cd.Colname, err)
}
col.IdentitySeq = &model.IdentitySequence{
Start: p.start,
Min: p.min,
Max: p.max,
Increment: p.increment,
Cache: p.cache,
Cycle: p.cycle,
}
}
case pg_query.ConstrType_CONSTR_GENERATED:
// pg_query reports GeneratedWhen="a" (ALWAYS) for STORED generated
// columns. PostgreSQL only supports STORED at this time, so any
// CONSTR_GENERATED implies stored. Map to the catalog form ('s').
col.Generated = model.ColumnGenerated('s')
if con.RawExpr != nil {
def, err := deparseExpr(con.RawExpr)
if err != nil {
return nil, fmt.Errorf("failed to deparse generated expr for column %s: %w", cd.Colname, err)
}
col.Default = &def
}
}
}
return col, nil
}
// normalizeStorageKeyword lowercases a STORAGE strategy or a COMPRESSION
// method and reads DEFAULT as none, which is how the catalog reports a column
// that carries neither.
func normalizeStorageKeyword(name string) string {
if strings.EqualFold(name, "default") {
return ""
}
return strings.ToLower(name)
}
// checkAlterTableTargets refuses a trigger state naming a trigger, or a
// storage setting naming a column, that the table does not declare before
// the statement. It runs before anything in the statement is applied or
// warned about. The ALL and USER trigger forms are subtypes of their own and
// name no trigger. A partition child declares no columns of its own, so its
// storage settings are not checked; dump writes none, pg_dump may.
func checkAlterTableTargets(as *pg_query.AlterTableStmt, t *model.Table, fqtn string, offset int32) error {
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil {
continue
}
switch cmd.Subtype {
case pg_query.AlterTableType_AT_EnableTrig, pg_query.AlterTableType_AT_DisableTrig,
pg_query.AlterTableType_AT_EnableAlwaysTrig, pg_query.AlterTableType_AT_EnableReplicaTrig:
if _, ok := t.Triggers.GetOk(cmd.Name); !ok {
return undeclared("ALTER TABLE "+fqtn, "trigger", cmd.Name, offset)
}
case pg_query.AlterTableType_AT_SetStorage, pg_query.AlterTableType_AT_SetCompression:
if _, ok := t.Columns.GetOk(cmd.Name); !ok && !t.IsPartitionChild() {
return undeclared("ALTER TABLE "+fqtn, "column", cmd.Name, offset)
}
}
}
return nil
}
// applyAlterTableColumnStorage reads the storage and compression actions onto
// the columns they name. pg_dump writes both as separate statements, so a file
// adopted from one carries them here rather than in the column definition.
func applyAlterTableColumnStorage(as *pg_query.AlterTableStmt, t *model.Table) {
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil {
continue
}
if cmd.Subtype != pg_query.AlterTableType_AT_SetStorage &&
cmd.Subtype != pg_query.AlterTableType_AT_SetCompression {
continue
}
// checkAlterTableTargets has refused a column the table does not
// declare, so the lookup fails only on a partition child, which
// declares none, or on an ignored table, which is not checked.
col, ok := t.Columns.GetOk(cmd.Name)
if !ok {
continue
}
value := normalizeStorageKeyword(cmd.Def.GetString_().GetSval())
if cmd.Subtype == pg_query.AlterTableType_AT_SetStorage {
col.StorageType = value
} else {
col.Compression = value
}
}
}
// nameDataLen mirrors PostgreSQL's NAMEDATALEN. An identifier holds at most
// nameDataLen-1 bytes.
const nameDataLen = 64
// clipIdent returns the longest prefix of s that is at most n bytes long and
// does not split a character, the way PostgreSQL's pg_mbcliplen does.
func clipIdent(s string, n int) string {
if n >= len(s) {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
// makeObjectName mirrors PostgreSQL's makeObjectName
// (src/backend/commands/indexcmds.c). It joins name1, name2 and label with
// underscores, shortening name1 and name2 - the longer one first - until the
// whole name fits in nameDataLen-1 bytes. The label is never shortened, so a
// long name keeps its _pkey / _check / ... suffix instead of losing it to a
// plain truncation of the joined string. An empty name2 stands for the NULL
// PostgreSQL passes when the name has no column part; label is always given,
// and is short enough to leave room for the rest, which is what PostgreSQL
// asserts there.
func makeObjectName(name1, name2, label string) string {
overhead := len(label) + 1
if name2 != "" {
overhead++ // separating underscore
}
name1chars := len(name1)
name2chars := len(name2)
availchars := nameDataLen - 1 - overhead
for name1chars+name2chars > availchars {
if name1chars > name2chars {
name1chars--
} else {
name2chars--
}
}
name := clipIdent(name1, name1chars)
if name2 != "" {
name += "_" + clipIdent(name2, name2chars)
}
return name + "_" + label
}
// MakeObjectName exports makeObjectName. The catalog uses it to compare a
// stored name with the name PostgreSQL would generate.
func MakeObjectName(name1, name2, label string) string {
return makeObjectName(name1, name2, label)
}
// autoNameConstraint generates a PostgreSQL-style constraint name for unnamed
// constraints, following the naming convention from PostgreSQL's
// ChooseConstraintName (src/backend/catalog/pg_constraint.c):
//
// PRIMARY KEY -> {table}_pkey
// UNIQUE -> {table}_{col}..._key
// CHECK -> {table}_{col}_check (or {table}_check)
// EXCLUSION -> {table}_{col}..._excl
// FOREIGN KEY -> {table}_{col}..._fkey
//
// cols holds every column the constraint keys on, in order, joined with an
// underscore the way PostgreSQL joins them. PRIMARY KEY carries no column, and
// a CHECK carries one only when its expression references exactly one, so both
// take an empty list in the other cases.
//
// A name that does not fit in an identifier is shortened by makeObjectName the
// way PostgreSQL shortens it. Without that the server would truncate the name
// it is handed instead, to something the next run no longer recognises.
//
// Duplicate name resolution is NOT handled: PostgreSQL appends a number to the
// second name (e.g. users_id_check1), which cannot be predicted from the
// desired schema alone, so a file that generates one name twice is rejected as
// a duplicate constraint name.
func autoNameConstraint(tableName string, cols []string, contype pg_query.ConstrType) string {
colPart := strings.Join(cols, "_")
switch contype {
case pg_query.ConstrType_CONSTR_PRIMARY:
return makeObjectName(tableName, "", "pkey")
case pg_query.ConstrType_CONSTR_UNIQUE:
return makeObjectName(tableName, colPart, "key")
case pg_query.ConstrType_CONSTR_CHECK:
return makeObjectName(tableName, colPart, "check")
case pg_query.ConstrType_CONSTR_EXCLUSION:
return makeObjectName(tableName, colPart, "excl")
case pg_query.ConstrType_CONSTR_FOREIGN:
return makeObjectName(tableName, colPart, "fkey")
default:
return ""
}
}
// figureIndexColname picks the name PostgreSQL gives an index element written
// as an expression, following FigureIndexColname and FigureColnameInternal
// (src/backend/parser/parse_target.c). The second return is PostgreSQL's
// strength: a name found deeper in the tree wins over one an enclosing cast or
// CASE falls back to, so `(a + b)::text` is named after the type while
// `coalesce(a, b)::text` keeps the function name. An empty name means
// PostgreSQL finds none and the element is called "expr".
//
// The node kinds below are the ones an index expression realistically holds.
// Anything else takes "expr", which is also what PostgreSQL does for every
// kind it has no name for.
func figureIndexColname(node *pg_query.Node) (string, int) {
if node == nil {
return "", 0
}
switch n := node.Node.(type) {
case *pg_query.Node_ColumnRef:
return lastNodeName(n.ColumnRef.Fields), 2
case *pg_query.Node_FuncCall:
return lastNodeName(n.FuncCall.Funcname), 2
case *pg_query.Node_AExpr:
// NULLIF is written like a function and named like one.
if n.AExpr.Kind == pg_query.A_Expr_Kind_AEXPR_NULLIF {
return "nullif", 2
}
case *pg_query.Node_CoalesceExpr:
return "coalesce", 2
case *pg_query.Node_MinMaxExpr:
if n.MinMaxExpr.Op == pg_query.MinMaxOp_IS_LEAST {
return "least", 2
}
return "greatest", 2
case *pg_query.Node_AArrayExpr:
return "array", 2
case *pg_query.Node_CaseExpr:
if name, strength := figureIndexColname(n.CaseExpr.Defresult); strength > 1 {
return name, strength
}
return "case", 1
case *pg_query.Node_TypeCast:
if name, strength := figureIndexColname(n.TypeCast.Arg); strength > 1 {
return name, strength
}
if n.TypeCast.TypeName != nil {
if name := lastNodeName(n.TypeCast.TypeName.Names); name != "" {
return name, 1
}
}
case *pg_query.Node_CollateClause:
return figureIndexColname(n.CollateClause.Arg)
case *pg_query.Node_AIndirection:
// A field selection is named after the field. A subscript carries no
// name of its own, so the argument is named instead.
if name := lastNodeName(n.AIndirection.Indirection); name != "" {
return name, 2
}
return figureIndexColname(n.AIndirection.Arg)
}
return "", 0
}
// lastNodeName returns the last name in a dotted or subscripted list, skipping
// subscripts and `*` the way FigureColnameInternal does. A list holding no name
// at all, such as a bare subscript, gives the empty string, which is what lets
// the A_Indirection case fall back to the argument.
func lastNodeName(nodes []*pg_query.Node) string {
var name string
for _, node := range nodes {
if s := node.GetString_(); s != nil {
name = s.Sval
}
}
return name
}
// chooseIndexColumnNames returns one name per index element, the key columns
// first and then the INCLUDE list, following ChooseIndexColumnNames
// (src/backend/commands/indexcmds.c). A name repeated within one index takes a
// number, so (lower(a), lower(b)) reads lower_lower1.
func chooseIndexColumnNames(is *pg_query.IndexStmt) []string {
return chooseIndexElemNames(is.IndexParams, is.IndexIncludingParams)
}
// chooseIndexElemNames is chooseIndexColumnNames over the element lists
// themselves, so a constraint's elements can be named the same way.
func chooseIndexElemNames(lists ...[]*pg_query.Node) []string {
var names []string
taken := func(name string) bool {
return slices.Contains(names, name)
}
for _, params := range lists {
for _, node := range params {
ie := node.GetIndexElem()
origname := ie.GetName()
if origname == "" {
origname, _ = figureIndexColname(ie.GetExpr())
if origname == "" {
origname = "expr"
}
}
curname := origname
for i := 1; taken(curname); i++ {
curname = origname + strconv.Itoa(i)
}
names = append(names, curname)
}
}
return names
}
// AutoNameIndex generates a PostgreSQL-style name for an index written without
// one, following ChooseRelationName (src/backend/commands/indexcmds.c):
//
// {table}_{col}..._idx
//
// Without this the index reaches the diff with an empty name, which matches no
// index in the database, so every run creates the index again and PostgreSQL
// numbers each copy.
//
// Duplicate name resolution is NOT handled, as for constraints: PostgreSQL
// appends a number when the name is already taken in the schema, which cannot
// be predicted from the desired schema alone, so a file that generates one
// name twice is rejected as a duplicate index name.
//
// The diff uses it too: the copy of a parent's index that PostgreSQL creates
// on a new partition takes this name.
func AutoNameIndex(is *pg_query.IndexStmt) string {
return makeObjectName(is.Relation.Relname, strings.Join(chooseIndexColumnNames(is), "_"), "idx")
}
// constraintNameParts returns the names PostgreSQL builds an unnamed constraint's
// name from, in order. The constraint is backed by an index, and the name comes
// from that index's elements: the key columns, which Keys holds for PRIMARY
// KEY and UNIQUE and Exclusions for EXCLUDE, followed by the INCLUDE columns.
// An element that is an expression is named the way an index element is, so
// lower(a) reads lower and a repeated name takes a number.
func constraintNameParts(con *pg_query.Constraint) []string {
var elems []*pg_query.Node
for _, k := range con.Keys {
if s := k.GetString_(); s != nil {
elems = append(elems, indexElemNode(s.Sval))
}
}
for _, ex := range con.Exclusions {
list := ex.GetList()
if list == nil {
continue
}
for _, item := range list.Items {
if item.GetIndexElem() != nil {
elems = append(elems, item)
}
}
}
for _, inc := range con.Including {
if s := inc.GetString_(); s != nil {
elems = append(elems, indexElemNode(s.Sval))
}
}
return chooseIndexElemNames(elems)
}
// indexElemNode wraps a column name as the index element a constraint's key
// list implies, so the naming can read keys and exclusions alike.
func indexElemNode(name string) *pg_query.Node {
return &pg_query.Node{Node: &pg_query.Node_IndexElem{IndexElem: &pg_query.IndexElem{Name: name}}}
}
// fkAttrCols returns the local-side columns of a foreign key, in order.
func fkAttrCols(con *pg_query.Constraint) []string {
var cols []string
for _, attr := range con.FkAttrs {
if s := attr.GetString_(); s != nil {
cols = append(cols, s.Sval)
}
}
return cols
}
// checkExprCols returns the single column a CHECK expression references, or nil
// when it references none or several. PostgreSQL names such a constraint after
// the column only in the single-column case, and it reads the expression even
// for a constraint written on a column, so `a integer CHECK (a > b)` becomes
// {table}_check rather than {table}_a_check.
//
// walkExprColumnRefs does not descend into every expression node, so an exotic
// CHECK can come back empty and take the {table}_check form where PostgreSQL
// would name the column.
func checkExprCols(expr *pg_query.Node) []string {
seen := map[string]bool{}
var cols []string
for _, ref := range walkExprColumnRefs(expr) {
if seen[ref] {
continue
}
seen[ref] = true
cols = append(cols, ref)
}
if len(cols) != 1 {
return nil
}
return cols
}
// autoNameColumnConstraint names an unnamed constraint written on a column.
func autoNameColumnConstraint(tableName, colName string, con *pg_query.Constraint) string {
if con.Contype == pg_query.ConstrType_CONSTR_CHECK {
return autoNameConstraint(tableName, checkExprCols(con.RawExpr), con.Contype)
}
return autoNameConstraint(tableName, []string{colName}, con.Contype)
}
// extractColumnConstraints extracts named constraints from a column definition
// (e.g. PRIMARY KEY, UNIQUE, CHECK, EXCLUSION, FOREIGN KEY) and adds them to
// the table. Column-attribute constraints (NOT NULL, DEFAULT, IDENTITY,
// GENERATED) are skipped as they are handled by parseColumnDef.
// Unnamed constraints are auto-named following PostgreSQL's naming convention.
func extractColumnConstraints(cd *pg_query.ColumnDef, table *model.Table, schema, defaultSchema string) error {
if err := foldConstraintAttrs(cd.Constraints); err != nil {
return err
}
for _, conNode := range cd.Constraints {
con := conNode.GetConstraint()
if con == nil {
continue
}
// Skip column-attribute constraints (NOT NULL, DEFAULT, IDENTITY,
// GENERATED) and the DEFERRABLE attributes folded in above.
switch con.Contype {
case pg_query.ConstrType_CONSTR_NOTNULL, pg_query.ConstrType_CONSTR_DEFAULT,
pg_query.ConstrType_CONSTR_IDENTITY, pg_query.ConstrType_CONSTR_GENERATED,
pg_query.ConstrType_CONSTR_ATTR_DEFERRABLE, pg_query.ConstrType_CONSTR_ATTR_NOT_DEFERRABLE,
pg_query.ConstrType_CONSTR_ATTR_DEFERRED, pg_query.ConstrType_CONSTR_ATTR_IMMEDIATE:
continue
}
if con.Conname == "" {
con.Conname = autoNameColumnConstraint(table.Name, cd.Colname, con)
}
// Column-level PK/UNIQUE/EXCLUSION have no Keys; fill in the column name.
// CHECK constraints do not use Keys (they reference columns via the expression).
switch con.Contype {
case pg_query.ConstrType_CONSTR_PRIMARY:
if len(con.Keys) == 0 {
con.Keys = []*pg_query.Node{pg_query.MakeStrNode(cd.Colname)}
}
// PK implies NOT NULL
if col, ok := table.Columns.GetOk(cd.Colname); ok {
col.NotNull = true
}
case pg_query.ConstrType_CONSTR_UNIQUE, pg_query.ConstrType_CONSTR_EXCLUSION:
if len(con.Keys) == 0 {
con.Keys = []*pg_query.Node{pg_query.MakeStrNode(cd.Colname)}
}
}
switch con.Contype {
case pg_query.ConstrType_CONSTR_FOREIGN:
// Column-level FK has no FkAttrs; fill in the owning column name.
if len(con.FkAttrs) == 0 {
con.FkAttrs = []*pg_query.Node{pg_query.MakeStrNode(cd.Colname)}
}
fk, err := parseInlineForeignKey(con, schema, table.Name, defaultSchema)
if err != nil {
return err
}
if err := setUnique(table.ForeignKeys, fk.Name, "foreign key", fk, table.FQTN(), con.Location); err != nil {
return err
}
case pg_query.ConstrType_CONSTR_PRIMARY, pg_query.ConstrType_CONSTR_UNIQUE,
pg_query.ConstrType_CONSTR_CHECK, pg_query.ConstrType_CONSTR_EXCLUSION:
constraint, err := parseTableConstraint(con, table.Name)
if err != nil {
return err
}
if constraint != nil {
if err := setUnique(table.Constraints, constraint.Name, "constraint", constraint, table.FQTN(), con.Location); err != nil {
return err
}
}
}
}
return nil
}
// foldConstraintAttrs applies the DEFERRABLE and INITIALLY clauses written on
// a column to the constraint before them, and rejects the ones PostgreSQL
// rejects, as its transformConstraintAttrs does. The grammar gives each clause
// its own node in ColumnDef.Constraints, so the parse lets them all through.
// INITIALLY DEFERRED implies DEFERRABLE.
func foldConstraintAttrs(nodes []*pg_query.Node) error {
var last *pg_query.Constraint
var sawDeferrability, sawInitially bool
for _, n := range nodes {
con := n.GetConstraint()
clause, ok := constraintAttrClauses[con.GetContype()]
if !ok {
last = con
sawDeferrability, sawInitially = false, false
continue
}
fail := func(msg string) error {
return &locatedError{msg: msg, offset: int(con.Location)}
}
if !supportsConstraintAttrs(last) {
return fail("misplaced " + clause + " clause")
}
switch con.Contype {
case pg_query.ConstrType_CONSTR_ATTR_DEFERRABLE, pg_query.ConstrType_CONSTR_ATTR_NOT_DEFERRABLE:
if sawDeferrability {
return fail("multiple DEFERRABLE/NOT DEFERRABLE clauses not allowed")
}
sawDeferrability = true
last.Deferrable = con.Contype == pg_query.ConstrType_CONSTR_ATTR_DEFERRABLE
default:
if sawInitially {
return fail("multiple INITIALLY IMMEDIATE/DEFERRED clauses not allowed")
}
sawInitially = true
last.Initdeferred = con.Contype == pg_query.ConstrType_CONSTR_ATTR_DEFERRED
if last.Initdeferred && !sawDeferrability {
last.Deferrable = true
}
}
if last.Initdeferred && !last.Deferrable {
return fail("constraint declared INITIALLY DEFERRED must be DEFERRABLE")
}
}
return nil
}
var constraintAttrClauses = map[pg_query.ConstrType]string{
pg_query.ConstrType_CONSTR_ATTR_DEFERRABLE: "DEFERRABLE",
pg_query.ConstrType_CONSTR_ATTR_NOT_DEFERRABLE: "NOT DEFERRABLE",
pg_query.ConstrType_CONSTR_ATTR_DEFERRED: "INITIALLY DEFERRED",
pg_query.ConstrType_CONSTR_ATTR_IMMEDIATE: "INITIALLY IMMEDIATE",
}
// supportsConstraintAttrs reports whether con takes DEFERRABLE and INITIALLY.
func supportsConstraintAttrs(con *pg_query.Constraint) bool {
switch con.GetContype() {
case pg_query.ConstrType_CONSTR_PRIMARY, pg_query.ConstrType_CONSTR_UNIQUE,
pg_query.ConstrType_CONSTR_EXCLUSION, pg_query.ConstrType_CONSTR_FOREIGN:
return true
}
return false
}
func parseTableConstraint(con *pg_query.Constraint, tableName string) (*model.Constraint, error) {
if con.Conname == "" {
if con.Indexname != "" {
// ADD UNIQUE USING INDEX without CONSTRAINT: PostgreSQL names
// the constraint after the index.
con.Conname = con.Indexname
} else {
cols := constraintNameParts(con)
if con.Contype == pg_query.ConstrType_CONSTR_CHECK {
cols = checkExprCols(con.RawExpr)
}
con.Conname = autoNameConstraint(tableName, cols, con.Contype)
}
}
var conType model.ConstraintType
switch con.Contype {
case pg_query.ConstrType_CONSTR_PRIMARY:
conType = model.ConstraintType('p')
case pg_query.ConstrType_CONSTR_UNIQUE:
conType = model.ConstraintType('u')
case pg_query.ConstrType_CONSTR_CHECK:
conType = model.ConstraintType('c')
case pg_query.ConstrType_CONSTR_EXCLUSION:
conType = model.ConstraintType('x')
default:
return nil, nil
}
def, err := deparseConstraintDef(con)
if err != nil {
return nil, fmt.Errorf("failed to deparse constraint %s: %w", con.Conname, err)
}
var columns []string
for _, k := range con.Keys {
if s := k.GetString_(); s != nil {
columns = append(columns, s.Sval)
}
}
return &model.Constraint{
Name: con.Conname,
Type: conType,
Definition: def,
Columns: columns,
Deferrable: con.Deferrable,
Deferred: con.Initdeferred,
Validated: !con.SkipValidation,
IndexName: con.Indexname,
}, nil
}
func parseIndexStmt(is *pg_query.IndexStmt, rawStmt *pg_query.RawStmt, defaultSchema string) (*model.Index, error) {
schema := is.Relation.Schemaname
if schema == "" {
schema = defaultSchema
// Qualify the relation with the default schema before deparsing
// so the Definition contains the fully-qualified table name.
is.Relation.Schemaname = defaultSchema
}
// Capture and clear the Concurrent flag before deparsing so the stored
// Definition is canonical (without CONCURRENTLY). Whether to emit
// CONCURRENTLY is decided per-operation via Index.Concurrently, which
// keeps HasConcurrently tracking and --disable-index-concurrently
// accurate even when input SQL uses CREATE INDEX CONCURRENTLY directly.
concurrent := is.Concurrent
is.Concurrent = false
// IF NOT EXISTS says how to run the statement, not what the index is.
// pg_get_indexdef never writes it, so leaving it in would make the two
// sides differ and plan a drop and a create on every run.
is.IfNotExists = false
// Name the index before deparsing so the stored Definition carries the
// name PostgreSQL would have picked.
if is.Idxname == "" {
is.Idxname = AutoNameIndex(is)
}
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{Stmt: rawStmt.Stmt}},
}
def, err := pg_query.Deparse(result)
if err != nil {
return nil, fmt.Errorf("failed to deparse index: %w", err)
}
var tablespace *string
if is.TableSpace != "" {
ts := is.TableSpace
tablespace = &ts
}
return &model.Index{
Schema: schema,
Name: is.Idxname,
Table: is.Relation.Relname,
Definition: def,
TableSpace: tablespace,
Concurrently: concurrent,
}, nil
}
func parseViewStmt(vs *pg_query.ViewStmt, defaultSchema string) (*model.View, error) {
schema := vs.View.Schemaname
if schema == "" {
schema = defaultSchema
}
columnNames := applyColumnNames(vs.Query, vs.Aliases)
// Deparse the SELECT query
selectResult := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: vs.Query,
}},
}
def, err := pg_query.Deparse(selectResult)
if err != nil {
return nil, fmt.Errorf("failed to deparse view query: %w", err)
}
return &model.View{
Schema: schema,
Name: vs.View.Relname,
Definition: def,
ColumnNames: columnNames,
CheckOption: viewCheckOption(vs),
StorageParams: parseViewStorageParams(vs.Options),
Indexes: orderedmap.New[string, *model.Index](),
Triggers: orderedmap.New[string, *model.Trigger](),
ColumnComments: orderedmap.New[string, string](),
}, nil
}
// applyColumnNames writes the column list of CREATE VIEW v (x, y) onto the
// target list of the query, which is where PostgreSQL keeps it and where
// pg_get_viewdef writes it back, so the definition compares with the catalog.
// A set operation takes its names from its leftmost SELECT. A name equal to
// the column a target reads is left off, as pg_get_viewdef leaves it off.
//
// It returns the names it could not write, to be kept as a column list: all of
// them when the query has no target list (VALUES), when a star sits among the
// named targets, when there are more names than targets, or when the query
// refers to a name that the rewrite would take away or add, as in ORDER BY z.
func applyColumnNames(query *pg_query.Node, colNames []*pg_query.Node) []string {
if len(colNames) == 0 {
return nil
}
names := make([]string, len(colNames))
for i, n := range colNames {
names[i] = n.GetString_().GetSval()
}
ss := query.GetSelectStmt()
for ss != nil && ss.Op != pg_query.SetOperation_SETOP_NONE {
ss = ss.Larg
}
if ss == nil || len(ss.TargetList) < len(names) {
return names
}
targets := make([]*pg_query.ResTarget, len(names))
for i := range names {
rt := ss.TargetList[i].GetResTarget()
if rt == nil || isStarTarget(rt) {
return names
}
targets[i] = rt
}
if refersToChangedName(query.GetSelectStmt(), ss, targets, names) {
return names
}
for i, rt := range targets {
rt.Name = names[i]
if columnRefName(rt) == names[i] {
rt.Name = ""
}
}
return nil
}
// refersToChangedName reports whether the query refers by a bare name to an
// output name that applyColumnNames would change. An alias the query wrote
// goes away, so ORDER BY z or GROUP BY z breaks. A new name is resolved before
// an input column of the same name in ORDER BY and DISTINCT ON, so
// SELECT b FROM t ORDER BY a with the list (a) would sort by b instead of t.a.
// GROUP BY resolves an input column first, so a new name there is harmless.
// The name a column reference gives its target is not counted: once it goes,
// the bare name still finds the same input column.
//
// Output names are visible only in these clauses, of the SELECT that carries
// the targets and, for a set operation, of the whole, and not in a sub-query
// under them.
func refersToChangedName(top, ss *pg_query.SelectStmt, targets []*pg_query.ResTarget, names []string) bool {
removed := map[string]bool{}
added := map[string]bool{}
for i, rt := range targets {
cur := rt.Name
if cur == "" {
cur = columnRefName(rt)
}
if cur == names[i] {
continue
}
if rt.Name != "" {
removed[rt.Name] = true
}
added[names[i]] = true
}
either := maps.Clone(removed)
maps.Copy(either, added)
found := false
refers := func(clause []*pg_query.Node, names map[string]bool) {
for _, c := range clause {
pgast.Walk(c, pgast.WalkOptions{SkipSubqueries: true}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
if cr := n.GetColumnRef(); cr != nil && len(cr.Fields) == 1 && names[cr.Fields[0].GetString_().GetSval()] {
found = true
}
return n
})
}
}
for _, s := range []*pg_query.SelectStmt{top, ss} {
refers(s.SortClause, either)
refers(s.DistinctClause, either)
refers(s.GroupClause, removed)
}
return found
}
// columnRefName returns the name of the column a target reads, or "" when the
// target is not a column reference.
func columnRefName(rt *pg_query.ResTarget) string {
fields := rt.Val.GetColumnRef().GetFields()
if len(fields) == 0 {
return ""
}
return fields[len(fields)-1].GetString_().GetSval()
}
// isStarTarget reports whether a target is * or t.*.
func isStarTarget(rt *pg_query.ResTarget) bool {
fields := rt.Val.GetColumnRef().GetFields()
return len(fields) > 0 && fields[len(fields)-1].GetAStar() != nil
}
// parseViewStorageParams reads the WITH (...) that precedes AS in a CREATE VIEW
// or CREATE MATERIALIZED VIEW. check_option is left out: it is read as the
// view's check option instead, which is also where the catalog side puts it.
func parseViewStorageParams(options []*pg_query.Node) *orderedmap.Map[string, string] {
rest := make([]*pg_query.Node, 0, len(options))
for _, o := range options {
if o.GetDefElem().GetDefname() == "check_option" {
continue
}
rest = append(rest, o)
}
return parseStorageParams(rest)
}
// viewCheckOption reads a view's check option from the trailing
// WITH [LOCAL | CASCADED] CHECK OPTION, which pg_dump and pista dump write, or
// from a check_option entry in the WITH (...) before AS. A bare WITH CHECK
// OPTION is CASCADED. PostgreSQL rejects a statement that writes both, so the
// order here does not matter.
func viewCheckOption(vs *pg_query.ViewStmt) string {
switch vs.WithCheckOption {
case pg_query.ViewCheckOption_LOCAL_CHECK_OPTION:
return "local"
case pg_query.ViewCheckOption_CASCADED_CHECK_OPTION:
return "cascaded"
}
for _, opt := range vs.Options {
de := opt.GetDefElem()
if de == nil || de.Defname != "check_option" || de.Arg == nil {
continue
}
// A quoted value arrives as a string, a bare one as a type name.
if s := de.Arg.GetString_(); s != nil {
return strings.ToLower(s.Sval)
}
if tn := de.Arg.GetTypeName(); tn != nil && len(tn.Names) > 0 {
return strings.ToLower(tn.Names[len(tn.Names)-1].GetString_().GetSval())
}
}
return ""
}
func parseCreateMatViewStmt(as *pg_query.CreateTableAsStmt, defaultSchema string) (*model.View, error) {
into := as.Into
if into == nil || into.Rel == nil {
return nil, errors.New("materialized view has no target relation")
}
schema := into.Rel.Schemaname
if schema == "" {
schema = defaultSchema
}
columnNames := applyColumnNames(as.Query, into.ColNames)
// Deparse the SELECT query
selectResult := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: as.Query,
}},
}
def, err := pg_query.Deparse(selectResult)
if err != nil {
return nil, fmt.Errorf("failed to deparse materialized view query: %w", err)
}
return &model.View{
Schema: schema,
Name: into.Rel.Relname,
Definition: def,
ColumnNames: columnNames,
Materialized: true,
WithNoData: into.SkipData,
StorageParams: parseViewStorageParams(into.Options),
Indexes: orderedmap.New[string, *model.Index](),
Triggers: orderedmap.New[string, *model.Trigger](),
ColumnComments: orderedmap.New[string, string](),
}, nil
}
func parseCreateDomainStmt(ds *pg_query.CreateDomainStmt, defaultSchema string) (*model.Domain, error) {
schema := defaultSchema
name := ""
for i, n := range ds.Domainname {
if s := n.GetString_(); s != nil {
if i == len(ds.Domainname)-1 {
name = s.Sval
} else {
schema = s.Sval
}
}
}
// Parse the base type
baseType := ""
if ds.TypeName != nil {
bt, err := deparseTypeName(ds.TypeName)
if err != nil {
return nil, fmt.Errorf("failed to deparse base type for domain %s: %w", name, err)
}
baseType = bt
}
domain := &model.Domain{
Schema: schema,
Name: name,
BaseType: baseType,
}
// Extract collation
domain.Collation = collationFromClause(ds.CollClause)
for _, conNode := range ds.Constraints {
con := conNode.GetConstraint()
if con == nil {
continue
}
switch con.Contype {
case pg_query.ConstrType_CONSTR_NOTNULL:
domain.NotNull = true
case pg_query.ConstrType_CONSTR_DEFAULT:
if con.RawExpr != nil {
def, err := deparseExpr(con.RawExpr)
if err != nil {
return nil, fmt.Errorf("failed to deparse default for domain %s: %w", name, err)
}
def = parenthesizeDefault(def)
domain.Default = &def
}
case pg_query.ConstrType_CONSTR_CHECK:
dc, err := parseDomainCheck(con, name)
if err != nil {
return nil, err
}
domain.Constraints = append(domain.Constraints, dc)
}
}
return domain, nil
}
// parseDomainCheck converts a domain CHECK constraint. An unnamed one gets the
// name PostgreSQL would give it.
func parseDomainCheck(con *pg_query.Constraint, domainName string) (*model.DomainConstraint, error) {
if con.Conname == "" {
con.Conname = makeObjectName(domainName, "", "check")
}
def := ""
if con.RawExpr != nil {
expr, err := deparseExpr(con.RawExpr)
if err != nil {
return nil, fmt.Errorf("failed to deparse constraint %s for domain %s: %w", con.Conname, domainName, err)
}
def = "CHECK (" + expr + ")"
}
return &model.DomainConstraint{
Name: con.Conname,
Definition: def,
Validated: !con.SkipValidation,
}, nil
}
// parseAlterDomainAddCheck reads ALTER DOMAIN ... ADD CONSTRAINT ... CHECK,
// which is how a NOT VALID domain constraint is written. It returns false for
// any other ALTER DOMAIN, which the caller warns about.
func parseAlterDomainAddCheck(
ads *pg_query.AlterDomainStmt,
defaultSchema string,
domains *orderedmap.Map[string, *model.Domain],
offset int32,
) (bool, error) {
con := ads.GetDef().GetConstraint()
if ads.Subtype != "C" || con == nil || con.Contype != pg_query.ConstrType_CONSTR_CHECK {
return false, nil
}
schema := defaultSchema
name := ""
for i, n := range ads.TypeName {
if s := n.GetString_(); s != nil {
if i == len(ads.TypeName)-1 {
name = s.Sval
} else {
schema = s.Sval
}
}
}
fqdn := model.Ident(schema, name)
d, ok := domains.GetOk(fqdn)
if !ok {
return false, undeclared("ALTER DOMAIN "+fqdn, "domain", fqdn, offset)
}
dc, err := parseDomainCheck(con, name)
if err != nil {
return false, err
}
for _, c := range d.Constraints {
if c.Name == dc.Name {
return false, &locatedError{
msg: fmt.Sprintf("duplicate domain constraint: %s on %s", dc.Name, fqdn),
offset: int(offset),
}
}
}
d.Constraints = append(d.Constraints, dc)
return true, nil
}
func parseCompositeTypeStmt(cts *pg_query.CompositeTypeStmt, defaultSchema string) (*model.CompositeType, error) {
schema := defaultSchema
name := ""
if cts.Typevar != nil {
name = cts.Typevar.Relname
if cts.Typevar.Schemaname != "" {
schema = cts.Typevar.Schemaname
}
}
compositeType := &model.CompositeType{
Schema: schema,
Name: name,
}
for _, node := range cts.Coldeflist {
cd := node.GetColumnDef()
if cd == nil {
continue
}
typeName := ""
if cd.TypeName != nil {
tn, err := deparseTypeName(cd.TypeName)
if err != nil {
return nil, fmt.Errorf("failed to deparse attribute type for composite type %s: %w", name, err)
}
typeName = tn
}
attr := &model.CompositeAttribute{
Name: cd.Colname,
TypeName: typeName,
}
attr.Collation = collationFromClause(cd.CollClause)
compositeType.Attributes = append(compositeType.Attributes, attr)
}
return compositeType, nil
}
func parseCommentOnDomain(cs *pg_query.CommentStmt, defaultSchema string, domains *orderedmap.Map[string, *model.Domain]) {
if d := findDomain(cs.Object.GetTypeName(), defaultSchema, domains); d != nil {
d.Comment = commentPtr(cs.Comment)
}
}
// parseCommentOnDomainConstraint reads COMMENT ON CONSTRAINT ... ON DOMAIN,
// whose object is the domain as a type name followed by the constraint's name.
func parseCommentOnDomainConstraint(cs *pg_query.CommentStmt, defaultSchema string, domains *orderedmap.Map[string, *model.Domain]) {
items := cs.Object.GetList().GetItems()
d := findDomain(items[0].GetTypeName(), defaultSchema, domains)
if d == nil {
return
}
name := items[1].GetString_().GetSval()
for _, c := range d.Constraints {
if c.Name == name {
c.Comment = commentPtr(cs.Comment)
return
}
}
}
// findDomain returns the domain a type name names, or nil when the file does
// not define it.
func findDomain(tn *pg_query.TypeName, defaultSchema string, domains *orderedmap.Map[string, *model.Domain]) *model.Domain {
var names []string
for _, n := range tn.Names {
if s := n.GetString_(); s != nil {
names = append(names, s.Sval)
}
}
schema, domainName := schemaName(names, defaultSchema)
return domains.Get(model.Ident(schema, domainName))
}
func parseCreateEnumStmt(es *pg_query.CreateEnumStmt, defaultSchema string) (*model.Enum, error) {
schema := defaultSchema
name := ""
for i, n := range es.TypeName {
if s := n.GetString_(); s != nil {
if i == len(es.TypeName)-1 {
name = s.Sval
} else {
schema = s.Sval
}
}
}
var values []string
for _, v := range es.Vals {
if s := v.GetString_(); s != nil {
values = append(values, s.Sval)
}
}
return &model.Enum{
Schema: schema,
Name: name,
Values: values,
}, nil
}
// seqParams holds a sequence option list after PostgreSQL's implicit defaults
// have been filled in.
type seqParams struct {
dataType string
start int64
min int64
max int64
increment int64
cache int64
cycle bool
ownerTable *string
ownerColumn *string
}
// parseSeqOptions reads a sequence option list, the one CREATE SEQUENCE takes
// and the one an identity column's sequence_options gives, and fills in the
// same implicit defaults PostgreSQL applies (so a bare "CREATE SEQUENCE s"
// matches the catalog values 1/1/2^63-1/1/1/false). NO MINVALUE and NO MAXVALUE
// (arg nil) are treated as "use the default", matching PostgreSQL. dataType is
// the type to resolve the bounds against, and an AS option overrides it.
func parseSeqOptions(options []*pg_query.Node, dataType string) (*seqParams, error) {
var (
increment int64 = 1
hasMin, hasMax, hasStart bool
minVal, maxVal, startVal int64
cacheVal int64 = 1
cycle bool
ownerTable, ownerColumn *string
)
for _, o := range options {
de := o.GetDefElem()
if de == nil {
continue
}
switch de.Defname {
case "as":
if tn := de.Arg.GetTypeName(); tn != nil {
dt, err := deparseTypeName(tn)
if err != nil {
return nil, fmt.Errorf("failed to deparse sequence data type: %w", err)
}
dataType = dt
}
case "increment":
v, ok, err := defElemInt64(de)
if err != nil {
return nil, err
}
if ok {
increment = v
}
case "minvalue":
v, ok, err := defElemInt64(de)
if err != nil {
return nil, err
}
if ok {
minVal = v
hasMin = true
}
case "maxvalue":
v, ok, err := defElemInt64(de)
if err != nil {
return nil, err
}
if ok {
maxVal = v
hasMax = true
}
case "start":
v, ok, err := defElemInt64(de)
if err != nil {
return nil, err
}
if ok {
startVal = v
hasStart = true
}
case "cache":
v, ok, err := defElemInt64(de)
if err != nil {
return nil, err
}
if ok {
cacheVal = v
}
case "cycle":
if b := de.Arg.GetBoolean(); b != nil {
cycle = b.Boolval
}
case "owned_by":
ownerTable, ownerColumn = parseSeqOwnedBy(de.Arg)
}
}
// Apply PostgreSQL's defaults for any options left unspecified.
def := model.DefaultIdentitySequence(dataType, increment)
if !hasMin {
minVal = def.Min
}
if !hasMax {
maxVal = def.Max
}
if !hasStart {
if increment > 0 {
startVal = minVal
} else {
startVal = maxVal
}
}
return &seqParams{
dataType: dataType,
start: startVal,
min: minVal,
max: maxVal,
increment: increment,
cache: cacheVal,
cycle: cycle,
ownerTable: ownerTable,
ownerColumn: ownerColumn,
}, nil
}
// parseCreateSeqStmt parses a CREATE SEQUENCE statement.
func parseCreateSeqStmt(cs *pg_query.CreateSeqStmt, defaultSchema string) (*model.Sequence, error) {
schema := cs.Sequence.Schemaname
if schema == "" {
schema = defaultSchema
}
p, err := parseSeqOptions(cs.Options, "bigint")
if err != nil {
return nil, err
}
return &model.Sequence{
Schema: schema,
Name: cs.Sequence.Relname,
Unlogged: cs.Sequence.Relpersistence == "u",
DataType: p.dataType,
Start: p.start,
Min: p.min,
Max: p.max,
Increment: p.increment,
Cache: p.cache,
Cycle: p.cycle,
OwnerTable: p.ownerTable,
OwnerColumn: p.ownerColumn,
}, nil
}
// defElemInt64 reads an integer sequence option. pg_query encodes values that
// fit in int32 as Integer nodes and larger values (e.g. bigint bounds) as
// Float nodes carrying the decimal string. Returns ok=false when the arg is
// nil (NO MINVALUE / NO MAXVALUE).
func defElemInt64(de *pg_query.DefElem) (int64, bool, error) {
if de.Arg == nil {
return 0, false, nil
}
if i := de.Arg.GetInteger(); i != nil {
return int64(i.Ival), true, nil
}
if f := de.Arg.GetFloat(); f != nil {
v, err := strconv.ParseInt(f.Fval, 10, 64)
if err != nil {
return 0, false, fmt.Errorf("invalid value %q for sequence option %s: %w", f.Fval, de.Defname, err)
}
return v, true, nil
}
return 0, false, fmt.Errorf("unexpected value for sequence option %s", de.Defname)
}
// applyAlterSeqOwnedBy records an ALTER SEQUENCE OWNED BY clause on the
// already-parsed sequence. OWNED BY NONE clears the owner. Other ALTER
// SEQUENCE options are not tracked.
func applyAlterSeqOwnedBy(as *pg_query.AlterSeqStmt, defaultSchema string, sequences *orderedmap.Map[string, *model.Sequence], offset int32) (*model.Sequence, error) {
if as.Sequence == nil {
return nil, nil
}
schema := as.Sequence.Schemaname
if schema == "" {
schema = defaultSchema
}
fqn := model.Ident(schema, as.Sequence.Relname)
seq, ok := sequences.GetOk(fqn)
if !ok {
return nil, undeclared("ALTER SEQUENCE "+fqn, "sequence", fqn, offset)
}
for _, opt := range as.Options {
de := opt.GetDefElem()
if de == nil || de.Defname != "owned_by" {
continue
}
seq.OwnerTable, seq.OwnerColumn = parseSeqOwnedBy(de.Arg)
}
return seq, nil
}
// warnIgnoredAlterSeqOptions warns about the ALTER SEQUENCE options other
// than OWNED BY, which the parser does not read, the way
// warnIgnoredAlterTableCmds does for an ALTER TABLE action: the warning
// carries a statement rebuilt from the dropped options alone.
func warnIgnoredAlterSeqOptions(sql string, loc *locator, rawStmt *pg_query.RawStmt, as *pg_query.AlterSeqStmt) {
var ignored []*pg_query.Node
for _, opt := range as.Options {
if de := opt.GetDefElem(); de != nil && de.Defname == "owned_by" {
continue
}
ignored = append(ignored, opt)
}
if len(ignored) == 0 {
return
}
warnIgnoredStmt(sql, loc, &pg_query.RawStmt{
Stmt: &pg_query.Node{
Node: &pg_query.Node_AlterSeqStmt{
AlterSeqStmt: &pg_query.AlterSeqStmt{
Sequence: as.Sequence,
Options: ignored,
MissingOk: as.MissingOk,
},
},
},
StmtLocation: rawStmt.StmtLocation,
StmtLen: rawStmt.StmtLen,
})
}
// parseSeqOwnedBy extracts the owner table and column from an OWNED BY clause.
// OWNED BY NONE (list ["none"]) yields nil owner. The table's schema is
// dropped, because PostgreSQL requires it to be the sequence's schema.
func parseSeqOwnedBy(arg *pg_query.Node) (*string, *string) {
list := arg.GetList()
if list == nil {
return nil, nil
}
var names []string
for _, item := range list.Items {
if s := item.GetString_(); s != nil {
names = append(names, s.Sval)
}
}
if len(names) < 2 {
return nil, nil
}
table := names[len(names)-2]
column := names[len(names)-1]
return &table, &column
}
func parseCommentStmt(cs *pg_query.CommentStmt, defaultSchema string, tables *orderedmap.Map[string, *model.Table], views *orderedmap.Map[string, *model.View], enums *orderedmap.Map[string, *model.Enum], domains *orderedmap.Map[string, *model.Domain], compositeTypes *orderedmap.Map[string, *model.CompositeType], sequences *orderedmap.Map[string, *model.Sequence], routines *orderedmap.Map[string, *model.Routine]) {
// COMMENT ON TYPE/DOMAIN uses TypeName, not a list. COMMENT ON TYPE also
// names a domain.
if cs.Objtype == pg_query.ObjectType_OBJECT_TYPE {
parseCommentOnType(cs, defaultSchema, enums, compositeTypes)
parseCommentOnDomain(cs, defaultSchema, domains)
return
}
if cs.Objtype == pg_query.ObjectType_OBJECT_DOMAIN {
parseCommentOnDomain(cs, defaultSchema, domains)
return
}
if cs.Objtype == pg_query.ObjectType_OBJECT_DOMCONSTRAINT {
parseCommentOnDomainConstraint(cs, defaultSchema, domains)
return
}
// COMMENT ON FUNCTION/PROCEDURE carries an ObjectWithArgs, not a list.
if cs.Objtype == pg_query.ObjectType_OBJECT_FUNCTION || cs.Objtype == pg_query.ObjectType_OBJECT_PROCEDURE {
parseCommentOnRoutine(cs, defaultSchema, routines)
return
}
items := cs.Object.GetList().GetItems()
if len(items) == 0 {
return
}
var names []string
for _, item := range items {
if s := item.GetString_(); s != nil {
names = append(names, s.Sval)
}
}
switch cs.Objtype {
case pg_query.ObjectType_OBJECT_TABLE:
schema, tableName := schemaName(names, defaultSchema)
fqtn := model.Ident(schema, tableName)
if t, ok := tables.GetOk(fqtn); ok {
t.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_VIEW, pg_query.ObjectType_OBJECT_MATVIEW:
schema, viewName := schemaName(names, defaultSchema)
fqvn := model.Ident(schema, viewName)
if v, ok := views.GetOk(fqvn); ok {
v.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_COLUMN:
if len(names) < 2 {
return
}
schema := defaultSchema
tableName := names[0]
colName := names[1]
if len(names) >= 3 {
schema = names[0]
tableName = names[1]
colName = names[2]
}
fqtn := model.Ident(schema, tableName)
comment := commentPtr(cs.Comment)
if t, ok := tables.GetOk(fqtn); ok {
if col, ok := t.Columns.GetOk(colName); ok {
col.Comment = comment
return
}
// A partition child declares no columns of its own, but comments
// are per-relation and can be set on an inherited column. Record
// the comment against a column entry so the diff can see it.
// Restricted to true partition children: an INHERITS-style child
// still goes through the regular column diff, where a bodyless
// entry would be mistaken for a new column.
if t.IsPartitionChild() {
t.Columns.Set(colName, &model.Column{Name: colName, Comment: comment})
}
return
}
// COMMENT ON COLUMN also targets the columns of a view or a
// materialized view. The column list is not checked: the view's
// columns come from its query, which the parser does not resolve.
if v, ok := views.GetOk(fqtn); ok {
if comment != nil {
v.ColumnComments.Set(colName, *comment)
} else {
v.ColumnComments.Delete(colName)
}
return
}
// COMMENT ON COLUMN also targets composite type attributes
// (schema.type.attribute).
if ct, ok := compositeTypes.GetOk(fqtn); ok {
for _, attr := range ct.Attributes {
if attr.Name == colName {
attr.Comment = comment
break
}
}
}
case pg_query.ObjectType_OBJECT_INDEX:
schema, idxName := schemaName(names, defaultSchema)
// COMMENT ON INDEX names the index alone, so the relation it sits on
// is found by scanning. An index name is unique within its schema, and
// the index a constraint owns is not in the model on either side.
if idx := findIndex(tables, views, schema, idxName); idx != nil {
idx.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_SEQUENCE:
schema, seqName := schemaName(names, defaultSchema)
fqn := model.Ident(schema, seqName)
if seq, ok := sequences.GetOk(fqn); ok {
seq.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_TABCONSTRAINT:
// A foreign key is a constraint too, and the two maps share the
// namespace PostgreSQL gives constraint names on a table.
t := tables.Get(relationIdent(names, defaultSchema))
if t == nil {
return
}
name := names[len(names)-1]
if con, ok := t.Constraints.GetOk(name); ok {
con.Comment = commentPtr(cs.Comment)
} else if fk, ok := t.ForeignKeys.GetOk(name); ok {
fk.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_TRIGGER:
// A view carries INSTEAD OF triggers. Every table and view the parser
// builds holds a trigger map, so neither is checked for nil.
fqn := relationIdent(names, defaultSchema)
name := names[len(names)-1]
var trg *model.Trigger
if t, ok := tables.GetOk(fqn); ok {
trg = t.Triggers.Get(name)
} else if v, ok := views.GetOk(fqn); ok {
trg = v.Triggers.Get(name)
}
if trg != nil {
trg.Comment = commentPtr(cs.Comment)
}
case pg_query.ObjectType_OBJECT_POLICY:
if t, ok := tables.GetOk(relationIdent(names, defaultSchema)); ok {
if p := t.Policies.Get(names[len(names)-1]); p != nil {
p.Comment = commentPtr(cs.Comment)
}
}
}
}
// relationIdent names the relation a constraint, a trigger or a policy sits
// on from the name parts COMMENT ON gives it: the relation, one- or
// two-part, followed by the object's own name.
func relationIdent(names []string, defaultSchema string) string {
schema, name := schemaName(names[:len(names)-1], defaultSchema)
return model.Ident(schema, name)
}
// commentPtr returns a pointer to a copy of the COMMENT ON text, or nil for an
// empty string, which removes the comment.
func commentPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// schemaName splits a one- or two-part object name into its schema and name,
// taking def as the schema when the name is unqualified.
func schemaName(names []string, def string) (schema, name string) {
schema = def
name = names[0]
if len(names) >= 2 {
schema = names[0]
name = names[1]
}
return schema, name
}
// findIndex returns the index of the given schema and name from the tables and
// views that carry one, or nil when no relation declares it. Every table and
// view the parser builds holds an index map, so neither is checked for nil.
func findIndex(tables *orderedmap.Map[string, *model.Table], views *orderedmap.Map[string, *model.View], schema, name string) *model.Index {
for _, t := range tables.CollectValues() {
if idx, ok := t.Indexes.GetOk(name); ok && idx.Schema == schema {
return idx
}
}
for _, v := range views.CollectValues() {
if idx, ok := v.Indexes.GetOk(name); ok && idx.Schema == schema {
return idx
}
}
return nil
}
func parseCommentOnType(cs *pg_query.CommentStmt, defaultSchema string, enums *orderedmap.Map[string, *model.Enum], compositeTypes *orderedmap.Map[string, *model.CompositeType]) {
tn := cs.Object.GetTypeName()
if tn == nil {
return
}
var names []string
for _, n := range tn.Names {
if s := n.GetString_(); s != nil {
names = append(names, s.Sval)
}
}
if len(names) == 0 {
return
}
schema, typeName := schemaName(names, defaultSchema)
// COMMENT ON TYPE names both enums and composite types; set on whichever
// this file defines.
fqn := model.Ident(schema, typeName)
comment := commentPtr(cs.Comment)
if e, ok := enums.GetOk(fqn); ok {
e.Comment = comment
}
if ct, ok := compositeTypes.GetOk(fqn); ok {
ct.Comment = comment
}
}
// parseAlterTableConstraints collects every ADD CONSTRAINT subcommand of an
// ALTER TABLE statement. PostgreSQL accepts comma-separated actions in one
// statement, so a single statement can declare several constraints.
func parseAlterTableConstraints(as *pg_query.AlterTableStmt, defaultSchema string) ([]*model.Constraint, []*model.ForeignKey, error) {
var constraints []*model.Constraint
var fks []*model.ForeignKey
schema := as.Relation.Schemaname
if schema == "" {
schema = defaultSchema
}
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil || cmd.Subtype != pg_query.AlterTableType_AT_AddConstraint {
continue
}
con := cmd.Def.GetConstraint()
if con == nil {
continue
}
if con.Contype == pg_query.ConstrType_CONSTR_FOREIGN {
fk, err := parseInlineForeignKey(con, schema, as.Relation.Relname, defaultSchema)
if err != nil {
return nil, nil, err
}
fks = append(fks, fk)
continue
}
// Non-FK constraint (PRIMARY KEY, UNIQUE, CHECK, etc.)
constraint, err := parseTableConstraint(con, as.Relation.Relname)
if err != nil {
return nil, nil, err
}
constraints = append(constraints, constraint)
}
return constraints, fks, nil
}
// parseInlineForeignKey builds a ForeignKey from a FOREIGN KEY constraint
// written inside CREATE TABLE or added by ALTER TABLE.
func parseInlineForeignKey(con *pg_query.Constraint, schema, table, defaultSchema string) (*model.ForeignKey, error) {
cols := fkAttrCols(con)
if con.Conname == "" {
con.Conname = autoNameConstraint(table, cols, con.Contype)
}
def, err := deparseConstraintDef(con)
if err != nil {
return nil, fmt.Errorf("failed to deparse constraint %s: %w", con.Conname, err)
}
var refSchema, refTable *string
if con.Pktable != nil {
rs := con.Pktable.Schemaname
if rs == "" {
rs = defaultSchema
}
refSchema = &rs
rt := con.Pktable.Relname
refTable = &rt
}
return &model.ForeignKey{
Name: con.Conname,
Type: model.ConstraintType('f'),
Definition: def,
Columns: cols,
Deferrable: con.Deferrable,
Deferred: con.Initdeferred,
Validated: !con.SkipValidation,
Schema: schema,
Table: table,
RefSchema: refSchema,
RefTable: refTable,
}, nil
}
// fillFKRefColumns fills in the referenced columns of a foreign key that
// leaves them out, taking the referenced table's primary key as PostgreSQL
// does. The catalog prints those columns, so a key without them never matches
// it. This runs after every statement is read, since the referenced table or
// its primary key may come later. A bare table name is looked up in the first
// target schema and then in the key's own, the two schemas the diff matches a
// bare name against. A key to a table the schema does not declare, or to one
// with no primary key, is left as written.
func fillFKRefColumns(tables *orderedmap.Map[string, *model.Table]) error {
for t := range tables.Values() {
for fk := range t.ForeignKeys.Values() {
con := pgast.ParseConstraintDef(fk.Definition)
if con == nil || len(con.PkAttrs) > 0 {
continue
}
ref, ok := tables.GetOk(model.Ident(*fk.RefSchema, *fk.RefTable))
if !ok && con.Pktable.Schemaname == "" {
ref, ok = tables.GetOk(model.Ident(t.Schema, *fk.RefTable))
}
if !ok {
continue
}
pkCols := primaryKeyColumns(ref)
if len(pkCols) == 0 {
continue
}
for _, col := range pkCols {
con.PkAttrs = append(con.PkAttrs, pg_query.MakeStrNode(col))
}
def, err := deparseConstraintDef(con)
if err != nil {
return fmt.Errorf("failed to deparse constraint %s: %w", fk.Name, err)
}
fk.Definition = def
}
}
return nil
}
// primaryKeyColumns returns the columns of a table's primary key, or nil when
// it has none. A key written USING INDEX lists no columns, so they are read
// from the index it takes over.
func primaryKeyColumns(t *model.Table) []string {
for con := range t.Constraints.Values() {
if !con.Type.IsPrimaryKeyConstraint() {
continue
}
if con.IndexName == "" {
return con.Columns
}
idx, ok := t.Indexes.GetOk(con.IndexName)
if !ok {
return nil
}
result, err := pg_query.Parse(idx.Definition)
if err != nil {
return nil
}
var cols []string
for _, param := range result.Stmts[0].Stmt.GetIndexStmt().GetIndexParams() {
cols = append(cols, param.GetIndexElem().GetName())
}
return cols
}
return nil
}
// Deparse helpers
func deparseTypeName(tn *pg_query.TypeName) (string, error) {
// pg_query's deparse places typmod after "with/without time zone" for
// timestamp/time variants, producing invalid SQL like
// "timestamp without time zone(6)". Format these four types directly
// from the AST so the precision lands in the right spot.
if s, ok := formatTimeTypeName(tn); ok {
return s, nil
}
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{
Node: &pg_query.Node_CreateStmt{
CreateStmt: &pg_query.CreateStmt{
Relation: pg_query.MakeSimpleRangeVar("_t", 0),
TableElts: []*pg_query.Node{
pg_query.MakeSimpleColumnDefNode("_c", tn, nil, 0),
},
},
},
},
}},
}
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse type name: %w", err)
}
const marker = "_c "
_, rest, ok := strings.Cut(sql, marker)
if !ok {
return "", fmt.Errorf("unexpected deparse output for type: %s", sql)
}
typeName, _, ok := strings.CutLast(rest, ")")
if !ok {
return "", fmt.Errorf("unexpected deparse output for type: %s", sql)
}
typeName = strings.TrimSpace(typeName)
// pg_query may qualify built-in types with "pg_catalog." (e.g. json -> pg_catalog.json).
// Strip the prefix so the result matches format_type() output.
typeName = strings.TrimPrefix(typeName, "pg_catalog.")
return normalizeTypeName(typeName), nil
}
func formatTimeTypeName(tn *pg_query.TypeName) (string, bool) {
if len(tn.Names) == 0 || len(tn.Names) > 2 {
return "", false
}
if len(tn.Names) == 2 {
q := tn.Names[0].GetString_()
if q == nil || q.GetSval() != "pg_catalog" {
return "", false
}
}
last := tn.Names[len(tn.Names)-1].GetString_()
if last == nil {
return "", false
}
var bare, zone string
switch last.GetSval() {
case "timestamp":
bare, zone = "timestamp", "without time zone"
case "timestamptz":
bare, zone = "timestamp", "with time zone"
case "time":
bare, zone = "time", "without time zone"
case "timetz":
bare, zone = "time", "with time zone"
default:
return "", false
}
prec := ""
if len(tn.Typmods) > 0 {
c := tn.Typmods[0].GetAConst()
if c == nil {
return "", false
}
ival := c.GetIval()
if ival == nil {
return "", false
}
prec = fmt.Sprintf("(%d)", ival.GetIval())
}
var arr strings.Builder
for _, b := range tn.ArrayBounds {
// pg_query encodes "[]" as Ival=-1; positive values are explicit
// array bounds like "[3]". Anything else (e.g. a non-Integer node)
// means we don't know how to format it; fall back to deparse.
i := b.GetInteger()
if i == nil {
return "", false
}
if i.GetIval() < 0 {
arr.WriteString("[]")
} else {
fmt.Fprintf(&arr, "[%d]", i.GetIval())
}
}
return bare + prec + " " + zone + arr.String(), true
}
var typeAliases = map[string]string{
"int": "integer",
"int4": "integer",
"int2": "smallint",
"int8": "bigint",
"serial2": "smallserial",
"serial4": "serial",
"serial8": "bigserial",
"float4": "real",
"float8": "double precision",
"bool": "boolean",
"varchar": "character varying",
"char": "character",
"timestamp": "timestamp without time zone",
"timestamptz": "timestamp with time zone",
"time": "time without time zone",
"timetz": "time with time zone",
"varbit": "bit varying",
"decimal": "numeric",
"float": "double precision",
}
func normalizeTypeName(name string) string {
// Handle types with modifiers like "varchar(255)" -> "character varying(255)"
base := name
suffix := ""
if idx := strings.Index(name, "("); idx != -1 {
base = name[:idx]
suffix = name[idx:]
} else if idx := strings.Index(name, "["); idx != -1 {
base = name[:idx]
suffix = name[idx:]
}
// Normalize spacing in type modifiers: "numeric(10, 2)" -> "numeric(10,2)"
suffix = strings.ReplaceAll(suffix, ", ", ",")
if canonical, ok := typeAliases[base]; ok {
base = canonical
}
// The array marker is separated so a modifier on an array type is reached:
// numeric(5)[] is stored as numeric(5,0)[].
mod, array := splitTypeSuffix(suffix)
if base == "numeric" {
mod = fillNumericScale(mod)
}
return base + mod + array
}
// splitTypeSuffix separates a type's modifier from its array marker:
// "(10,2)[]" becomes "(10,2)" and "[]".
func splitTypeSuffix(suffix string) (mod, array string) {
if strings.HasPrefix(suffix, "(") {
if end := strings.Index(suffix, ")"); end != -1 {
return suffix[:end+1], suffix[end+1:]
}
return suffix, ""
}
return "", suffix
}
// fillNumericScale writes the scale a numeric modifier leaves out. PostgreSQL
// takes "numeric(5)" as "numeric(5,0)" and format_type prints both digits, so
// without this the declaration and the catalog never compare equal.
func fillNumericScale(mod string) string {
if mod == "" || strings.Contains(mod, ",") {
return mod
}
precision := strings.TrimSuffix(strings.TrimPrefix(mod, "("), ")")
if precision == "" {
return mod
}
for _, r := range precision {
if r < '0' || r > '9' {
return mod
}
}
return "(" + precision + ",0)"
}
func deparseExpr(node *pg_query.Node) (string, error) {
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{
Node: &pg_query.Node_SelectStmt{
SelectStmt: &pg_query.SelectStmt{
TargetList: []*pg_query.Node{
pg_query.MakeResTargetNodeWithVal(node, 0),
},
},
},
},
}},
}
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse expression: %w", err)
}
expr, ok := strings.CutPrefix(sql, "SELECT ")
if !ok {
return "", fmt.Errorf("unexpected deparse output for expression: %s", sql)
}
return strings.TrimSpace(expr), nil
}
// parenthesizeDefault wraps a deparsed column or domain DEFAULT expression in
// parentheses when it does not stand there on its own. The grammar takes a
// restricted expression after DEFAULT, and deparseExpr renders a SELECT
// target, so AT TIME ZONE, AND and the like come out without the parentheses
// DEFAULT needs. A trailing COLLATE parses, but as the column's collation
// rather than part of the default. Any other expression is left as
// deparseExpr writes it.
//
// The answer is kept per expression, since most tables repeat the same few
// defaults and each check is a parse through cgo.
func parenthesizeDefault(def string) string {
if v, ok := parenthesizedDefaults.Load(def); ok {
return v.(string)
}
v := def
tree, err := pg_query.Parse("CREATE TABLE t (c int DEFAULT " + def + ")")
if err != nil || tree.Stmts[0].Stmt.GetCreateStmt().TableElts[0].GetColumnDef().CollClause != nil {
v = "(" + def + ")"
}
parenthesizedDefaults.Store(def, v)
return v
}
var parenthesizedDefaults sync.Map
func deparseConstraintDef(con *pg_query.Constraint) (string, error) {
// Temporarily clear SkipValidation so "NOT VALID" is not included in the
// definition string (it is tracked separately via the Validated field).
origSkipValidation := con.SkipValidation
con.SkipValidation = false
defer func() { con.SkipValidation = origSkipValidation }()
// Clear the name too, so the definition is whatever follows the fixed
// "ALTER TABLE _t ADD " prefix. Looking for the name in the output fails
// when the deparser quotes it and model.Ident does not, as with "time".
origConname := con.Conname
con.Conname = ""
defer func() { con.Conname = origConname }()
alterCmd := &pg_query.AlterTableCmd{
Subtype: pg_query.AlterTableType_AT_AddConstraint,
Def: &pg_query.Node{Node: &pg_query.Node_Constraint{Constraint: con}},
}
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{
Node: &pg_query.Node_AlterTableStmt{
AlterTableStmt: &pg_query.AlterTableStmt{
Relation: pg_query.MakeSimpleRangeVar("_t", 0),
Cmds: []*pg_query.Node{{
Node: &pg_query.Node_AlterTableCmd{AlterTableCmd: alterCmd},
}},
Objtype: pg_query.ObjectType_OBJECT_TABLE,
},
},
},
}},
}
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse constraint: %w", err)
}
def, ok := strings.CutPrefix(sql, "ALTER TABLE _t ADD ")
if !ok {
return "", fmt.Errorf("could not extract constraint definition from: %s", sql)
}
return strings.TrimSpace(def), nil
}
func deparsePartitionSpec(cs *pg_query.CreateStmt) (string, error) {
minCS := &pg_query.CreateStmt{
Relation: pg_query.MakeSimpleRangeVar("_t", 0),
TableElts: []*pg_query.Node{
pg_query.MakeSimpleColumnDefNode("_c", &pg_query.TypeName{
Names: []*pg_query.Node{pg_query.MakeStrNode("integer")},
}, nil, 0),
},
Partspec: cs.Partspec,
}
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{Node: &pg_query.Node_CreateStmt{CreateStmt: minCS}},
}},
}
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse partition spec: %w", err)
}
const prefix = "PARTITION BY "
_, after, ok := strings.Cut(sql, prefix)
if !ok {
return "", fmt.Errorf("could not extract partition spec from: %s", sql)
}
return strings.TrimSpace(after), nil
}
func deparsePartitionBound(cs *pg_query.CreateStmt) (string, error) {
minCS := &pg_query.CreateStmt{
Relation: pg_query.MakeSimpleRangeVar("_t", 0),
InhRelations: []*pg_query.Node{
{Node: &pg_query.Node_RangeVar{RangeVar: pg_query.MakeSimpleRangeVar("_parent", 0)}},
},
Partbound: cs.Partbound,
}
result := &pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{Node: &pg_query.Node_CreateStmt{CreateStmt: minCS}},
}},
}
sql, err := pg_query.Deparse(result)
if err != nil {
return "", fmt.Errorf("failed to deparse partition bound: %w", err)
}
const prefix = "PARTITION OF _parent "
_, after, ok := strings.Cut(sql, prefix)
if !ok {
return "", fmt.Errorf("could not extract partition bound from: %s", sql)
}
return strings.TrimSpace(after), nil
}
// parseStorageParams reads the WITH clause of a CREATE TABLE. A parameter of
// the TOAST relation arrives with `toast` as its namespace and is keyed under
// the same `toast.` prefix the catalog reports it with.
func parseStorageParams(options []*pg_query.Node) *orderedmap.Map[string, string] {
params := make(map[string]string, len(options))
for _, o := range options {
de := o.GetDefElem()
name := de.GetDefname()
if ns := de.GetDefnamespace(); ns != "" {
name = ns + "." + name
}
// WITH (oids = false) is what every pg_dump before 12 writes on every
// table. PostgreSQL still accepts it in a CREATE TABLE and stores
// nothing for it, while ALTER TABLE ... SET rejects the name, so it is
// not a parameter. oids = true is rejected by the CREATE itself.
if name == "oids" {
continue
}
params[name] = storageParamValue(de)
}
return model.SortedStorageParams(params)
}
// storageParamValue reads a storage parameter's value as text. A parameter
// written without one asks for true, which is what PostgreSQL stores for it.
func storageParamValue(de *pg_query.DefElem) string {
switch n := de.GetArg().GetNode().(type) {
case *pg_query.Node_String_:
return n.String_.Sval
case *pg_query.Node_Integer:
return strconv.FormatInt(int64(n.Integer.Ival), 10)
case *pg_query.Node_Float:
return n.Float.Fval
case *pg_query.Node_TypeName:
// A value that reads as a bare identifier, `autovacuum_enabled = off`,
// arrives as a type name: the grammar accepts a type there. Its parts
// carry the word PostgreSQL stores.
var parts []string
for _, name := range n.TypeName.Names {
parts = append(parts, name.GetString_().GetSval())
}
return strings.Join(parts, ".")
default:
return "true"
}
}
// unnamedConstraints lists the constraints that a CREATE TABLE or an ALTER
// TABLE writes without a name. Parsing the statement fills in their names, so
// addAutoNamed reads them afterwards.
func unnamedConstraints(stmt any) []*pg_query.Constraint {
var nodes []*pg_query.Node
switch s := stmt.(type) {
case *pg_query.CreateStmt:
for _, elt := range s.TableElts {
if cd := elt.GetColumnDef(); cd != nil {
nodes = append(nodes, cd.Constraints...)
} else {
nodes = append(nodes, elt)
}
}
case *pg_query.AlterTableStmt:
for _, cmd := range s.Cmds {
if c := cmd.GetAlterTableCmd(); c != nil && c.Subtype == pg_query.AlterTableType_AT_AddConstraint {
nodes = append(nodes, c.Def)
}
}
}
var unnamed []*pg_query.Constraint
for _, n := range nodes {
if con := n.GetConstraint(); con != nil && con.Conname == "" {
unnamed = append(unnamed, con)
}
}
return unnamed
}
// addAutoNamed records the constraints of fqtn that unnamedConstraints found
// and parsing named. NOT NULL and the other column attributes stay unnamed
// and are skipped.
func addAutoNamed(autoNamed map[AutoNamedObject]bool, fqtn string, unnamed []*pg_query.Constraint) {
for _, con := range unnamed {
if con.Conname != "" {
autoNamed[AutoNamedObject{Table: fqtn, Name: con.Conname}] = true
}
}
}
// addCreateTablePositions records where a table, its columns and its foreign
// keys are declared. A foreign key written as a table constraint is placed at
// its first keyword. parseCreateStmt has named an unnamed one by then. A key
// written on a column is placed at the column, and any other at the table.
func addCreateTablePositions(add func(LintTarget, int32), cs *pg_query.CreateStmt, table *model.Table, stmtOffset int32) {
fqtn := table.FQTN()
add(LintTarget{Kind: LintTable, Table: fqtn}, stmtOffset)
namedFKs := map[string]int32{}
columnFKs := map[string]int32{}
for _, elt := range cs.TableElts {
if cd := elt.GetColumnDef(); cd != nil {
add(LintTarget{Kind: LintColumn, Table: fqtn, Name: cd.Colname}, cd.Location)
for _, c := range cd.Constraints {
if con := c.GetConstraint(); con != nil && con.Contype == pg_query.ConstrType_CONSTR_FOREIGN {
columnFKs[cd.Colname] = cd.Location
}
}
} else if con := elt.GetConstraint(); con != nil && con.Contype == pg_query.ConstrType_CONSTR_FOREIGN && con.Conname != "" {
namedFKs[con.Conname] = con.Location
}
}
for name, fk := range table.ForeignKeys.All() {
offset := stmtOffset
if loc, ok := namedFKs[name]; ok {
offset = loc
} else if len(fk.Columns) == 1 {
if loc, ok := columnFKs[fk.Columns[0]]; ok {
offset = loc
}
}
add(LintTarget{Kind: LintForeignKey, Table: fqtn, Name: name}, offset)
}
}
// addInlineLintIgnores gives the rule names of each -- pista:lint-ignore line
// in a CREATE TABLE to the element written after it. A directive before a
// column also applies to a foreign key written on that column, since lint
// reports that key at the column. parseCreateStmt has named every foreign key
// by then, so an unnamed one is found too.
func addInlineLintIgnores(add func([]LintTarget, []string), cs *pg_query.CreateStmt, table *model.Table, byOffset map[int32][]string) {
fqtn := table.FQTN()
for _, elt := range cs.TableElts {
if cd := elt.GetColumnDef(); cd != nil {
targets := []LintTarget{{Kind: LintColumn, Table: fqtn, Name: cd.Colname}}
for _, c := range cd.Constraints {
if con := c.GetConstraint(); con != nil && con.Contype == pg_query.ConstrType_CONSTR_FOREIGN {
targets = append(targets, LintTarget{Kind: LintForeignKey, Table: fqtn, Name: con.Conname})
}
}
add(targets, byOffset[cd.Location])
} else if con := elt.GetConstraint(); con != nil && con.Contype == pg_query.ConstrType_CONSTR_FOREIGN {
add([]LintTarget{{Kind: LintForeignKey, Table: fqtn, Name: con.Conname}}, byOffset[con.Location])
}
}
}
package parser
import (
"errors"
"fmt"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// applyAlterTableRLS scans an ALTER TABLE statement for row-level security
// toggle subcommands (ENABLE / DISABLE / FORCE / NO FORCE) and applies them
// to the table's flags. Non-RLS subcommands are ignored, so the caller can
// invoke parseAlterTableConstraints on the same statement to pick up any
// AT_AddConstraint subcommands; PostgreSQL allows mixing the two in one
// ALTER TABLE.
func applyAlterTableRLS(as *pg_query.AlterTableStmt, t *model.Table) {
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil {
continue
}
switch cmd.Subtype {
case pg_query.AlterTableType_AT_EnableRowSecurity:
t.RowSecurity = true
case pg_query.AlterTableType_AT_DisableRowSecurity:
t.RowSecurity = false
case pg_query.AlterTableType_AT_ForceRowSecurity:
t.ForceRowSecurity = true
case pg_query.AlterTableType_AT_NoForceRowSecurity:
t.ForceRowSecurity = false
}
}
}
// parseCreatePolicyStmt converts a CreatePolicyStmt into a model.Policy and
// attaches it to the owning table. Returns the created policy so the caller
// can apply post-parse decorations (e.g. RenameFrom). The table must already
// exist in `tables`, otherwise an error is returned. offset is where the
// statement starts, which a duplicate-name error points at.
func parseCreatePolicyStmt(
cps *pg_query.CreatePolicyStmt,
defaultSchema string,
tables *orderedmap.Map[string, *model.Table],
offset int32,
) (*model.Policy, error) {
if cps.Table == nil {
return nil, errors.New("CREATE POLICY: missing table reference")
}
schema := cps.Table.Schemaname
if schema == "" {
schema = defaultSchema
}
fqtn := model.Ident(schema, cps.Table.Relname)
tbl, ok := tables.GetOk(fqtn)
if !ok {
return nil, undeclared("CREATE POLICY "+cps.PolicyName, "table", fqtn, offset)
}
cmd, err := parsePolicyCommand(cps.CmdName)
if err != nil {
return nil, fmt.Errorf("CREATE POLICY %s: %w", cps.PolicyName, err)
}
roles, err := parsePolicyRoles(cps.Roles)
if err != nil {
return nil, fmt.Errorf("CREATE POLICY %s: %w", cps.PolicyName, err)
}
policy := &model.Policy{
Name: cps.PolicyName,
Schema: schema,
Table: cps.Table.Relname,
Permissive: cps.Permissive,
Command: cmd,
Roles: roles,
}
if cps.Qual != nil {
using, err := deparseExpr(cps.Qual)
if err != nil {
return nil, fmt.Errorf("CREATE POLICY %s: failed to deparse USING: %w", cps.PolicyName, err)
}
policy.Using = &using
}
if cps.WithCheck != nil {
wc, err := deparseExpr(cps.WithCheck)
if err != nil {
return nil, fmt.Errorf("CREATE POLICY %s: failed to deparse WITH CHECK: %w", cps.PolicyName, err)
}
policy.WithCheck = &wc
}
if err := setUnique(tbl.Policies, policy.Name, "policy", policy, fqtn, offset); err != nil {
return nil, err
}
return policy, nil
}
// parsePolicyCommand maps the parser-emitted command name to the catalog
// representation in pg_policy.polcmd.
func parsePolicyCommand(name string) (model.PolicyCommand, error) {
switch name {
case "", "all":
return '*', nil
case "select":
return 'r', nil
case "insert":
return 'a', nil
case "update":
return 'w', nil
case "delete":
return 'd', nil
default:
return 0, fmt.Errorf("unsupported policy command: %s", name)
}
}
// parsePolicyRoles converts a list of RoleSpec nodes into role name strings.
// Reserved role specifiers (CURRENT_USER, CURRENT_ROLE, SESSION_USER, PUBLIC)
// are kept lower-cased so the diff layer can reliably compare them.
func parsePolicyRoles(nodes []*pg_query.Node) ([]string, error) {
if len(nodes) == 0 {
return nil, nil
}
roles := make([]string, 0, len(nodes))
for _, n := range nodes {
rs := n.GetRoleSpec()
if rs == nil {
return nil, errors.New("expected RoleSpec in role list")
}
switch rs.Roletype {
case pg_query.RoleSpecType_ROLESPEC_PUBLIC:
// PostgreSQL ignores the other roles in a list that names PUBLIC.
return []string{"public"}, nil
case pg_query.RoleSpecType_ROLESPEC_CURRENT_USER:
roles = append(roles, "current_user")
case pg_query.RoleSpecType_ROLESPEC_CURRENT_ROLE:
roles = append(roles, "current_role")
case pg_query.RoleSpecType_ROLESPEC_SESSION_USER:
roles = append(roles, "session_user")
default:
roles = append(roles, rs.Rolename)
}
}
return roles, nil
}
package parser
import (
"errors"
"fmt"
"strconv"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// ErrUnsupportedRoutine marks a CREATE FUNCTION / PROCEDURE statement that
// pistachio reads but does not manage. The parser turns it into the same
// "ignored unsupported statement" warning any other unhandled statement gets,
// and the catalog skips the row rather than failing the whole read, so both
// sides of the diff leave the same routines out. Never fail a parse over one:
// a routine the desired schema declares is read whether or not
// --manage-routine is set, so an error here would reach someone who never
// asked for routines at all.
var ErrUnsupportedRoutine = errors.New("unsupported routine")
// errRoutineConfigFromCurrent marks SET x FROM CURRENT, which captures the
// session value at creation time. The statement does not carry that value, so
// there is nothing to compare against.
//
// This is not ErrUnsupportedRoutine, because the invariant that one rests on
// does not hold here: pg_get_functiondef writes the resolved value, so the
// catalog reads such a routine back like any other. Leaving it out of the
// desired side alone would plan a DROP of a routine the schema file declares.
// The routine is marked Ignore instead, which takes the key off both sides and
// surfaces it as -- ignored:, the same as -- pista:ignore.
var errRoutineConfigFromCurrent = errors.New("routine config from current")
// Default COST and ROWS per pg_proc. pg_get_functiondef prints neither when
// the routine carries the default, so the parser drops them as well.
const (
defaultCostC = 1
defaultCostOther = 100
defaultRowsSet = 1000
)
// ParseRoutineDef builds a Routine from a single CREATE FUNCTION or
// CREATE PROCEDURE statement. The catalog reads pg_get_functiondef and calls
// this, so both sides of the diff go through one code path and render through
// one model.Routine.SQL.
func ParseRoutineDef(sql, defaultSchema string) (*model.Routine, error) {
result, err := pg_query.Parse(sql)
if err != nil {
return nil, fmt.Errorf("failed to parse routine definition: %w", err)
}
if len(result.Stmts) != 1 {
return nil, fmt.Errorf("expected one statement in routine definition, got %d", len(result.Stmts))
}
cfs := result.Stmts[0].Stmt.GetCreateFunctionStmt()
if cfs == nil {
return nil, errors.New("routine definition is not a CREATE FUNCTION statement")
}
return parseCreateFunctionStmt(cfs, defaultSchema)
}
func parseCreateFunctionStmt(cfs *pg_query.CreateFunctionStmt, defaultSchema string) (*model.Routine, error) {
schema, name, err := splitFuncName(cfs.Funcname, defaultSchema)
if err != nil {
return nil, err
}
routine := &model.Routine{
Schema: schema,
Name: name,
Procedure: cfs.IsProcedure,
}
if err := parseRoutineParams(cfs.Parameters, routine); err != nil {
return nil, err
}
if err := parseRoutineReturn(cfs.ReturnType, routine); err != nil {
return nil, err
}
if err := parseRoutineOptions(cfs.Options, routine); err != nil {
return nil, err
}
if cfs.SqlBody != nil {
body, err := deparseSQLBody(cfs.SqlBody)
if err != nil {
return nil, err
}
routine.SQLBody = body
}
normalizeRoutineDefaults(routine)
return routine, nil
}
func splitFuncName(names []*pg_query.Node, defaultSchema string) (string, string, error) {
parts := make([]string, 0, len(names))
for _, n := range names {
s := n.GetString_()
if s == nil {
return "", "", errors.New("unexpected routine name component")
}
parts = append(parts, s.GetSval())
}
switch len(parts) {
case 1:
return defaultSchema, parts[0], nil
case 2:
return parts[0], parts[1], nil
default:
return "", "", fmt.Errorf("unexpected routine name: %s", strings.Join(parts, "."))
}
}
var paramModes = map[pg_query.FunctionParameterMode]string{
pg_query.FunctionParameterMode_FUNC_PARAM_IN: "",
pg_query.FunctionParameterMode_FUNC_PARAM_DEFAULT: "",
pg_query.FunctionParameterMode_FUNC_PARAM_OUT: "OUT",
pg_query.FunctionParameterMode_FUNC_PARAM_INOUT: "INOUT",
pg_query.FunctionParameterMode_FUNC_PARAM_VARIADIC: "VARIADIC",
pg_query.FunctionParameterMode_FUNC_PARAM_TABLE: "TABLE",
}
func parseRoutineParams(params []*pg_query.Node, routine *model.Routine) error {
for _, p := range params {
fp := p.GetFunctionParameter()
if fp == nil {
return errors.New("unexpected routine parameter")
}
mode, ok := paramModes[fp.Mode]
if !ok {
return fmt.Errorf("unsupported routine parameter mode: %s", fp.Mode)
}
typeName, err := deparseTypeName(fp.ArgType)
if err != nil {
return err
}
arg := &model.RoutineArg{
Mode: mode,
Name: fp.Name,
// PostgreSQL discards a type modifier on a parameter, so the
// catalog never reports one back. Drop it here as well or a
// desired varchar(10) would differ from the catalog's varchar.
Type: stripTypeMod(typeName),
}
if fp.Defexpr != nil {
def, err := deparseExpr(fp.Defexpr)
if err != nil {
return err
}
arg.Default = def
}
routine.Args = append(routine.Args, arg)
}
return nil
}
// stripTypeMod removes a type modifier while keeping any array bounds:
// "numeric(10,2)[]" becomes "numeric[]".
func stripTypeMod(typeName string) string {
open := strings.Index(typeName, "(")
if open < 0 {
return typeName
}
closeIdx := strings.Index(typeName[open:], ")")
if closeIdx < 0 {
return typeName
}
return typeName[:open] + typeName[open+closeIdx+1:]
}
func parseRoutineReturn(returnType *pg_query.TypeName, routine *model.Routine) error {
if routine.Procedure {
return nil
}
if returnType != nil {
// SETOF is tracked separately, so clear it for the deparse: it renders
// as part of the type name and would come back as "SETOF integer".
setof := returnType.Setof
returnType.Setof = false
name, err := deparseTypeName(returnType)
returnType.Setof = setof
if err != nil {
return err
}
routine.ReturnType = stripTypeMod(name)
routine.ReturnsSet = setof
return nil
}
// No RETURNS clause. PostgreSQL derives the result from the OUT and INOUT
// parameters, and pg_get_functiondef writes that derived type back, so the
// desired side has to derive it the same way.
var outArgs []*model.RoutineArg
for _, a := range routine.Args {
if a.Mode == "OUT" || a.Mode == "INOUT" {
outArgs = append(outArgs, a)
}
}
switch len(outArgs) {
case 0:
return fmt.Errorf("function %s has no RETURNS clause and no OUT parameter", model.Ident(routine.Schema, routine.Name))
case 1:
routine.ReturnType = outArgs[0].Type
default:
routine.ReturnType = "record"
}
return nil
}
func parseRoutineOptions(options []*pg_query.Node, routine *model.Routine) error {
for _, o := range options {
de := o.GetDefElem()
if de == nil {
return errors.New("unexpected routine option")
}
switch de.Defname {
case "as":
if err := parseRoutineBody(de.Arg, routine); err != nil {
return err
}
case "language":
routine.Language = defElemString(de.Arg)
case "volatility":
routine.Volatility = strings.ToUpper(defElemString(de.Arg))
case "strict":
routine.Strict = defElemBool(de.Arg)
case "security":
routine.SecurityDefiner = defElemBool(de.Arg)
case "leakproof":
routine.Leakproof = defElemBool(de.Arg)
case "parallel":
routine.Parallel = strings.ToUpper(defElemString(de.Arg))
case "cost":
f, err := defElemFloat(de.Arg)
if err != nil {
return err
}
routine.Cost = &f
case "rows":
f, err := defElemFloat(de.Arg)
if err != nil {
return err
}
routine.Rows = &f
case "set":
cfg, err := parseRoutineConfig(de.Arg)
if errors.Is(err, errRoutineConfigFromCurrent) {
routine.Ignore = true
continue
}
if err != nil {
return err
}
routine.Config = append(routine.Config, cfg)
case "window":
// prokind 'w'. The catalog does not read window functions, so the
// desired side leaves them alone too.
return ErrUnsupportedRoutine
default:
// SUPPORT and TRANSFORM FOR TYPE reach here, and pg_get_functiondef
// writes both back, so the catalog skips the same routines.
return fmt.Errorf("%w: option %s", ErrUnsupportedRoutine, de.Defname)
}
}
return nil
}
func parseRoutineBody(arg *pg_query.Node, routine *model.Routine) error {
items := arg.GetList().GetItems()
switch len(items) {
case 1:
routine.Body = items[0].GetString_().GetSval()
case 2:
// LANGUAGE c: AS 'obj_file', 'link_symbol'.
routine.ObjFile = items[0].GetString_().GetSval()
routine.Body = items[1].GetString_().GetSval()
default:
return fmt.Errorf("unexpected routine body with %d parts", len(items))
}
return nil
}
// deparseSQLBody renders a SQL-standard body, BEGIN ATOMIC or RETURN. Each
// statement of a BEGIN ATOMIC block goes on a line of its own, so a dump
// reads the way pg_get_functiondef lays the body out.
func deparseSQLBody(body *pg_query.Node) (string, error) {
if body.GetReturnStmt() != nil {
// A RETURN statement cannot be deparsed on its own, so it is taken out
// of a CREATE FUNCTION that carries nothing else.
const prefix = "CREATE FUNCTION _f() "
out, err := pg_query.Deparse(&pg_query.ParseResult{Version: pgast.ParseTreeVersion, Stmts: []*pg_query.RawStmt{{
Stmt: &pg_query.Node{Node: &pg_query.Node_CreateFunctionStmt{CreateFunctionStmt: &pg_query.CreateFunctionStmt{
Funcname: []*pg_query.Node{pg_query.MakeStrNode("_f")},
SqlBody: body,
}}},
}}})
if err != nil {
return "", fmt.Errorf("failed to deparse routine body: %w", err)
}
return strings.TrimPrefix(out, prefix), nil
}
// BEGIN ATOMIC arrives as a list holding the list of statements.
lines := []string{"BEGIN ATOMIC"}
for _, item := range body.GetList().GetItems() {
for _, stmt := range item.GetList().GetItems() {
out, err := pg_query.Deparse(&pg_query.ParseResult{Version: pgast.ParseTreeVersion, Stmts: []*pg_query.RawStmt{{Stmt: stmt}}})
if err != nil {
return "", fmt.Errorf("failed to deparse routine body: %w", err)
}
lines = append(lines, " "+out+";")
}
}
lines = append(lines, "END")
return strings.Join(lines, "\n"), nil
}
func parseRoutineConfig(arg *pg_query.Node) (*model.RoutineConfig, error) {
vss := arg.GetVariableSetStmt()
if vss == nil {
return nil, errors.New("unexpected SET clause on routine")
}
if len(vss.Args) == 0 {
return nil, fmt.Errorf("%w: SET %s", errRoutineConfigFromCurrent, vss.Name)
}
// GUC names are case-insensitive, and PostgreSQL keeps its own spelling:
// pg_get_functiondef writes SET "TimeZone" for a routine created with
// SET timezone. Both sides reach this function, so folding the name here
// makes a written name and a read-back one compare equal.
cfg := &model.RoutineConfig{Name: strings.ToLower(vss.Name)}
for _, a := range vss.Args {
c := a.GetAConst()
if c == nil {
return nil, fmt.Errorf("unexpected SET value on routine %s", vss.Name)
}
switch {
case c.GetSval() != nil:
cfg.Args = append(cfg.Args, c.GetSval().GetSval())
case c.GetIval() != nil:
cfg.Args = append(cfg.Args, strconv.FormatInt(int64(c.GetIval().GetIval()), 10))
case c.GetFval() != nil:
cfg.Args = append(cfg.Args, c.GetFval().GetFval())
default:
return nil, fmt.Errorf("unexpected SET value on routine %s", vss.Name)
}
}
return cfg, nil
}
// The generated getters take a nil receiver, so a non-String (non-Boolean)
// argument falls through to the proto zero value without a guard here.
func defElemString(arg *pg_query.Node) string {
return arg.GetString_().GetSval()
}
func defElemBool(arg *pg_query.Node) bool {
return arg.GetBoolean().GetBoolval()
}
func defElemFloat(arg *pg_query.Node) (float64, error) {
switch {
case arg.GetInteger() != nil:
return float64(arg.GetInteger().GetIval()), nil
case arg.GetFloat() != nil:
return strconv.ParseFloat(arg.GetFloat().GetFval(), 64)
}
return 0, errors.New("unexpected numeric routine option")
}
// normalizeRoutineDefaults drops the attribute values pg_get_functiondef
// leaves out, so a desired schema that spells them out matches the catalog.
func normalizeRoutineDefaults(routine *model.Routine) {
// A SQL-standard body implies LANGUAGE sql, which pg_get_functiondef
// writes out.
if routine.Atomic() && routine.Language == "" {
routine.Language = "sql"
}
if routine.Volatility == "" {
routine.Volatility = model.VolatilityVolatile
}
if routine.Parallel == "" {
routine.Parallel = model.ParallelUnsafe
}
if routine.Cost != nil && *routine.Cost == defaultCost(routine.Language) {
routine.Cost = nil
}
if routine.Rows != nil && (!routine.ReturnsSet || *routine.Rows == defaultRowsSet) {
routine.Rows = nil
}
}
func defaultCost(language string) float64 {
switch language {
case "c", "internal":
return defaultCostC
default:
return defaultCostOther
}
}
// parseCommentOnRoutine attaches a COMMENT ON FUNCTION / PROCEDURE to the
// routine it names. The statement carries an ObjectWithArgs rather than the
// plain name list every other COMMENT ON uses, and the argument list is what
// tells two overloads apart. PostgreSQL lets the list be left out when the
// name is unambiguous, and so does this: a bare name matches when the desired
// schema declares exactly one routine with it.
func parseCommentOnRoutine(cs *pg_query.CommentStmt, defaultSchema string, routines *orderedmap.Map[string, *model.Routine]) {
owa := cs.Object.GetObjectWithArgs()
if owa == nil {
return
}
schema, name, err := splitFuncName(owa.Objname, defaultSchema)
if err != nil {
return
}
routine := lookupRoutine(routines, schema, name, owa)
if routine == nil {
return
}
routine.Comment = commentPtr(cs.Comment)
}
func lookupRoutine(routines *orderedmap.Map[string, *model.Routine], schema, name string, owa *pg_query.ObjectWithArgs) *model.Routine {
if !owa.ArgsUnspecified {
types := make([]string, 0, len(owa.Objargs))
for _, a := range owa.Objargs {
tn := a.GetTypeName()
if tn == nil {
return nil
}
typeName, err := deparseTypeName(tn)
if err != nil {
return nil
}
// The same two steps FQRN applies, or a comment naming a type in
// the routine's own schema would key as a different routine and
// silently fail to attach.
types = append(types, model.StripTypeSchema(stripTypeMod(typeName), model.Ident(schema)))
}
return routines.Get(model.Ident(schema, name) + "(" + strings.Join(types, ", ") + ")")
}
var found *model.Routine
for r := range routines.Values() {
if r.Schema != schema || r.Name != name {
continue
}
if found != nil {
// Ambiguous, the same way PostgreSQL would reject it.
return nil
}
found = r
}
return found
}
package parser
import (
"fmt"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// parseCreateTrigStmt converts a CreateTrigStmt into a model.Trigger. The
// relation is qualified with defaultSchema when the statement leaves it bare,
// so the stored definition names the same relation whatever search_path the
// plan runs under. OR REPLACE is dropped: it says how to reach the state, not
// what the state is, and the diff decides that.
func parseCreateTrigStmt(ct *pg_query.CreateTrigStmt, defaultSchema string) (*model.Trigger, error) {
if ct.Relation == nil {
return nil, fmt.Errorf("CREATE TRIGGER %s: missing table reference", ct.Trigname)
}
schema := ct.Relation.Schemaname
if schema == "" {
schema = defaultSchema
}
// The statement is deparsed from the node itself. Nothing reads the parse
// tree after this case, so the two fields are set in place rather than on
// a copy.
ct.Relation.Schemaname = schema
ct.Replace = false
def, err := pg_query.Deparse(&pg_query.ParseResult{
Version: pgast.ParseTreeVersion,
Stmts: []*pg_query.RawStmt{
{Stmt: &pg_query.Node{Node: &pg_query.Node_CreateTrigStmt{CreateTrigStmt: ct}}},
},
})
if err != nil {
return nil, fmt.Errorf("CREATE TRIGGER %s: failed to deparse: %w", ct.Trigname, err)
}
return &model.Trigger{
Schema: schema,
Table: ct.Relation.Relname,
Name: ct.Trigname,
Definition: def,
State: model.TriggerStateDefault,
}, nil
}
// attachTrigger files a parsed trigger under the table or view it is on. A
// trigger names its relation, so the relation has to be declared first, the
// way CREATE POLICY needs its table. offset is where the statement starts,
// which a duplicate-name error points at.
func attachTrigger(
trg *model.Trigger,
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
offset int32,
) error {
fqtn := trg.FQTN()
if t, ok := tables.GetOk(fqtn); ok {
return setUnique(t.Triggers, trg.Name, "trigger", trg, fqtn, offset)
}
if v, ok := views.GetOk(fqtn); ok {
return setUnique(v.Triggers, trg.Name, "trigger", trg, fqtn, offset)
}
return undeclared("CREATE TRIGGER "+trg.Name, "table or view", fqtn, offset)
}
// applyAlterTableTriggerState picks up the ENABLE / DISABLE TRIGGER
// subcommands and records the state on the named trigger. CREATE TRIGGER has
// no syntax for a disabled trigger, so this is the only way a desired schema
// can ask for one, and it is what dump writes. The ALL and USER forms name no
// single trigger and are left alone.
func applyAlterTableTriggerState(as *pg_query.AlterTableStmt, t *model.Table) {
for _, cmdNode := range as.Cmds {
cmd := cmdNode.GetAlterTableCmd()
if cmd == nil {
continue
}
var state model.TriggerState
switch cmd.Subtype {
case pg_query.AlterTableType_AT_EnableTrig:
state = model.TriggerStateDefault
case pg_query.AlterTableType_AT_DisableTrig:
state = 'D'
case pg_query.AlterTableType_AT_EnableAlwaysTrig:
state = 'A'
case pg_query.AlterTableType_AT_EnableReplicaTrig:
state = 'R'
default:
continue
}
// checkAlterTableTargets has refused a trigger the table does not
// declare, unless the table is ignored and so not checked.
if trg, ok := t.Triggers.GetOk(cmd.Name); ok {
trg.State = state
}
}
}
package parser
// undeclared reports stmt, which names a kind and name no statement before
// offset declares: a typo, or a file that comes too early. The parser reads
// the input in one pass, as PostgreSQL would run it, so the declaration has to
// come first. The error carries the statement's position.
func undeclared(stmt, kind, name string, offset int32) error {
return &locatedError{msg: stmt + ": " + kind + " " + name + " is not declared before it", offset: int(offset)}
}
package parser
import (
"errors"
"fmt"
"sync"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// validateColumnRefs returns an error if any column DEFAULT or GENERATED
// expression, index, constraint, or foreign-key definition on a desired table
// references a column that does not exist in that table's desired column set.
// All violations across all tables are aggregated via errors.Join so a single
// plan run reports every problem. Within a single object (column expression /
// index / constraint / FK), each missing column name is reported at most once
// so multiple references to the same name don't produce duplicate error
// lines.
//
// Scope: only same-table references are checked. Foreign-key referenced
// columns (PkAttrs, on the parent table) are out of scope. Tables that
// inherit columns from a parent (declarative partition children and
// INHERITS-style children) are skipped.
func validateColumnRefs(tables *orderedmap.Map[string, *model.Table]) error {
var errs []error
for fqtn, t := range tables.All() {
// Ignored tables are unmanaged, so their internal consistency is not
// checked.
if t.Ignore {
continue
}
// Skip both partition children (PartitionOf + PartitionBound set) and
// INHERITS-style children (PartitionOf set, PartitionBound nil); both
// inherit their columns from the parent rather than declaring their
// own complete column list.
if t.PartitionOf != nil {
continue
}
cols := make(map[string]bool, t.Columns.Len())
for name := range t.Columns.Keys() {
cols[name] = true
}
for name, col := range t.Columns.All() {
if col.Default == nil {
continue
}
kind := "DEFAULT"
if col.Generated.IsGeneratedColumn() {
kind = "GENERATED"
}
reportMissing(&errs, cols, collectColumnRefsInColumnExpr(*col.Default), func(ref string) error {
return fmt.Errorf("column %s referenced in the %s expression on column %s does not exist on table %s",
model.Ident(ref), kind, model.Ident(name), fqtn)
})
}
for _, idx := range t.Indexes.CollectValues() {
reportMissing(&errs, cols, collectColumnRefsInIndexDef(idx.Definition), func(ref string) error {
return fmt.Errorf("column %s referenced in index %s does not exist on table %s",
model.Ident(ref), model.Ident(idx.Name), fqtn)
})
}
for _, con := range t.Constraints.CollectValues() {
kind := constraintKindLabel(con.Type)
reportMissing(&errs, cols, collectColumnRefsInConstraintDef(con.Definition), func(ref string) error {
return fmt.Errorf("column %s referenced in %s %s does not exist on table %s",
model.Ident(ref), kind, model.Ident(con.Name), fqtn)
})
}
for _, fk := range t.ForeignKeys.CollectValues() {
reportMissing(&errs, cols, collectColumnRefsInFKDef(fk.Definition), func(ref string) error {
return fmt.Errorf("column %s referenced in foreign key %s does not exist on table %s",
model.Ident(ref), model.Ident(fk.Name), fqtn)
})
}
}
return errors.Join(errs...)
}
// validatePartitionCycles returns an error for each table that PARTITION OF
// or INHERITS makes its own ancestor. PostgreSQL refuses such a table, and the
// diff, which follows PartitionOf up to the root, would never finish. A table
// below a cycle but not in it is left to the tables in the cycle to report.
func validatePartitionCycles(tables *orderedmap.Map[string, *model.Table]) error {
var errs []error
for fqtn, t := range tables.All() {
seen := map[string]bool{fqtn: true}
for t.PartitionOf != nil {
parent := *t.PartitionOf
if parent == fqtn {
errs = append(errs, fmt.Errorf("circular inheritance: table %s is an ancestor of itself", fqtn))
break
}
if seen[parent] {
break
}
seen[parent] = true
next, ok := tables.GetOk(parent)
if !ok {
break
}
t = next
}
}
return errors.Join(errs...)
}
// reportMissing appends one error per distinct refs[i] that is not in cols,
// preserving first-encounter order so the aggregated error is deterministic.
func reportMissing(errs *[]error, cols map[string]bool, refs []string, mkErr func(string) error) {
seen := map[string]bool{}
for _, ref := range refs {
if cols[ref] || seen[ref] {
continue
}
seen[ref] = true
*errs = append(*errs, mkErr(ref))
}
}
func constraintKindLabel(ct model.ConstraintType) string {
switch {
case ct.IsCheckConstraint():
return "CHECK constraint"
case ct.IsPrimaryKeyConstraint():
return "PRIMARY KEY constraint"
case ct.IsUniqueConstraint():
return "UNIQUE constraint"
case ct.IsForeignKeyConstraint():
return "FOREIGN KEY constraint"
case ct.IsExclusionConstraint():
return "EXCLUDE constraint"
default:
return "constraint"
}
}
// collectColumnRefsInIndexDef returns the unqualified column names referenced
// by an index definition (IndexParams, IndexIncludingParams, WhereClause).
// Returns nil on parse errors so validation degrades to a no-op for
// unparsable definitions.
func collectColumnRefsInIndexDef(def string) []string {
result, err := pg_query.Parse(def)
if err != nil || len(result.Stmts) == 0 {
return nil
}
is := result.Stmts[0].Stmt.GetIndexStmt()
if is == nil {
return nil
}
var refs []string
collect := func(params []*pg_query.Node) {
for _, p := range params {
ie := p.GetIndexElem()
if ie == nil {
continue
}
if ie.Name != "" {
refs = append(refs, ie.Name)
}
refs = append(refs, walkExprColumnRefs(ie.Expr)...)
}
}
collect(is.IndexParams)
collect(is.IndexIncludingParams)
refs = append(refs, walkExprColumnRefs(is.WhereClause)...)
return refs
}
// collectColumnRefsInConstraintDef returns the unqualified column names
// referenced by a constraint definition fragment (Keys, Including, RawExpr,
// EXCLUDE Exclusions IndexElem).
func collectColumnRefsInConstraintDef(def string) []string {
con := pgast.ParseConstraintDef(def)
if con == nil {
return nil
}
var refs []string
collectStringList := func(nodes []*pg_query.Node) {
for _, n := range nodes {
if s := n.GetString_(); s != nil && s.Sval != "" {
refs = append(refs, s.Sval)
}
}
}
collectStringList(con.Keys)
collectStringList(con.Including)
refs = append(refs, walkExprColumnRefs(con.RawExpr)...)
for _, ex := range con.Exclusions {
list := ex.GetList()
if list == nil {
continue
}
for _, item := range list.Items {
ie := item.GetIndexElem()
if ie == nil {
continue
}
if ie.Name != "" {
refs = append(refs, ie.Name)
}
refs = append(refs, walkExprColumnRefs(ie.Expr)...)
}
}
return refs
}
// collectColumnRefsInFKDef returns the local-side column names (FkAttrs)
// referenced by a foreign-key definition. PkAttrs (parent-table columns) are
// intentionally excluded; cross-table validation is out of scope.
func collectColumnRefsInFKDef(def string) []string {
con := pgast.ParseConstraintDef(def)
if con == nil {
return nil
}
return fkAttrCols(con)
}
// collectColumnRefsInColumnExpr returns the unqualified column names
// referenced by a column DEFAULT or GENERATED expression, which is stored as
// a bare expression rather than a statement. Returns nil on parse errors so
// validation degrades to a no-op for unparsable expressions.
//
// A generated expression may reference only columns of its own table, so every
// name it carries belongs to the set checked here. A plain DEFAULT may
// reference no column at all, which PostgreSQL rejects on its own; the names
// it does carry are checked the same way rather than rejected outright.
//
// Only unqualified names are read, as they are for an index or a constraint.
// A generated expression may write one qualified, which the diff cannot
// compare at all; LIMITATIONS.md records that.
//
// The answer is kept per expression, since most tables repeat the same few
// defaults and each one is a parse through cgo. Callers must not modify the
// returned slice.
func collectColumnRefsInColumnExpr(expr string) []string {
if v, ok := columnExprRefs.Load(expr); ok {
return v.([]string)
}
var refs []string
if _, target, err := pgast.ParseExpr(expr); err == nil {
refs = walkExprColumnRefs(target.Val)
}
columnExprRefs.Store(expr, refs)
return refs
}
var columnExprRefs sync.Map
// walkExprColumnRefs returns the unqualified ColumnRef names found in an
// expression tree.
func walkExprColumnRefs(node *pg_query.Node) []string {
var refs []string
pgast.WalkExprColumnRefs(node, func(s *pg_query.String) {
if s.Sval != "" {
refs = append(refs, s.Sval)
}
})
return refs
}
// namespace tracks the names registered in one PostgreSQL catalog, mapping
// each name to the kind of object that claimed it. scope names the object the
// names belong to, for a catalog that keys them per object rather than per
// schema; it is empty otherwise.
type namespace struct {
catalog string
scope string
names map[string]string
}
func newNamespace(catalog string) *namespace {
return &namespace{catalog: catalog, names: map[string]string{}}
}
func newScopedNamespace(catalog, scope string) *namespace {
return &namespace{catalog: catalog, scope: scope, names: map[string]string{}}
}
func (ns *namespace) add(name, kind string) error {
if prev, ok := ns.names[name]; ok {
on := ""
if ns.scope != "" {
on = " on " + ns.scope
}
// Two objects of the same kind reach here when their own map is keyed
// by something narrower than the catalog: index names are unique per
// schema in PostgreSQL, but pistachio keys them per table. The message
// matches setUnique's, which reports the same clash within one table.
if prev == kind {
return fmt.Errorf("duplicate %s: %s%s", kind, name, on)
}
return fmt.Errorf("duplicate %s name: %s%s (%s and %s)", ns.catalog, name, on, prev, kind)
}
ns.names[name] = kind
return nil
}
// validateNamespaces returns an error when one name is claimed twice within a
// PostgreSQL catalog: by two objects in a schema, or by two members of a single
// object. Left in, the file fails partway through an apply with
// `relation "x" already exists`, `type "x" already exists`, or
// `constraint "c" for relation "t" already exists`.
//
// pg_class holds tables, views, materialized views, sequences, composite types
// and indexes. The indexes include the ones PostgreSQL builds for PRIMARY KEY,
// UNIQUE and EXCLUDE constraints, which take the constraint name; CHECK and
// foreign-key constraints build no index, so their names are not registered.
// pg_type holds a row for every table, view, materialized view and composite
// type, plus domains and enums. Sequences and indexes have no type entry.
//
// pg_constraint is unique on (conrelid, contypid, conname), so a constraint
// name is scoped to the table or the domain that owns it rather than to the
// schema, and each of those gets a namespace of its own. Every constraint kind
// shares it, including the CHECK and foreign-key names pg_class does not see.
//
// pg_attribute and pg_enum scope a composite type's attribute names and an
// enum's labels to that one type. Such a namespace only ever sees one kind, so
// its catalog label never reaches a message.
//
// setUnique cannot stand in for any of this. Constraints and ForeignKeys are
// separate maps, so it compares a constraint name only against the same kind,
// and Domain.Constraints, CompositeType.Attributes and Enum.Values are plain
// slices with no uniqueness check at all.
//
// Ignored objects are checked as well, since an unmanaged relation still
// occupies its name in the database. All violations are aggregated via
// errors.Join, one line per object.
func validateNamespaces(r *ParseResult) error {
var errs []error
relations := newNamespace("relation")
types := newNamespace("type")
add := func(name, kind string, nss ...*namespace) {
for _, ns := range nss {
if err := ns.add(name, kind); err != nil {
// A table and a composite type clash in both catalogs; the
// first rejection is the whole report for this object.
errs = append(errs, err)
return
}
}
}
addIndexes := func(indexes *orderedmap.Map[string, *model.Index]) {
for _, idx := range indexes.CollectValues() {
add(model.Ident(idx.Schema, idx.Name), "index", relations)
}
}
for fqtn, t := range r.Tables.All() {
add(fqtn, "table", relations, types)
addIndexes(t.Indexes)
constraints := newScopedNamespace("constraint", fqtn)
for _, con := range t.Constraints.CollectValues() {
add(model.Ident(con.Name), constraintKindLabel(con.Type), constraints)
if !con.Type.IsPrimaryKeyConstraint() && !con.Type.IsUniqueConstraint() && !con.Type.IsExclusionConstraint() {
continue
}
// A USING INDEX constraint that keeps the index's name is that
// index, already added above.
if _, ok := t.Indexes.GetOk(con.IndexName); ok && con.IndexName == con.Name {
continue
}
add(model.Ident(t.Schema, con.Name), constraintKindLabel(con.Type), relations)
}
for _, fk := range t.ForeignKeys.CollectValues() {
add(model.Ident(fk.Name), constraintKindLabel(fk.Type), constraints)
}
}
for fqvn, v := range r.Views.All() {
kind := "view"
if v.Materialized {
kind = "materialized view"
}
add(fqvn, kind, relations, types)
addIndexes(v.Indexes)
}
for name := range r.Sequences.Keys() {
add(name, "sequence", relations)
}
for fqcn, ct := range r.CompositeTypes.All() {
add(fqcn, "composite type", relations, types)
attributes := newScopedNamespace("attribute", fqcn)
for _, attr := range ct.Attributes {
add(model.Ident(attr.Name), "attribute", attributes)
}
}
for fqdn, d := range r.Domains.All() {
add(fqdn, "domain", types)
constraints := newScopedNamespace("constraint", fqdn)
for _, con := range d.Constraints {
// A domain constraint is always CHECK; NOT NULL is a flag on the
// domain rather than an entry here.
add(model.Ident(con.Name), "CHECK constraint", constraints)
}
}
for fqen, e := range r.Enums.All() {
add(fqen, "enum", types)
// Labels are literals, not identifiers, so they are not quoted.
values := newScopedNamespace("enum value", fqen)
for _, v := range e.Values {
add(v, "enum value", values)
}
}
return errors.Join(errs...)
}
package pistachio
import (
"context"
"fmt"
"slices"
"strings"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/parser"
)
type PlanOptions struct {
DropPolicy
Files []string `arg:"" help:"Path to the desired schema SQL file(s)."`
PreSQL string `xor:"pre-sql" env:"PISTA_PRE_SQL" help:"SQL to prepend to the plan output."`
PreSQLFile string `type:"path" xor:"pre-sql" env:"PISTA_PRE_SQL_FILE" help:"Path to a SQL file to prepend to the plan output."`
ConcurrentlyPreSQL string `xor:"concurrently-pre-sql" env:"PISTA_CONCURRENTLY_PRE_SQL" help:"SQL to run before CONCURRENTLY index DDL (e.g. SET lock_timeout). Emitted only when the diff contains CONCURRENTLY index DDL."`
ConcurrentlyPreSQLFile string `type:"path" xor:"concurrently-pre-sql" env:"PISTA_CONCURRENTLY_PRE_SQL_FILE" help:"Path to a SQL file to run before CONCURRENTLY index DDL."`
DisableIndexConcurrently bool `xor:"index-concurrently" env:"PISTA_DISABLE_INDEX_CONCURRENTLY" help:"Ignore CONCURRENTLY opt-ins (directive and inline) and emit plain CREATE/DROP INDEX."`
ForceIndexConcurrently bool `xor:"index-concurrently" env:"PISTA_FORCE_INDEX_CONCURRENTLY" help:"Force CONCURRENTLY on every CREATE/DROP INDEX, including pure drops."`
BulkAlter bool `env:"PISTA_BULK_ALTER" help:"Combine consecutive ALTER TABLE actions on the same table into a single statement. FK changes, RENAME, VALIDATE CONSTRAINT, RLS toggles, and skipped DROPs stay separate."`
AssumeValidated bool `env:"PISTA_ASSUME_VALIDATED" help:"Treat every table constraint, domain constraint, and foreign key as validated: ignore NOT VALID and never emit VALIDATE CONSTRAINT."`
EvaluateLiterals bool `env:"PISTA_EVALUATE_LITERALS" help:"Compare a string literal with a cast by value, not by text, in a DEFAULT, a generated column and a CHECK constraint. The literals whose text differs are sent to the server in one query."`
NoReadOnly bool `env:"PISTA_NO_READ_ONLY" help:"Open the database connection read-write. By default plan uses a read-only connection."`
Explain bool `env:"PISTA_EXPLAIN" help:"Comment each statement that scans or rewrites a table with what it does, what its lock blocks, and the table's row and byte estimate from pg_class."`
Out string `type:"path" env:"PISTA_OUT" placeholder:"FILE" help:"Also write the plan to this file, for pista apply-from to execute later. The plan is fixed when it is written: apply-from runs these statements, and only checks that the schema has not changed under them."`
}
// ObjectCount holds the number of objects inspected by type.
type ObjectCount struct {
Schemas []string `json:"schemas"`
Tables int `json:"tables"`
Views int `json:"views"`
Enums int `json:"enums"`
Domains int `json:"domains"`
CompositeTypes int `json:"composite_types"`
Sequences int `json:"sequences"`
// Routines is nil unless --manage-routine is set. A nil value leaves the
// slot out of Summary entirely, so the line reads exactly as it did
// before routines were managed.
Routines *int `json:"routines"`
}
func (c ObjectCount) SchemaLabel() string {
if len(c.Schemas) == 1 {
return "schema " + c.Schemas[0]
}
return "schemas " + strings.Join(c.Schemas, ", ")
}
func (c ObjectCount) Summary() string {
parts := []string{
pluralize(c.Tables, "table"),
pluralize(c.Views, "view"),
pluralize(c.Enums, "enum"),
pluralize(c.Domains, "domain"),
pluralize(c.CompositeTypes, "composite type"),
pluralize(c.Sequences, "sequence"),
}
if c.Routines != nil {
parts = append(parts, pluralize(*c.Routines, "routine"))
}
return strings.Join(parts, ", ")
}
func pluralize(n int, singular string) string {
return plural(n, singular, singular+"s")
}
// PlanResult holds the result of a Plan operation.
type PlanResult struct {
SQL string
DisallowedDrops string
Ignored string
Count ObjectCount
// HasChanges is true when the plan contains executable statements
// (DDL or execute directives). Suppressed drops do not count.
HasChanges bool
// InvalidIndexes warns about each managed index that is invalid, one
// comment per line.
InvalidIndexes string
}
func (client *Client) Plan(ctx context.Context, options *PlanOptions) (*PlanResult, error) {
if err := client.validateSchemas(); err != nil {
return nil, err
}
desired, err := client.loadDesiredInput(
options.Files,
options.PreSQL, options.PreSQLFile,
options.ConcurrentlyPreSQL, options.ConcurrentlyPreSQLFile,
)
if err != nil {
return nil, err
}
conn, err := client.connect(ctx, !options.NoReadOnly)
if err != nil {
return nil, err
}
defer conn.Close(ctx) //nolint:errcheck
result, err := client.diffAll(ctx, conn, &diffAllOptions{
FilterOptions: client.FilterOptions,
DropPolicy: options.DropPolicy,
Desired: desired,
DisableIndexConcurrently: options.DisableIndexConcurrently,
ForceIndexConcurrently: options.ForceIndexConcurrently,
BulkAlter: options.BulkAlter,
AssumeValidated: options.AssumeValidated,
EvaluateLiterals: options.EvaluateLiterals,
StateHash: options.Out != "",
})
if err != nil {
return nil, err
}
// The statements as the diff wrote them. --explain rewrites result.Stmts
// with a comment above each statement it has something to say about,
// which belongs in the output a person reads rather than in the file
// apply-from executes.
planStmts := result.Stmts
// The type names --explain resolves were printed under the connection's
// search_path, so this runs before the SET below changes it. A plan with
// no statements is left alone, so the common no-drift run pays for no
// catalog read.
if options.Explain && len(result.Stmts) > 0 {
cat, err := catalog.NewCatalog(conn, client.Schemas)
if err != nil {
return nil, fmt.Errorf("failed to create catalog: %w", err)
}
result.Stmts, err = client.explainStmts(ctx, cat, result.Stmts, result.CurrentTables, result.DesiredTables, result.DesiredDomains)
if err != nil {
return nil, fmt.Errorf("failed to explain plan: %w", err)
}
}
// A check SQL is evaluated under the same search_path apply gives it, so an
// unqualified name in it resolves to the same object in both commands.
// Without it a check reading a table in a non-public target schema failed
// here and was reported as undetermined, while apply answered it. The
// catalog has already been read, so this cannot change what the diff
// compared, and only a run that has a check to evaluate pays for the
// statement.
if hasCheckSQL(result.ExecuteStmts) {
if _, err := conn.Exec(ctx, client.searchPathSQL()); err != nil {
return nil, fmt.Errorf("failed to set search_path: %w", err)
}
}
// execute-first statements run before the schema changes, plain execute
// statements after them. Order within each group follows the source file.
// A statement whose check SQL is false is left out, so the plan shows what
// apply would run rather than everything the file holds.
//
// A check that cannot be evaluated is undetermined, not fatal. plan runs
// before the managed DDL and on a read-only connection, so a check may
// reference a table this run creates, or write. Both answer fine at apply
// time. Such a statement is kept in the plan, as it was before checks were
// evaluated at all, with the reason recorded; failing here would take the
// unrelated DDL down with it.
//
// --out is the exception: a plan file says what apply-from will run, and
// there is no apply left to decide. Writing the guess as a promise is
// worse than failing here with the reason.
var decided []*parser.ExecuteStmt
appendExecuteStmts := func(stmts []string, first bool) ([]string, error) {
for _, es := range result.ExecuteStmts {
if es.First != first {
continue
}
shouldExecute := true
note := ""
if es.CheckSQL != "" {
if err := conn.QueryRow(ctx, es.CheckSQL).Scan(&shouldExecute); err != nil {
if options.Out != "" {
return nil, fmt.Errorf(
"failed to evaluate check SQL for --out: %s: %w (plan connects read-only; --no-read-only answers a check that writes)",
es.CheckSQL, err,
)
}
shouldExecute = true
note = "check SQL could not be evaluated at plan time: " + err.Error() + "; apply will decide"
}
}
if shouldExecute {
stmts = append(stmts, parser.FormatExecuteStmtWithNote(es, note))
// The plan file holds the decision, not the condition: the
// statement is one apply-from runs.
decided = append(decided, &parser.ExecuteStmt{SQL: es.SQL, First: es.First})
}
}
return stmts, nil
}
var stmts []string
stmts, err = appendExecuteStmts(stmts, true)
if err != nil {
return nil, err
}
stmts = append(stmts, result.Stmts...)
stmts, err = appendExecuteStmts(stmts, false)
if err != nil {
return nil, err
}
hasChanges := len(stmts) > 0
// Prefix order matches apply: PreSQL -> concurrently-pre-SQL -> DDL.
// Skipped entirely when there is nothing to execute, so an empty plan
// stays empty instead of leaking a bare SET / pre-SQL line.
if hasChanges {
var prefix []string
if result.PreSQL != "" {
prefix = append(prefix, result.PreSQL)
}
if result.ConcurrentlyPreSQL != "" && result.HasConcurrentlyIndex {
prefix = append(prefix, result.ConcurrentlyPreSQL)
}
stmts = append(prefix, stmts...)
}
if options.Out != "" {
scope := client.ScopeOptions
// The effective value, so that apply-from opens its connection the
// way this run did even where the default applied.
searchPath := scope.searchPath()
scope.SearchPath = &searchPath
serverVersion, err := serverMajorVersion(ctx, conn)
if err != nil {
return nil, err
}
if err := writePlanFile(options.Out, &planFile{
Version: planFileVersion,
ServerVersion: serverVersion,
Scope: scope,
StateHash: result.StateHash,
Count: result.Count,
PreSQL: result.PreSQL,
ConcurrentlyPreSQL: result.ConcurrentlyPreSQL,
HasConcurrentlyIndex: result.HasConcurrentlyIndex,
Stmts: planStmts,
ExecuteStmts: decided,
DisallowedDrops: result.DisallowedDrops,
IgnoredObjects: result.IgnoredObjects,
}); err != nil {
return nil, err
}
}
return &PlanResult{
SQL: strings.Join(stmts, "\n"),
DisallowedDrops: strings.Join(result.DisallowedDrops, "\n"),
Ignored: strings.Join(result.Ignored, "\n"),
InvalidIndexes: strings.Join(result.InvalidIndexes, "\n"),
Count: result.Count,
HasChanges: hasChanges,
}, nil
}
// hasCheckSQL reports whether any execute statement carries a condition to
// evaluate, so plan sets the search_path only when one is going to run.
func hasCheckSQL(stmts []*parser.ExecuteStmt) bool {
return slices.ContainsFunc(stmts, func(es *parser.ExecuteStmt) bool { return es.CheckSQL != "" })
}
package pistachio
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/pistachio/parser"
)
// planFileVersion is the format of the file plan --out writes and apply-from
// reads. A file of another version is refused rather than read as this one.
//
// Raise it whenever what a plan file holds or how it is read changes, the
// state hash included. The hash is taken over the model as JSON, so adding a
// field to a model struct changes it for an unchanged database: a file
// written before that change would otherwise be reported as drift, and the
// operator would go looking at the database for it.
const planFileVersion = 5
// planFile is what plan --out writes: the statements apply-from is to run, the
// scope they were computed under, and the fingerprint of the schema they were
// computed against.
//
// The statements are held apart the way apply runs them rather than as one
// script. Pre-SQL runs before the search_path is set and outside the managed
// DDL, the CONCURRENTLY pre-SQL only when there is CONCURRENTLY index DDL, and
// an execute statement carries its directive into the output.
//
// pista writes this file and pista reads it. There is no schema published for
// it, and no promise beyond the version above.
type planFile struct {
Version int `json:"version"`
// ServerVersion is the major version of the server the plan was computed
// against. It decides what the catalog reads and what DDL is valid, so a
// plan file is not carried to a server of another major version.
ServerVersion int `json:"server_version"`
// Scope is what apply-from reads the database with. Taking it from the
// file rather than the command line is what makes the state hash mean
// anything: a read under other filters is a different read.
Scope ScopeOptions `json:"scope"`
// StateHash fingerprints the current-side schema the statements were
// computed against.
StateHash string `json:"state_hash"`
// Count is what the plan inspected, for the line apply-from writes above
// its output. It is the plan's rather than a count apply-from takes of
// its own read: an object the desired schema marks -- pista:ignore is
// left out of the plan's count, and apply-from, which reads no desired
// schema, cannot tell which those are.
Count ObjectCount `json:"count"`
PreSQL string `json:"pre_sql"`
ConcurrentlyPreSQL string `json:"concurrently_pre_sql"`
HasConcurrentlyIndex bool `json:"has_concurrently_index"`
Stmts []string `json:"statements"`
ExecuteStmts []*parser.ExecuteStmt `json:"execute_statements"`
DisallowedDrops []string `json:"disallowed_drops"`
// IgnoredObjects names what the desired schema marked -- pista:ignore.
// apply-from drops them from its read before it hashes it, so an object
// the plan did not compare cannot report drift, and renders the
// -- ignored: lines of the output from the same list.
IgnoredObjects []string `json:"ignored_objects"`
}
// writePlanFile writes the plan file as indented JSON, with the newline a text
// file ends on.
func writePlanFile(path string, plan *planFile) error {
encoded, err := json.Marshal(plan, jsontext.WithIndent(" "), json.Deterministic(true))
if err != nil {
return fmt.Errorf("failed to encode the plan file: %w", err)
}
if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil { //nolint:gosec
return fmt.Errorf("failed to write the plan file: %w", err)
}
return nil
}
// readPlanFile reads a plan file and refuses one this pista does not write.
func readPlanFile(path string) (*planFile, error) {
encoded, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read the plan file: %w", err)
}
// The version is read on its own first. A file of another format may no
// longer decode into this struct at all, and "run plan again" is a better
// answer to that than a parse error naming a field.
var version struct {
Version int `json:"version"`
}
if err := json.Unmarshal(encoded, &version); err != nil {
return nil, fmt.Errorf("failed to parse the plan file: %w", err)
}
if version.Version != planFileVersion {
return nil, fmt.Errorf(
"plan file %s is version %d, and this pista writes version %d: run plan again",
path, version.Version, planFileVersion,
)
}
plan := &planFile{}
if err := json.Unmarshal(encoded, plan); err != nil {
return nil, fmt.Errorf("failed to parse the plan file: %w", err)
}
return plan, nil
}
// serverMajorVersion reads the major version of the server on the other end of
// the connection.
func serverMajorVersion(ctx context.Context, conn *pgx.Conn) (int, error) {
var num int
if err := conn.QueryRow(ctx, "SELECT current_setting('server_version_num')::int").Scan(&num); err != nil {
return 0, fmt.Errorf("failed to read the server version: %w", err)
}
return num / 10000, nil
}
package pistachio
import (
"fmt"
"os"
)
// resolvePreSQL returns the pre-SQL string from either the direct string
// or the file path. The direct string takes precedence.
func resolvePreSQL(preSQL, preSQLFile string) (string, error) {
return resolveSQLOpt(preSQL, preSQLFile, "pre-SQL")
}
// resolveConcurrentlyPreSQL returns the concurrently-pre-SQL string from
// either the direct string or the file path. The direct string takes
// precedence.
func resolveConcurrentlyPreSQL(preSQL, preSQLFile string) (string, error) {
return resolveSQLOpt(preSQL, preSQLFile, "concurrently-pre-SQL")
}
func resolveSQLOpt(sql, file, label string) (string, error) {
if sql != "" {
return sql, nil
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("failed to read %s file: %w", label, err)
}
return string(data), nil
}
return "", nil
}
package pistachio
import (
"regexp"
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// defReplacer rewrites schema-qualified prefixes in raw SQL definitions,
// "staging." to "public.". A prefix counts only where a name can start: at the
// beginning of the text or after a character that cannot continue an
// identifier, so a schema whose name ends in the mapped one, mystaging., and a
// table named like the schema in a three-part reference, staging.staging.col,
// keep their second part. String literals and quoted identifiers are handled by
// Replace.
//
// All inputs come from canonical SQL, pg_get_*def output from the catalog or
// pg_query deparse output from the parser, so an identifier that requires
// quoting is already wrapped in double quotes and only the model.Ident form is
// matched. An unquoted fallback, the raw `a.b.` for a schema literally named
// `a.b`, would collide with a three-part reference `a.b.col`.
type defReplacer struct {
re *regexp.Regexp
to map[string]string
schemas map[string]string
}
func buildDefReplacer(schemaMap map[string]string) *defReplacer {
r := &defReplacer{to: make(map[string]string, len(schemaMap)), schemas: schemaMap}
// Longest first, so a name that starts another whole name is not matched
// short: the alternation takes the first branch that matches.
froms := make([]string, 0, len(schemaMap))
for from, to := range schemaMap {
ident := model.Ident(from)
froms = append(froms, ident)
r.to[ident] = model.Ident(to)
}
slices.SortFunc(froms, func(a, b string) int {
return len(b) - len(a)
})
for i, from := range froms {
froms[i] = regexp.QuoteMeta(from)
}
// Replace hands the regexp only text outside quotes, so of the names
// only an unquoted one ever matches it.
r.re = regexp.MustCompile(`(^|[^A-Za-z0-9_$"])(` + strings.Join(froms, "|") + `)\.`)
return r
}
// Replace rewrites every mapped schema prefix in a statement: an index, a
// trigger or a view body. ReplaceConstraint does it for a constraint
// definition and ReplaceExpr for an expression.
//
// A string literal is left alone unless it names an object, as the argument
// of nextval, currval or setval or cast to an object identifier type, which
// remapDefaultExpr treats the same way in a default. The first part of a
// two-part column reference is a table or an alias, never a schema, so it is
// left alone too. The definition is parsed to find those; one that does not
// parse is rewritten without that check.
func (r *defReplacer) Replace(s string) string {
return r.replace(s, columnQualifiers("", s))
}
// ReplaceConstraint is Replace for a constraint definition, as
// pg_get_constraintdef writes it.
func (r *defReplacer) ReplaceConstraint(s string) string {
return r.replace(s, columnQualifiers("ALTER TABLE _t ADD CONSTRAINT _c ", s))
}
// ReplaceExpr is Replace for an expression, a policy's USING or WITH CHECK.
func (r *defReplacer) ReplaceExpr(s string) string {
return r.replace(s, columnQualifiers("SELECT ", s))
}
// columnQualifiers returns the offsets in s where a two-part column reference
// starts, as found by parsing prefix and s together, or nil when they do not
// parse.
func columnQualifiers(prefix, s string) map[int]bool {
result, err := pg_query.Parse(prefix + s)
if err != nil {
return nil
}
offsets := map[int]bool{}
for _, stmt := range result.Stmts {
pgast.Walk(stmt.Stmt, pgast.WalkOptions{}, func(_ pgast.Ctx, n *pg_query.Node) *pg_query.Node {
if cr := n.GetColumnRef(); cr != nil && len(cr.Fields) == 2 {
offsets[int(cr.Location)-len(prefix)] = true
}
return n
})
}
return offsets
}
// replace is Replace with the column qualifier offsets in hand.
func (r *defReplacer) replace(s string, qualifiers map[int]bool) string {
var b strings.Builder
code := 0
for i := 0; i < len(s); {
switch s[i] {
case '"':
// A quoted identifier is a name, never searched for a prefix. It
// is mapped whole when it is a schema: a mapped name followed by a
// dot, not itself after one and not a column qualifier.
end := skipQuoted(s, i, '"', false)
b.WriteString(r.replaceNames(s, code, i, qualifiers))
ident := s[i:end]
if to, ok := r.to[ident]; ok && end < len(s) && s[end] == '.' && (i == 0 || s[i-1] != '.') && !qualifiers[i] {
ident = to
}
b.WriteString(ident)
code, i = end, end
case '\'':
escape := i > 0 && (s[i-1] == 'E' || s[i-1] == 'e') && (i == 1 || !isIdentByte(s[i-2]))
end := skipQuoted(s, i, '\'', escape)
b.WriteString(r.replaceNames(s, code, i, qualifiers))
lit := s[i:end]
if !escape && namesObject(s[code:i], s[end:]) {
lit = r.remapNameLiteral(lit)
}
b.WriteString(lit)
code, i = end, end
default:
i++
}
}
b.WriteString(r.replaceNames(s, code, len(s), qualifiers))
return b.String()
}
// replaceNames rewrites every mapped schema prefix in s[from:to], which holds
// no string literal, other than one at a column qualifier offset.
func (r *defReplacer) replaceNames(s string, from, to int, qualifiers map[int]bool) string {
seg := s[from:to]
var b strings.Builder
last := 0
for _, m := range r.re.FindAllStringSubmatchIndex(seg, -1) {
if qualifiers[from+m[4]] {
continue
}
b.WriteString(seg[last:m[4]])
b.WriteString(r.to[seg[m[4]:m[5]]])
last = m[5]
}
b.WriteString(seg[last:])
return b.String()
}
// remapNameLiteral rewrites the schema of the qualified name a string literal
// holds, quotes included.
func (r *defReplacer) remapNameLiteral(lit string) string {
name := strings.ReplaceAll(lit[1:len(lit)-1], "''", "'")
mapped := remapQualifiedName(name, func(schema string) string {
if to, ok := r.schemas[schema]; ok {
return to
}
return schema
})
return "'" + strings.ReplaceAll(mapped, "'", "''") + "'"
}
var (
sequenceCallRE = regexp.MustCompile(`(^|[^A-Za-z0-9_$."])(pg_catalog\.)?(nextval|currval|setval)\(\s*$`)
regCastRE = regexp.MustCompile(`^::(pg_catalog\.)?(` + strings.Join(regTypes, "|") + `)\b`)
)
// namesObject reports whether the string literal between before and after is
// the name of an object: the first argument of a sequence function, or the
// operand of a cast to an object identifier type.
func namesObject(before, after string) bool {
return sequenceCallRE.MatchString(before) || regCastRE.MatchString(after)
}
// skipQuoted returns the index just past the quoted token that opens at i. A
// doubled quote stands for one, and in an escape string a backslash escapes
// the byte after it. An unterminated token runs to the end of s.
func skipQuoted(s string, i int, quote byte, escape bool) int {
for j := i + 1; j < len(s); j++ {
switch {
case escape && s[j] == '\\':
j++
case s[j] == quote:
if j+1 < len(s) && s[j+1] == quote {
j++
continue
}
return j + 1
}
}
return len(s)
}
func isIdentByte(c byte) bool {
return c == '_' || c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
}
func buildReverseDefReplacer(schemaMap map[string]string) *defReplacer {
reversed := make(map[string]string, len(schemaMap))
for k, v := range schemaMap {
reversed[v] = k
}
return buildDefReplacer(reversed)
}
// remapQualifiedName rewrites the schema of a schema-qualified name, a column
// type or a partition parent, and leaves an unqualified one alone. The name is
// split the way an identifier is read, so a type modifier or an array suffix
// stays with the type, and a schema whose name ends in the mapped one is not
// touched the way a prefix substitution would touch it.
func remapQualifiedName(name string, mapSchema func(string) string) string {
parts := model.SplitQualifiedName(name)
if len(parts) != 2 {
return name
}
schema := model.UnquoteIdent(parts[0])
mapped := mapSchema(schema)
if mapped == schema {
return name
}
return model.Ident(mapped) + "." + parts[1]
}
// remapDefaultExpr rewrites the schema in a default expression, a column's or
// a parameter's: the name of a function it calls, the type of a cast, and the
// relation a reg* literal names, which is how nextval('myschema.seq'::regclass)
// carries one. The expression is parsed for it, since a prefix substitution
// over the text would also rewrite a string literal that happens to start the
// same way. An expression that names no mapped schema, or does not parse, is
// returned as it was.
func remapDefaultExpr(expr string, mapSchema func(string) string) string {
result, target, err := pgast.ParseExpr(expr)
if err != nil {
return expr
}
remapName := func(names []*pg_query.Node) bool {
if len(names) != 2 {
return false
}
s := names[0].GetString_()
if s == nil {
return false
}
mapped := mapSchema(s.Sval)
if mapped == s.Sval {
return false
}
s.Sval = mapped
return true
}
changed := false
pgast.Walk(target.Val, pgast.WalkOptions{}, func(_ pgast.Ctx, node *pg_query.Node) *pg_query.Node {
switch n := node.Node.(type) {
case *pg_query.Node_FuncCall:
if remapName(n.FuncCall.Funcname) {
changed = true
}
if lit := sequenceLiteral(n.FuncCall); lit != nil {
if mapped := remapQualifiedName(lit.Sval, mapSchema); mapped != lit.Sval {
lit.Sval = mapped
changed = true
}
}
case *pg_query.Node_TypeCast:
if remapName(n.TypeCast.GetTypeName().GetNames()) {
changed = true
}
if lit := regLiteral(n.TypeCast); lit != nil {
if mapped := remapQualifiedName(lit.Sval, mapSchema); mapped != lit.Sval {
lit.Sval = mapped
changed = true
}
}
}
return node
})
if !changed {
return expr
}
sql, err := pg_query.Deparse(result)
if err != nil {
return expr
}
return strings.TrimPrefix(sql, "SELECT ")
}
// sequenceLiteral returns the bare string literal a sequence function is
// called with, as in nextval('myschema.seq'), or nil. A schema file writes the
// call that way; the catalog writes the literal cast to regclass, which
// regLiteral reads.
func sequenceLiteral(fc *pg_query.FuncCall) *pg_query.String {
if !builtinName(fc.Funcname, "nextval", "currval", "setval") || len(fc.Args) == 0 {
return nil
}
return fc.Args[0].GetAConst().GetSval()
}
// regTypes are the object identifier types. A literal cast to one names a
// catalog object, schema-qualified or not.
var regTypes = []string{
"regclass", "regcollation", "regconfig", "regdictionary", "regnamespace",
"regoper", "regoperator", "regproc", "regprocedure", "regrole", "regtype",
}
// regLiteral returns the string literal a cast to an object identifier type
// is applied to, or nil for any other cast.
func regLiteral(tc *pg_query.TypeCast) *pg_query.String {
if !builtinName(tc.GetTypeName().GetNames(), regTypes...) {
return nil
}
return tc.Arg.GetAConst().GetSval()
}
// builtinName reports whether a function or type name is one of the given
// pg_catalog names, written bare or qualified with pg_catalog. A user-defined
// object of the same name in another schema is not one, so its argument is
// left alone.
func builtinName(names []*pg_query.Node, candidates ...string) bool {
switch len(names) {
case 1:
case 2:
if names[0].GetString_().GetSval() != "pg_catalog" {
return false
}
default:
return false
}
return slices.Contains(candidates, names[len(names)-1].GetString_().GetSval())
}
// remapColumns rewrites the schema in each column's type, collation and
// default, in the partition parent, and in the constraint definitions.
func remapColumns(t *model.Table, mapSchema func(string) string, replacer *defReplacer) {
for _, col := range t.Columns.CollectValues() {
col.TypeName = remapQualifiedName(col.TypeName, mapSchema)
col.Collation = remapQualifiedNamePtr(col.Collation, mapSchema)
col.Default = remapDefaultExprPtr(col.Default, mapSchema)
}
t.PartitionOf = remapQualifiedNamePtr(t.PartitionOf, mapSchema)
for _, con := range t.Constraints.CollectValues() {
con.Definition = replacer.ReplaceConstraint(con.Definition)
}
}
// remapDomain rewrites the schema in a domain's base type, collation and
// default, and in its constraint definitions.
func remapDomain(d *model.Domain, mapSchema func(string) string, replacer *defReplacer) {
d.BaseType = remapQualifiedName(d.BaseType, mapSchema)
d.Collation = remapQualifiedNamePtr(d.Collation, mapSchema)
d.Default = remapDefaultExprPtr(d.Default, mapSchema)
for _, con := range d.Constraints {
con.Definition = replacer.ReplaceConstraint(con.Definition)
}
}
// mapPtr applies f to the string p points to and returns a pointer to the
// result, or nil when p is nil.
func mapPtr(p *string, f func(string) string) *string {
if p == nil {
return nil
}
mapped := f(*p)
return &mapped
}
func remapQualifiedNamePtr(name *string, mapSchema func(string) string) *string {
return mapPtr(name, func(s string) string { return remapQualifiedName(s, mapSchema) })
}
func remapDefaultExprPtr(expr *string, mapSchema func(string) string) *string {
return mapPtr(expr, func(s string) string { return remapDefaultExpr(s, mapSchema) })
}
func (client *Client) remapTableSchemas(tables *orderedmap.Map[string, *model.Table]) *orderedmap.Map[string, *model.Table] {
if len(client.SchemaMap) == 0 {
return tables
}
return remapTables(tables, client.RemapSchema, buildDefReplacer(client.SchemaMap))
}
func (client *Client) reverseRemapTableSchemas(tables *orderedmap.Map[string, *model.Table]) *orderedmap.Map[string, *model.Table] {
if len(client.SchemaMap) == 0 {
return tables
}
return remapTables(tables, client.ReverseRemapSchema, buildReverseDefReplacer(client.SchemaMap))
}
func remapTables(
tables *orderedmap.Map[string, *model.Table],
mapSchema func(string) string,
replacer *defReplacer,
) *orderedmap.Map[string, *model.Table] {
remapped := orderedmap.New[string, *model.Table]()
for _, t := range tables.CollectValues() {
t.Schema = mapSchema(t.Schema)
remapColumns(t, mapSchema, replacer)
remapIndexes(t.Indexes, mapSchema, replacer)
for _, fk := range t.ForeignKeys.CollectValues() {
fk.Schema = mapSchema(fk.Schema)
fk.Definition = replacer.ReplaceConstraint(fk.Definition)
fk.RefSchema = mapPtr(fk.RefSchema, mapSchema)
}
remapPolicies(t.Policies, mapSchema, replacer)
remapTriggers(t.Triggers, mapSchema, replacer)
remapped.Set(t.FQTN(), t)
}
return remapped
}
// remapPolicies rewrites the Schema field on each policy and applies the
// schema replacer to USING / WITH CHECK expressions so cross-schema
// references in subqueries / function calls follow the table schema.
// `policies` is always non-nil because both parser and catalog initialize
// Table.Policies.
func remapPolicies(
policies *orderedmap.Map[string, *model.Policy],
mapSchema func(string) string,
replacer *defReplacer,
) {
for _, p := range policies.CollectValues() {
p.Schema = mapSchema(p.Schema)
p.Using = mapPtr(p.Using, replacer.ReplaceExpr)
p.WithCheck = mapPtr(p.WithCheck, replacer.ReplaceExpr)
}
}
// remapIndexes rewrites the Schema field on each index of a table or
// materialized view and applies the schema replacer to the definition, which
// names the relation the index is on.
func remapIndexes(
indexes *orderedmap.Map[string, *model.Index],
mapSchema func(string) string,
replacer *defReplacer,
) {
for _, idx := range indexes.CollectValues() {
idx.Schema = mapSchema(idx.Schema)
idx.Definition = replacer.Replace(idx.Definition)
}
}
// remapTriggers rewrites the Schema field on each trigger and applies the
// schema replacer to the definition, which names the relation the trigger is
// on and may name a function in another schema. `triggers` is always non-nil
// because both parser and catalog initialize the map.
func remapTriggers(
triggers *orderedmap.Map[string, *model.Trigger],
mapSchema func(string) string,
replacer *defReplacer,
) {
for _, trg := range triggers.CollectValues() {
trg.Schema = mapSchema(trg.Schema)
trg.Definition = replacer.Replace(trg.Definition)
}
}
func (client *Client) remapViewSchemas(views *orderedmap.Map[string, *model.View]) *orderedmap.Map[string, *model.View] {
if len(client.SchemaMap) == 0 {
return views
}
return remapViews(views, client.RemapSchema, buildDefReplacer(client.SchemaMap))
}
func (client *Client) reverseRemapViewSchemas(views *orderedmap.Map[string, *model.View]) *orderedmap.Map[string, *model.View] {
if len(client.SchemaMap) == 0 {
return views
}
return remapViews(views, client.ReverseRemapSchema, buildReverseDefReplacer(client.SchemaMap))
}
func remapViews(
views *orderedmap.Map[string, *model.View],
mapSchema func(string) string,
replacer *defReplacer,
) *orderedmap.Map[string, *model.View] {
remapped := orderedmap.New[string, *model.View]()
for _, v := range views.CollectValues() {
v.Schema = mapSchema(v.Schema)
v.Definition = replacer.Replace(v.Definition)
remapIndexes(v.Indexes, mapSchema, replacer)
remapTriggers(v.Triggers, mapSchema, replacer)
remapped.Set(v.FQVN(), v)
}
return remapped
}
func (client *Client) remapEnumSchemas(enums *orderedmap.Map[string, *model.Enum]) *orderedmap.Map[string, *model.Enum] {
if len(client.SchemaMap) == 0 {
return enums
}
return remapEnums(enums, client.RemapSchema)
}
func (client *Client) reverseRemapEnumSchemas(enums *orderedmap.Map[string, *model.Enum]) *orderedmap.Map[string, *model.Enum] {
if len(client.SchemaMap) == 0 {
return enums
}
return remapEnums(enums, client.ReverseRemapSchema)
}
func remapEnums(
enums *orderedmap.Map[string, *model.Enum],
mapSchema func(string) string,
) *orderedmap.Map[string, *model.Enum] {
remapped := orderedmap.New[string, *model.Enum]()
for _, e := range enums.CollectValues() {
e.Schema = mapSchema(e.Schema)
remapped.Set(e.FQEN(), e)
}
return remapped
}
func (client *Client) remapSequenceSchemas(sequences *orderedmap.Map[string, *model.Sequence]) *orderedmap.Map[string, *model.Sequence] {
if len(client.SchemaMap) == 0 {
return sequences
}
return remapSequences(sequences, client.RemapSchema)
}
func (client *Client) reverseRemapSequenceSchemas(sequences *orderedmap.Map[string, *model.Sequence]) *orderedmap.Map[string, *model.Sequence] {
if len(client.SchemaMap) == 0 {
return sequences
}
return remapSequences(sequences, client.ReverseRemapSchema)
}
func remapSequences(
sequences *orderedmap.Map[string, *model.Sequence],
mapSchema func(string) string,
) *orderedmap.Map[string, *model.Sequence] {
remapped := orderedmap.New[string, *model.Sequence]()
for _, s := range sequences.CollectValues() {
s.Schema = mapSchema(s.Schema)
remapped.Set(s.FQN(), s)
}
return remapped
}
func (client *Client) remapDomainSchemas(domains *orderedmap.Map[string, *model.Domain]) *orderedmap.Map[string, *model.Domain] {
if len(client.SchemaMap) == 0 {
return domains
}
return remapDomains(domains, client.RemapSchema, buildDefReplacer(client.SchemaMap))
}
func (client *Client) reverseRemapDomainSchemas(domains *orderedmap.Map[string, *model.Domain]) *orderedmap.Map[string, *model.Domain] {
if len(client.SchemaMap) == 0 {
return domains
}
return remapDomains(domains, client.ReverseRemapSchema, buildReverseDefReplacer(client.SchemaMap))
}
func remapDomains(
domains *orderedmap.Map[string, *model.Domain],
mapSchema func(string) string,
replacer *defReplacer,
) *orderedmap.Map[string, *model.Domain] {
remapped := orderedmap.New[string, *model.Domain]()
for _, d := range domains.CollectValues() {
d.Schema = mapSchema(d.Schema)
remapDomain(d, mapSchema, replacer)
remapped.Set(d.FQDN(), d)
}
return remapped
}
func (client *Client) remapCompositeTypeSchemas(compositeTypes *orderedmap.Map[string, *model.CompositeType]) *orderedmap.Map[string, *model.CompositeType] {
if len(client.SchemaMap) == 0 {
return compositeTypes
}
return remapCompositeTypes(compositeTypes, client.RemapSchema)
}
func (client *Client) reverseRemapCompositeTypeSchemas(compositeTypes *orderedmap.Map[string, *model.CompositeType]) *orderedmap.Map[string, *model.CompositeType] {
if len(client.SchemaMap) == 0 {
return compositeTypes
}
return remapCompositeTypes(compositeTypes, client.ReverseRemapSchema)
}
func remapCompositeTypes(
compositeTypes *orderedmap.Map[string, *model.CompositeType],
mapSchema func(string) string,
) *orderedmap.Map[string, *model.CompositeType] {
remapped := orderedmap.New[string, *model.CompositeType]()
for _, ct := range compositeTypes.CollectValues() {
ct.Schema = mapSchema(ct.Schema)
for _, a := range ct.Attributes {
a.TypeName = remapQualifiedName(a.TypeName, mapSchema)
a.Collation = remapQualifiedNamePtr(a.Collation, mapSchema)
}
remapped.Set(ct.FQCN(), ct)
}
return remapped
}
// remapRoutineSchemas rewrites the schema of each routine, of the type names
// in its signature and of a parameter default. The body is left untouched: it
// is opaque text in whatever language the routine is written in, and a blind
// prefix substitution over it would be a guess.
func (client *Client) remapRoutineSchemas(routines *orderedmap.Map[string, *model.Routine]) *orderedmap.Map[string, *model.Routine] {
if len(client.SchemaMap) == 0 {
return routines
}
return remapRoutines(routines, client.RemapSchema)
}
func (client *Client) reverseRemapRoutineSchemas(routines *orderedmap.Map[string, *model.Routine]) *orderedmap.Map[string, *model.Routine] {
if len(client.SchemaMap) == 0 {
return routines
}
return remapRoutines(routines, client.ReverseRemapSchema)
}
func remapRoutines(
routines *orderedmap.Map[string, *model.Routine],
remapSchema func(string) string,
) *orderedmap.Map[string, *model.Routine] {
remapped := orderedmap.New[string, *model.Routine]()
for _, r := range routines.CollectValues() {
r.Schema = remapSchema(r.Schema)
r.ReturnType = remapQualifiedName(r.ReturnType, remapSchema)
for _, a := range r.Args {
a.Type = remapQualifiedName(a.Type, remapSchema)
if a.Default != "" {
a.Default = remapDefaultExpr(a.Default, remapSchema)
}
}
remapped.Set(r.FQRN(), r)
}
return remapped
}
package pistachio
import (
"regexp"
"slices"
"strings"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
)
// objectRename is a user type or sequence that -- pista:renamed-from renames.
type objectRename struct {
schema, from, to string
}
// referenceRenames carries a type or sequence rename into the current side's
// references to it: the type of a column, a composite attribute or a domain,
// and the sequence in a nextval() default. PostgreSQL follows the rename by
// OID, but the catalog was read before it and still names the old object, so
// without this the diff retypes every column of the renamed type.
//
// The catalog writes a name without its schema only when the schema is on the
// search_path, so a bare name counts only then.
type referenceRenames struct {
types, sequences []objectRename
searchPath []string
}
// collectRenames returns the renames the Diff* function for the same objects
// would plan: the desired object names a current one it is renamed from, and
// the new name is not in use yet. Any other combination is an error there or
// a rename already applied.
func collectRenames[V any](current, desired *orderedmap.Map[string, V], get func(V) (schema, name string, renameFrom *string)) []objectRename {
var renames []objectRename
for newKey, des := range desired.All() {
_, to, renameFrom := get(des)
if renameFrom == nil || *renameFrom == newKey {
continue
}
cur, ok := current.GetOk(*renameFrom)
if !ok {
continue
}
if _, exists := current.GetOk(newKey); exists {
continue
}
schema, from, _ := get(cur)
renames = append(renames, objectRename{schema: schema, from: from, to: to})
}
return renames
}
// splitSearchPath returns the schemas a search_path value names.
func splitSearchPath(searchPath string) []string {
var schemas []string
for s := range strings.SplitSeq(searchPath, ",") {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
s = strings.ReplaceAll(s[1:len(s)-1], `""`, `"`)
}
if s != "" {
schemas = append(schemas, s)
}
}
return schemas
}
// rename returns name with a rename in renames applied, and whether one was.
// name is an identifier as the catalog writes it, bare or schema-qualified.
func (r *referenceRenames) rename(name string, renames []objectRename) (string, bool) {
for _, o := range renames {
if name == model.Ident(o.schema, o.from) {
return model.Ident(o.schema, o.to), true
}
if name == model.Ident(o.from) && slices.Contains(r.searchPath, o.schema) {
return model.Ident(o.to), true
}
}
return name, false
}
// renameType applies a type rename to a type name, keeping any array suffix.
func (r *referenceRenames) renameType(typeName string) (string, bool) {
base := strings.TrimRight(typeName, "[]")
renamed, ok := r.rename(base, r.types)
return renamed + typeName[len(base):], ok
}
var nextvalRe = regexp.MustCompile(`nextval\('((?:[^']|'')*)'`)
// renameDefault applies a sequence rename to the nextval() calls in a default.
func (r *referenceRenames) renameDefault(def *string) (*string, bool) {
if def == nil || len(r.sequences) == 0 {
return def, false
}
changed := false
s := nextvalRe.ReplaceAllStringFunc(*def, func(m string) string {
lit := nextvalRe.FindStringSubmatch(m)[1]
renamed, ok := r.rename(strings.ReplaceAll(lit, "''", "'"), r.sequences)
if !ok {
return m
}
changed = true
return "nextval('" + strings.ReplaceAll(renamed, "'", "''") + "'"
})
return &s, changed
}
// applyTables returns tables with the renames applied to its columns. A table
// with nothing to rename is shared, not copied.
func (r *referenceRenames) applyTables(tables *orderedmap.Map[string, *model.Table]) *orderedmap.Map[string, *model.Table] {
out := tables.Clone()
for key, t := range tables.All() {
var cols *orderedmap.Map[string, *model.Column]
for name, col := range t.Columns.All() {
typeName, typeChanged := r.renameType(col.TypeName)
def, defChanged := r.renameDefault(col.Default)
if !typeChanged && !defChanged {
continue
}
if cols == nil {
cols = t.Columns.Clone()
}
c := *col
c.TypeName = typeName
c.Default = def
cols.Set(name, &c)
}
if cols != nil {
copied := *t
copied.Columns = cols
out.Set(key, &copied)
}
}
return out
}
// applyDomains returns domains with the renames applied to their base type
// and default.
func (r *referenceRenames) applyDomains(domains *orderedmap.Map[string, *model.Domain]) *orderedmap.Map[string, *model.Domain] {
out := domains.Clone()
for key, d := range domains.All() {
baseType, typeChanged := r.renameType(d.BaseType)
def, defChanged := r.renameDefault(d.Default)
if !typeChanged && !defChanged {
continue
}
copied := *d
copied.BaseType = baseType
copied.Default = def
out.Set(key, &copied)
}
return out
}
// applyCompositeTypes returns composite types with the type renames applied
// to their attributes.
func (r *referenceRenames) applyCompositeTypes(types *orderedmap.Map[string, *model.CompositeType]) *orderedmap.Map[string, *model.CompositeType] {
out := types.Clone()
for key, ct := range types.All() {
var attrs []*model.CompositeAttribute
for i, a := range ct.Attributes {
typeName, changed := r.renameType(a.TypeName)
if !changed {
continue
}
if attrs == nil {
attrs = slices.Clone(ct.Attributes)
}
copied := *a
copied.TypeName = typeName
attrs[i] = &copied
}
if attrs != nil {
copied := *ct
copied.Attributes = attrs
out.Set(key, &copied)
}
}
return out
}
package pistachio
import (
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/model"
"github.com/winebarrel/pistachio/parser"
)
// filterDesiredBySchemas removes objects from the parse result whose schema
// is not in the target schemas list. This prevents objects from unrelated
// schemas in the SQL file from being treated as desired state.
//
// Every object map ParseResult carries is named below. One left out reaches the
// diff with nothing to compare against, so the plan creates it in a schema the
// run was not asked to manage. ExecuteStmts is not an object map: a
// -- pista:execute statement is raw SQL the file asked to run, which carries no
// schema to filter on.
func filterDesiredBySchemas(result *parser.ParseResult, schemas []string, schemaMap map[string]string) {
schemaSet := nameSet(schemas)
// Also include schema-map destinations (desired SQL may use mapped names)
for _, v := range schemaMap {
schemaSet[v] = true
}
result.Tables = filterMapBySchema(result.Tables, schemaSet, func(t *model.Table) string { return t.Schema })
result.Views = filterMapBySchema(result.Views, schemaSet, func(v *model.View) string { return v.Schema })
result.Enums = filterMapBySchema(result.Enums, schemaSet, func(e *model.Enum) string { return e.Schema })
result.Domains = filterMapBySchema(result.Domains, schemaSet, func(d *model.Domain) string { return d.Schema })
result.CompositeTypes = filterMapBySchema(result.CompositeTypes, schemaSet, func(ct *model.CompositeType) string { return ct.Schema })
result.Sequences = filterMapBySchema(result.Sequences, schemaSet, func(s *model.Sequence) string { return s.Schema })
result.Routines = filterMapBySchema(result.Routines, schemaSet, func(r *model.Routine) string { return r.Schema })
}
func filterMapBySchema[V any](m *orderedmap.Map[string, V], schemas map[string]bool, getSchema func(V) string) *orderedmap.Map[string, V] {
return m.Filter(func(_ string, v V) bool { return schemas[getSchema(v)] })
}
package pistachio
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/catalog"
"github.com/winebarrel/pistachio/model"
)
// stateHash fingerprints the current-side schema a plan was computed against,
// so apply-from can tell whether the database still is what the plan assumed.
//
// It covers what the diff read and nothing else. It is called on the objects
// handed to diff.Diff*: filtered, and with the storage parameters cleared
// where they are not managed. An object a filter left out cannot
// change the plan, so it cannot change this either, and an autovacuum setting
// nobody manages does not report drift.
//
// An object the desired schema marks -- pista:ignore is out of it too:
// removeIgnored drops it from the current side before the plan hashes it, and
// apply-from, which has no desired schema to find them from, drops the names
// the plan file records.
//
// Every object is hashed on its own and the digests are sorted, so the value
// does not depend on the order the catalog returned them in. An object list
// added to the plan file later can therefore name what drifted without the
// hash itself changing.
//
// The model carries each object's OID, so a table dropped and created again
// with the same definition is a different state, which is what the plan
// assumed it away from.
func (o *schemaObjects) stateHash() (string, error) {
var digests []string
// One call per kind, since each map is of its own type. They are listed
// rather than called in sequence so that the failure of any of them is
// reported in one place.
for _, appendKind := range []func() error{
func() error { return appendDigests(&digests, "table", o.Tables) },
func() error { return appendDigests(&digests, "view", o.Views) },
func() error { return appendDigests(&digests, "enum", o.Enums) },
func() error { return appendDigests(&digests, "domain", o.Domains) },
func() error { return appendDigests(&digests, "composite_type", o.CompositeTypes) },
func() error { return appendDigests(&digests, "sequence", o.Sequences) },
func() error { return appendDigests(&digests, "routine", o.Routines) },
} {
if err := appendKind(); err != nil {
return "", err
}
}
slices.Sort(digests)
sum := sha256.Sum256([]byte(strings.Join(digests, "\n")))
return hex.EncodeToString(sum[:]), nil
}
// appendDigests adds one digest per object of a kind. The kind and the key are
// hashed with the object so that two kinds cannot collide, and so that an
// object renamed into another's place is a different state even where the
// definition is identical.
//
// The JSON is the document pista parse and pista dump write, through the same
// marshalers, so one description of the model serves all three.
func appendDigests[V any](digests *[]string, kind string, objects *orderedmap.Map[string, V]) error {
for key, object := range objects.All() {
encoded, err := json.Marshal(object, json.Deterministic(true), model.JSONMarshalers)
if err != nil {
return fmt.Errorf("failed to encode %s %s: %w", kind, key, err)
}
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%s\x00%s", kind, key, encoded))
*digests = append(*digests, hex.EncodeToString(sum[:]))
}
return nil
}
// currentState reads the current schema and narrows it the way a plan run
// does. apply-from has no desired schema to diff against, only the statements
// the plan file already holds, so it reads the catalog for the state hash and
// the object count alone.
func (client *Client) currentState(ctx context.Context, conn *pgx.Conn) (*schemaObjects, error) {
cat, err := catalog.NewCatalog(conn, client.Schemas)
if err != nil {
return nil, fmt.Errorf("failed to create catalog: %w", err)
}
current, err := readCurrent(ctx, cat, &client.FilterOptions)
if err != nil {
return nil, err
}
return client.currentSide(current), nil
}
// remove drops the named objects from every kind. The names are keys of the
// maps the catalog filled, so the kind a name belongs to does not have to be
// known: the six it does not belong to hold nothing under it.
func (o *schemaObjects) remove(names []string) {
for _, name := range names {
o.Tables.Delete(name)
o.Views.Delete(name)
o.Enums.Delete(name)
o.Domains.Delete(name)
o.CompositeTypes.Delete(name)
o.Sequences.Delete(name)
o.Routines.Delete(name)
}
}
// currentSide narrows what the catalog returned to what the diff compares:
// the filters applied, and the storage parameters dropped where they are not
// managed. Both the plan and apply-from hash the result, so they have to
// narrow it the same way, which is why this is one function rather than two
// sets of calls that happen to agree today.
func (f *FilterOptions) currentSide(current *schemaObjects) *schemaObjects {
narrowed := &schemaObjects{
Tables: f.filterTables(current.Tables),
Views: f.filterViews(current.Views),
Enums: f.filterEnums(current.Enums),
Domains: f.filterDomains(current.Domains),
CompositeTypes: f.filterCompositeTypes(current.CompositeTypes),
Sequences: f.filterSequences(current.Sequences),
Routines: f.filterRoutines(current.Routines),
}
if !f.ManageStorageParam {
clearStorageParams(narrowed.Tables)
clearMatViewStorageParams(narrowed.Views)
}
return narrowed
}
package pistachio
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/jackc/pgx/v5/pgconn"
pg_query "github.com/pganalyze/pg_query_go/v6"
)
// sqlstateLockNotAvailable is the SQLSTATE of a statement that waited for a
// lock longer than lock_timeout allows.
const sqlstateLockNotAvailable = "55P03"
func isLockTimeout(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == sqlstateLockNotAvailable
}
// timeoutStmts returns the SET statements --lock-timeout and
// --statement-timeout ask for. They run before the pre-SQL, so a SET in the
// pre-SQL overrides them.
func (o *ExecOptions) timeoutStmts() []string {
var stmts []string
if o.LockTimeout != nil {
stmts = append(stmts, "SET lock_timeout = "+millis(*o.LockTimeout)+";")
}
if o.StatementTimeout != nil {
stmts = append(stmts, "SET statement_timeout = "+millis(*o.StatementTimeout)+";")
}
return stmts
}
// millis renders d in milliseconds, the unit of both settings, rounded up: a
// value below a millisecond would otherwise become 0 and turn the setting off.
// 0 itself stays 0, which turns it off as the user asked.
func millis(d UnsignedDuration) string {
ms := (time.Duration(d) + time.Millisecond - 1) / time.Millisecond
return fmt.Sprintf("'%dms'", ms)
}
// retryable reports whether a statement that failed on lock_timeout can be
// run again as it is. A CONCURRENTLY index build or drop runs in several
// transactions of its own, and one that fails after the first leaves an
// invalid index behind, so running it again fails on that index. Text the
// parser cannot read is not known to be safe, so it is not retried either.
func retryable(stmt string) bool {
result, err := pg_query.Parse(stmt)
if err != nil {
return false
}
for _, raw := range result.Stmts {
if is := raw.Stmt.GetIndexStmt(); is != nil && is.Concurrent {
return false
}
if ds := raw.Stmt.GetDropStmt(); ds != nil && ds.Concurrent {
return false
}
}
return true
}
// waitLockRetry writes that the retry-th retry is coming and sleeps for
// --lock-retry-delay. Cancelling ctx ends the sleep.
func waitLockRetry(ctx context.Context, w io.Writer, o *ExecOptions, retry uint) error {
delay := time.Duration(o.LockRetryDelay)
fmt.Fprintf(w, "-- Lock timeout: retrying in %s (%d/%d)\n", delay, retry, o.LockRetries) //nolint:errcheck
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
package toposort
import (
"fmt"
"slices"
)
// graph is a directed acyclic graph for topological sorting.
type graph struct {
nodes map[string]bool
edges map[string][]string // from -> [to ...] meaning "from depends on to"
}
// newGraph creates a new empty graph.
func newGraph() *graph {
return &graph{
nodes: make(map[string]bool),
edges: make(map[string][]string),
}
}
// AddNode adds a node to the graph.
func (g *graph) AddNode(name string) {
g.nodes[name] = true
}
// AddEdge adds a dependency edge: "from" depends on "to".
// Both nodes are implicitly added.
func (g *graph) AddEdge(from, to string) {
g.nodes[from] = true
g.nodes[to] = true
g.edges[from] = append(g.edges[from], to)
}
// Sort performs topological sort using Kahn's algorithm.
// Returns nodes in dependency order (dependencies first).
// Returns an error if a cycle is detected.
// Nodes with no dependency ordering are sorted alphabetically for stability.
func (g *graph) Sort() ([]string, error) {
// Build in-degree map and reverse adjacency list
inDegree := make(map[string]int)
dependents := make(map[string][]string) // to -> [from ...] (who depends on to)
for from, tos := range g.edges {
for _, to := range tos {
// "from depends on to" means edge to -> from in execution order
inDegree[from]++
dependents[to] = append(dependents[to], from)
}
}
// Collect nodes with no dependencies
var queue []string
for n := range g.nodes {
if inDegree[n] == 0 {
queue = append(queue, n)
}
}
slices.Sort(queue)
var result []string
for len(queue) > 0 {
// Pop first (alphabetically smallest for stability)
node := queue[0]
queue = queue[1:]
result = append(result, node)
// Reduce in-degree for dependents
for _, dep := range dependents[node] {
inDegree[dep]--
if inDegree[dep] == 0 {
queue = append(queue, dep)
}
}
slices.Sort(queue)
}
if len(result) != len(g.nodes) {
return nil, fmt.Errorf("cycle detected: sorted %d of %d nodes", len(result), len(g.nodes))
}
return result, nil
}
// reachable returns every node that from depends on, directly or through
// other nodes.
func (g *graph) reachable(from string) map[string]bool {
seen := make(map[string]bool)
stack := []string{from}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
for _, to := range g.edges[n] {
if !seen[to] {
seen[to] = true
stack = append(stack, to)
}
}
}
return seen
}
package toposort
import (
"fmt"
"maps"
"slices"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/winebarrel/orderedmap/v2"
"github.com/winebarrel/pistachio/internal/pgast"
"github.com/winebarrel/pistachio/model"
)
// OrderFromSchema builds a dependency graph from parsed model objects and
// returns the object names in topological order (dependencies first).
// Sequences are leaf nodes; a table depends on a sequence when a column
// default references it via nextval, so the sequence is created first.
func OrderFromSchema(
enums *orderedmap.Map[string, *model.Enum],
domains *orderedmap.Map[string, *model.Domain],
compositeTypes *orderedmap.Map[string, *model.CompositeType],
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
sequences *orderedmap.Map[string, *model.Sequence],
routines *orderedmap.Map[string, *model.Routine],
) ([]string, error) {
g := newGraph()
defined := collectDefined(enums, domains, compositeTypes, tables, views, sequences)
// Enums: no dependencies (leaf nodes)
for k := range enums.Keys() {
g.AddNode(k)
}
// Sequences: no dependencies (leaf nodes)
for k := range sequences.Keys() {
g.AddNode(k)
}
// Domains: may depend on enums or other domains via base type
for k, d := range domains.All() {
g.AddNode(k)
if dep := resolveTypeDep(d.BaseType, d.Schema, k, defined); dep != "" {
g.AddEdge(k, dep)
}
}
// Composite types: may depend on enums, domains, or other composite types
// via attribute types.
for k, ct := range compositeTypes.All() {
g.AddNode(k)
for _, a := range ct.Attributes {
if dep := resolveTypeDep(a.TypeName, ct.Schema, k, defined); dep != "" {
g.AddEdge(k, dep)
}
}
}
// Tables: may depend on enums/domains (column types) and sequences (column
// defaults via nextval). Foreign keys draw no edge. The plan drops them
// before the tables and adds them after, so they never order the tables.
for k, t := range tables.All() {
g.AddNode(k)
// Column type dependencies
if t.Columns != nil {
for _, col := range t.Columns.CollectValues() {
if dep := resolveTypeDep(col.TypeName, t.Schema, k, defined); dep != "" {
g.AddEdge(k, dep)
}
if col.Default != nil {
for _, dep := range extractSeqDeps(*col.Default, t.Schema, defined) {
g.AddEdge(k, dep)
}
}
}
}
// Partition parent dependency
if t.PartitionOf != nil && defined[*t.PartitionOf] {
g.AddEdge(k, *t.PartitionOf)
}
}
// Views: depend on tables/views referenced in their definition
addViewDeps(g, views, defined)
addRoutineDeps(g, routines, domains, tables, views, defined)
order, err := g.Sort()
if err != nil {
return nil, fmt.Errorf("schema dependency sort failed: %w", err)
}
return order, nil
}
// OrderViews returns the views in dependency order, with nothing else in the
// graph. It is for a caller that has a set of views to order and no whole
// schema to hand over, which OrderFromSchema wants.
//
// A reference to anything but another view in the set resolves to nothing and
// drops out, so what is left are the view-to-view edges. Those cannot hold a
// cycle, since PostgreSQL rejects a view that reads a view reading it back,
// which is what lets this succeed where OrderFromSchema fails on a cycle
// elsewhere in the schema.
func OrderViews(views *orderedmap.Map[string, *model.View]) ([]string, error) {
g := newGraph()
defined := make(map[string]bool, views.Len())
for k := range views.Keys() {
defined[k] = true
}
addViewDeps(g, views, defined)
order, err := g.Sort()
if err != nil {
return nil, fmt.Errorf("view dependency sort failed: %w", err)
}
return order, nil
}
// addViewDeps gives each view a node and an edge to every object in defined
// that its definition reads.
func addViewDeps(g *graph, views *orderedmap.Map[string, *model.View], defined map[string]bool) {
for k, v := range views.All() {
g.AddNode(k)
for _, dep := range extractViewDeps(v.Definition, v.Schema, defined) {
if dep != k {
g.AddEdge(k, dep)
}
}
}
}
// collectDefined returns a set of all defined object names.
func collectDefined(
enums *orderedmap.Map[string, *model.Enum],
domains *orderedmap.Map[string, *model.Domain],
compositeTypes *orderedmap.Map[string, *model.CompositeType],
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
sequences *orderedmap.Map[string, *model.Sequence],
) map[string]bool {
defined := make(map[string]bool)
for k := range enums.Keys() {
defined[k] = true
}
for k := range domains.Keys() {
defined[k] = true
}
for k := range compositeTypes.Keys() {
defined[k] = true
}
for k := range tables.Keys() {
defined[k] = true
}
for k := range views.Keys() {
defined[k] = true
}
for k := range sequences.Keys() {
defined[k] = true
}
return defined
}
// extractSeqDeps finds the sequences referenced by nextval(...) in a column
// default expression and returns their resolved (schema-qualified) names.
// Only sequences present in defined are returned; references to unmanaged
// (e.g. serial/identity-owned) sequences are ignored.
func extractSeqDeps(defaultExpr, defaultSchema string, defined map[string]bool) []string {
var deps []string
rest := defaultExpr
for {
_, afterCall, found := strings.Cut(rest, "nextval(")
if !found {
break
}
_, afterOpen, found := strings.Cut(afterCall, "'")
if !found {
break
}
lit, remainder, found := strings.Cut(afterOpen, "'")
if !found {
break
}
rest = remainder
// lit is the identifier as pg_get_expr / pg_query deparse emit it:
// already quoted when the name requires it, matching the form of both
// the `defined` keys and resolveUnqualified's name argument. Do not
// re-quote it with model.Ident, which would double-quote a name like
// "MySeq" and miss the lookup.
if defined[lit] {
deps = append(deps, lit)
} else if !strings.Contains(lit, ".") {
// No self to skip: a table and a sequence cannot share a name.
if q := resolveUnqualified(lit, defaultSchema, "", defined); q != "" {
deps = append(deps, q)
}
}
}
return deps
}
// resolveTypeDep checks if a type name refers to a defined object.
// Handles schema-qualified ("public.status"), unqualified ("status"),
// and array types ("status[]"). Uses defaultSchema to qualify unqualified names.
//
// self is the key of the object the type is written on, or "" for a caller
// that cannot collide with one. An object named after the type it is built on
// resolves to itself, which is no dependency at all: the type it means is the
// one further along the search path, as it was when the object was created.
func resolveTypeDep(typeName, defaultSchema, self string, defined map[string]bool) string {
if typeName == "" {
return ""
}
// Try as-is (already schema-qualified)
if defined[typeName] && typeName != self {
return typeName
}
// Unqualified names: try default schema, then public (search_path fallback).
if !strings.Contains(typeName, ".") {
if q := resolveUnqualified(typeName, defaultSchema, self, defined); q != "" {
return q
}
}
// Array types: strip trailing []
base := strings.TrimSuffix(typeName, "[]")
if base != typeName {
return resolveTypeDep(base, defaultSchema, self, defined)
}
return ""
}
// resolveUnqualified returns the schema-qualified FQDN of an unqualified
// identifier by modeling PostgreSQL's default search_path: try defaultSchema
// first, then fall back to public. Returns "" if no defined object matches.
//
// `defined` keys are model.Ident-formed (schema quoted when needed), so the
// schema component must go through model.Ident too; raw concatenation would
// miss schemas like "MySchema". `name` is taken as-is because callers already
// normalize it to the form that appears in `defined` (typically the deparsed
// identifier for type names, or model.Ident-quoted for raw RangeVar names).
// self is skipped the way resolveTypeDep describes, so a name that matches the
// object it is written on falls through to the next schema on the path.
func resolveUnqualified(name, defaultSchema, self string, defined map[string]bool) string {
if q := model.Ident(defaultSchema) + "." + name; defined[q] && q != self {
return q
}
if defaultSchema != "public" {
if q := "public." + name; defined[q] && q != self {
return q
}
}
return ""
}
// extractViewDeps parses a view definition SQL to find referenced tables/views.
// Uses pg_query to parse the SELECT statement and extract RangeVar references.
//
// The walk reaches every node kind, which the older enumeration could not do: a
// sub-query under a cast, GREATEST, an array or row constructor, a subscript,
// ORDER BY, GROUP BY, LIMIT, an OVER clause or an aggregate FILTER named a
// relation the view was then ordered without waiting for. Two new views in one
// run, one reading the other from such a position, applied in the wrong order.
func extractViewDeps(definition, defaultSchema string, defined map[string]bool) []string {
seen := make(map[string]bool)
// Parse the view definition directly as a SELECT statement.
// pg_get_viewdef returns a standalone SELECT, so it can be parsed as-is.
def := strings.TrimSpace(definition)
def = strings.TrimSuffix(def, ";")
result, err := pg_query.Parse(def)
if err != nil {
// Fallback: try substring matching if parsing fails
return extractViewDepsFallback(definition, defaultSchema, defined)
}
for _, stmt := range result.Stmts {
pgast.Walk(stmt.Stmt, pgast.WalkOptions{}, func(_ pgast.Ctx, node *pg_query.Node) *pg_query.Node {
if rv := node.GetRangeVar(); rv != nil {
if name := qualifyRangeVar(rv, defaultSchema, defined); name != "" {
seen[name] = true
}
}
return node
})
}
return slices.Sorted(maps.Keys(seen))
}
// qualifyRangeVar returns the schema-qualified FQDN of a RangeVar that exists
// in defined. If the RangeVar has no schema, defaultSchema and "public" are
// tried in order, modeling PostgreSQL's default search_path. Returns "" when
// the RangeVar does not match any defined object.
//
// rv.Schemaname / rv.Relname are raw identifiers from pg_query, so both are
// run through model.Ident to match the way `defined` keys are formed.
func qualifyRangeVar(rv *pg_query.RangeVar, defaultSchema string, defined map[string]bool) string {
if rv == nil || rv.Relname == "" {
return ""
}
if rv.Schemaname != "" {
q := model.Ident(rv.Schemaname, rv.Relname)
if defined[q] {
return q
}
return ""
}
// No self to skip: a view cannot name itself in its own definition.
return resolveUnqualified(model.Ident(rv.Relname), defaultSchema, "", defined)
}
// extractViewDepsFallback uses substring matching as a fallback when
// pg_query parsing fails. It checks both fully-qualified names and
// unqualified names (with defaultSchema or public prefix, matching the
// search_path semantics modeled elsewhere) to handle schemaless refs.
func extractViewDepsFallback(definition, defaultSchema string, defined map[string]bool) []string {
seen := make(map[string]bool)
for name := range defined {
if strings.Contains(definition, name) {
seen[name] = true
continue
}
// Try matching the unqualified part (e.g., "users" for "public.users").
// `name` is a model.Ident-formed key, so its schema portion may be
// quoted (e.g. `"MySchema"`); compare against the same form.
schema, rel, ok := strings.Cut(name, ".")
if ok && (schema == model.Ident(defaultSchema) || schema == "public") {
if strings.Contains(definition, rel) {
seen[name] = true
}
}
}
return slices.Sorted(maps.Keys(seen))
}
// RoutineNode returns the graph node name for a routine, given its
// schema-qualified name. The identity argument list is left out, so an overload
// set shares one node: the ordering rules below hold for every overload alike,
// and a CREATE statement spells its parameters with names and defaults, which
// no FQRN could be recovered from.
//
// The "routine:" prefix keeps the node out of the relation and type namespace.
// A schema may hold a table and a function of the same name, and without the
// prefix the two would collapse into one node and read as a cycle.
func RoutineNode(qualifiedName string) string {
if qualifiedName == "" {
return ""
}
return "routine:" + qualifiedName
}
// addRoutineDeps places routines between the types they name and the tables
// that call them.
//
// A routine depends on the types in its signature, so it is created after
// them. Every domain, table and view is then made to depend on every routine,
// so routines come first: a CHECK constraint, a DEFAULT, a GENERATED
// expression, an index expression, a policy or a trigger can call one, and
// those run as part of the domain or table DDL. The edge is drawn wholesale
// rather than by reading each expression, since the answer is the same
// "routine first" either way.
//
// A signature can name a relation or a domain as well as a type: RETURNS SETOF
// <table>, or a table row type or a domain as a parameter. The routine then
// follows it, and a domain, table or view it reaches that way gets no
// wholesale edge, which would close a cycle. When two routines name two
// different relations, the first still precedes the other relation and the
// second follows both.
//
// The reverse direction is otherwise absent. A LANGUAGE sql body that reads a
// table would want the table first, which cannot hold at the same time as the
// rule above, so that case is a documented limitation rather than an edge.
//
// A routine with a SQL-standard body is the exception. PostgreSQL parses such
// a body at creation time, so the routine follows every table, view and
// routine its body names, the way a view does, and gets no wholesale edge. A
// view that calls it follows it in turn. Its statements run with the views,
// after every table, so a CHECK or an index that calls it cannot be created
// in the same run. An overload set that mixes the two forms shares one node
// and is treated as the plain form.
func addRoutineDeps(
g *graph,
routines *orderedmap.Map[string, *model.Routine],
domains *orderedmap.Map[string, *model.Domain],
tables *orderedmap.Map[string, *model.Table],
views *orderedmap.Map[string, *model.View],
defined map[string]bool,
) {
if routines.Len() == 0 {
return
}
nodes := make([]string, 0, routines.Len())
// atomic maps every routine node to whether each routine under it has a
// SQL-standard body.
atomic := make(map[string]bool, routines.Len())
for r := range routines.Values() {
node := RoutineNode(model.Ident(r.Schema, r.Name))
if _, ok := atomic[node]; !ok {
atomic[node] = true
nodes = append(nodes, node)
}
atomic[node] = atomic[node] && r.Atomic()
g.AddNode(node)
// No self-edge is possible: resolveTypeDep returns a key from defined,
// and nothing there carries the routine: prefix.
for _, a := range r.Args {
if dep := resolveTypeDep(a.Type, r.Schema, "", defined); dep != "" {
g.AddEdge(node, dep)
}
}
if dep := resolveTypeDep(r.ReturnType, r.Schema, "", defined); dep != "" {
g.AddEdge(node, dep)
}
}
for r := range routines.Values() {
if !r.Atomic() {
continue
}
node := RoutineNode(model.Ident(r.Schema, r.Name))
for _, dep := range extractBodyDeps(r.SQLBody, r.Schema, defined, atomic) {
if dep != node {
g.AddEdge(node, dep)
}
}
}
atomicOnly := make(map[string]bool, len(atomic))
for node, ok := range atomic {
if ok {
atomicOnly[node] = true
}
}
for k, v := range views.All() {
for _, dep := range extractCallDeps(v.Definition, v.Schema, atomicOnly) {
g.AddEdge(k, dep)
}
}
// An edge into a routine does not change what the routine reaches, so one
// walk per routine covers every domain, table and view.
addDependents := func(keys func(func(string) bool)) {
for _, node := range nodes {
if atomic[node] {
continue
}
reach := g.reachable(node)
for k := range keys {
if reach[k] {
continue
}
g.AddEdge(k, node)
}
}
}
addDependents(domains.Keys())
addDependents(tables.Keys())
addDependents(views.Keys())
}
// extractBodyDeps returns what a SQL-standard routine body names: the tables
// and views in defined it reads, and the routines in routines it calls.
func extractBodyDeps(body, defaultSchema string, defined, routines map[string]bool) []string {
result, err := pg_query.Parse("CREATE FUNCTION _f() " + body)
if err != nil {
return nil
}
seen := make(map[string]bool)
for _, stmt := range result.Stmts {
pgast.Walk(stmt.Stmt, pgast.WalkOptions{}, func(_ pgast.Ctx, node *pg_query.Node) *pg_query.Node {
if rv := node.GetRangeVar(); rv != nil {
if name := qualifyRangeVar(rv, defaultSchema, defined); name != "" {
seen[name] = true
}
}
if fc := node.GetFuncCall(); fc != nil {
if name := resolveRoutineCall(fc.Funcname, defaultSchema, routines); name != "" {
seen[name] = true
}
}
return node
})
}
return slices.Sorted(maps.Keys(seen))
}
// extractCallDeps returns the routines in routines a view definition calls.
func extractCallDeps(definition, defaultSchema string, routines map[string]bool) []string {
if len(routines) == 0 {
return nil
}
def := strings.TrimSuffix(strings.TrimSpace(definition), ";")
result, err := pg_query.Parse(def)
if err != nil {
return nil
}
seen := make(map[string]bool)
for _, stmt := range result.Stmts {
pgast.Walk(stmt.Stmt, pgast.WalkOptions{}, func(_ pgast.Ctx, node *pg_query.Node) *pg_query.Node {
if fc := node.GetFuncCall(); fc != nil {
if name := resolveRoutineCall(fc.Funcname, defaultSchema, routines); name != "" {
seen[name] = true
}
}
return node
})
}
return slices.Sorted(maps.Keys(seen))
}
// resolveRoutineCall returns the routine node a function call names, trying
// defaultSchema and then public for an unqualified name, or "" when the call
// names none of routines.
func resolveRoutineCall(funcname []*pg_query.Node, defaultSchema string, routines map[string]bool) string {
parts := make([]string, 0, len(funcname))
for _, n := range funcname {
parts = append(parts, n.GetString_().GetSval())
}
var candidates []string
switch len(parts) {
case 1:
candidates = []string{model.Ident(defaultSchema, parts[0]), model.Ident("public", parts[0])}
case 2:
candidates = []string{model.Ident(parts[0], parts[1])}
}
for _, c := range candidates {
if node := RoutineNode(c); routines[node] {
return node
}
}
return ""
}