sqlplan

package
v1.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package sqlplan turns the apply SQL of a nuzur schema push into something a human can decide on: an ordered statement list, each labelled with what it does and what it can cost.

It exists because the CLI receives the migration as one opaque string. sql-push hands over the exact DDL it is about to run — see the confirmation step in extension-sql-push's manager — and nothing between there and here says which of those statements deletes a table.

What this package cannot know

Read this before trusting its output, and before extending it. A preview users over-trust is worse than no preview.

  1. No per-statement hazards. pg-schema-diff computes Statement.Hazards, Timeout and LockTimeout for every statement it generates, and every one of those is discarded before the CLI sees it (in the local agent's pg-schema-plan handler, twice in nuzur-go's sql-diff-manager, and finally by the wire types themselves — ComputePgSchemaPlanResponse.apply_sql and GetChangesDiffResponse.apply are plain strings). So this package cannot tell you which statements take an ACCESS EXCLUSIVE lock and therefore take the table offline, which are multi-hour index builds, or which the differ itself considered correctness-affecting. It re-derives only what a keyword can see.
  2. No row counts. "DROP TABLE audit_log_2023" cannot be annotated with how many rows that is; nothing in the path counts.
  3. No idea whether an ALTER will succeed. Adding a foreign key against orphan rows, SET NOT NULL against nulls, a unique index against duplicates: all of those are flagged "may fail", never resolved. pg-schema-diff could resolve them by validating against a temporary database, but nuzur asks it not to.
  4. Atomicity is engine- and content-dependent, and this package reports it rather than guarantees it. sql-push now asks for a transaction, so on Postgres a migration is applied as one unit and a failure rolls the whole thing back — EXCEPT when the batch contains something Postgres cannot run inside a transaction block (CREATE/DROP INDEX CONCURRENTLY, VACUUM, CREATE/DROP DATABASE, ALTER SYSTEM, TABLESPACE operations), which downgrades the whole batch to the old statement-at-a-time path. On MySQL the transaction is opened but DDL commits implicitly, so it is best-effort only and a failure at statement 7 of 12 still leaves 1 through 6 applied. TransactionalWarning says which of those a given plan gets; it cannot change any of them.
  5. MySQL diffs contain churn this package cannot identify. nuzur cannot read a MySQL schema directly, so the "existing" side is reconstructed by introspecting the database into a project version and re-rendering it as DDL. Anything the model cannot express comes back normalized, producing statements that change nothing and reappear on every deploy. They take two shapes — column redefinitions (the narrowing class) and index drop/re-add pairs, when the reconstruction loses an index's type — and ChurnNote counts both. A caller with a MySQL target should say so out loud.

The one thing it is careful to get right is the data-loss class, because that is what a caller gates on. Everything unrecognized is KindOther at SeverityNone: never read "not flagged" as "proven safe".

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DropOnlyWhatItCouldCreate

func DropOnlyWhatItCouldCreate() string

DropOnlyWhatItCouldCreate is the bound on a reconciling deploy's blast radius, and no user currently knows it.

The diff restricts BOTH sides to what a project version can express — on Postgres by an object-class allowlist (schemas, tables, indexes, foreign keys), on MySQL because both sides are rendered by the same generator. Anything the model cannot represent is therefore absent from both sides and can never be proposed for a drop.

func MySQLCaveat

func MySQLCaveat() string

MySQLCaveat explains why a MySQL plan can contain statements that change nothing. Callers print it whenever the target is MySQL.

It matters because MySQL is the default engine, and because a user who sees a dozen pointless MODIFY COLUMNs and is not told why will conclude the tool is broken — which is roughly what happened to the user this feature was built for.

func Split

func Split(applySQL string) []string

Split splits apply SQL into statements exactly the way the connection manager will execute it: on ";", discarding fragments that are only whitespace.

This is deliberately the same naive split as executeRawQuery in nuzur-go's sql-query-manager. A smarter splitter would produce a prettier preview and a less truthful one: whatever a ";" inside a string literal does to the real execution, the preview has to show the same thing. If executeRawQuery ever learns to parse, this must learn with it.

Fragments are trimmed for display. Leading and trailing whitespace is the one difference from what is executed, and it changes nothing about what runs.

Types

type Counts

type Counts struct {
	Total          int `json:"total"`
	Additive       int `json:"additive"`
	DataLoss       int `json:"data_loss"`
	ConstraintLoss int `json:"constraint_loss"`
	Narrowing      int `json:"narrowing"`
}

Counts summarizes a plan by severity.

type Engine added in v1.5.0

type Engine string

Engine names the database a plan will be applied to. The atomicity a migration gets depends on it, so anything reporting on that has to be told.

Spelled here rather than imported: sqlplan is a leaf package with no dependency on the deploy package, and the values deliberately match deploy.DBEngine's so a caller can convert directly.

const (
	// EngineUnknown is the zero value, and makes callers that never resolved an
	// engine get the conservative answer rather than the reassuring one.
	EngineUnknown  Engine = ""
	EngineMySQL    Engine = "mysql"
	EnginePostgres Engine = "postgres"
)

type Hazard added in v1.5.4

type Hazard struct {
	Severity Severity `json:"severity"`
	Kind     Kind     `json:"kind"`
	// Object is a best-effort "table" or "table.column" — the thing this hazard
	// applies to, for pointing at, not for programmatic use.
	Object string `json:"object,omitempty"`
	// Reason says what this one costs, in a sentence a person can act on.
	Reason string `json:"reason,omitempty"`
}

Hazard is one thing a statement costs. A statement can carry several: the generators bundle a whole action list into a single ALTER, and each action is answerable on its own terms.

type Kind

type Kind string

Kind is what a statement does, at the granularity a reader cares about.

const (
	KindCreateTable    Kind = "create_table"
	KindCreateIndex    Kind = "create_index"
	KindCreateSchema   Kind = "create_schema"
	KindAddColumn      Kind = "add_column"
	KindAddConstraint  Kind = "add_constraint"
	KindAlterColumn    Kind = "alter_column"
	KindDropTable      Kind = "drop_table"
	KindDropColumn     Kind = "drop_column"
	KindDropIndex      Kind = "drop_index"
	KindDropConstraint Kind = "drop_constraint"
	KindDropSchema     Kind = "drop_schema"
	KindDropDatabase   Kind = "drop_database"
	KindTruncate       Kind = "truncate"
	KindOther          Kind = "other"
)

type Plan

type Plan struct {
	Statements []Statement `json:"statements"`
}

Plan is an ordered migration.

func Analyze

func Analyze(applySQL string) Plan

Analyze splits apply SQL into statements and labels each one.

func (Plan) ChurnNote

func (p Plan) ChurnNote() string

ChurnNote reports how much of a MySQL plan is likely to be that no-op churn, or "" when none of it is.

It used to count column redefinitions only, which missed the other shape entirely and the largest single source of a plan that never converges: when the reconstruction loses an index's TYPE, the differ sees a type change and proposes to drop the index and add it back — every run, forever. Those land as an index drop plus an index create, neither of which is a column redefinition, so a plan that was pure churn from top to bottom got no note at all.

func (Plan) Counts

func (p Plan) Counts() Counts

Counts summarizes the plan by severity.

func (Plan) Destructive

func (p Plan) Destructive() []Statement

Destructive returns the statements that delete data, in execution order.

func (Plan) Empty

func (p Plan) Empty() bool

Empty reports whether there is nothing to apply.

func (Plan) HasDestructive

func (p Plan) HasDestructive() bool

HasDestructive reports whether any statement deletes data. This is the question the deploy gate asks.

func (Plan) RenderDestructive

func (p Plan) RenderDestructive() string

RenderDestructive is the called-out block of statements that delete data, or "" when there are none. Numbering is the plan's execution order, so a reader can find each one in the full listing.

func (Plan) RenderStatements

func (p Plan) RenderStatements() string

RenderStatements is the full plan in execution order, each statement annotated with what it costs.

func (Plan) SummaryLine

func (p Plan) SummaryLine() string

SummaryLine is the one-line count of what this plan does.

func (Plan) Transactional added in v1.5.0

func (p Plan) Transactional(engine Engine) bool

Transactional reports whether the whole plan is applied as one unit — true only on Postgres, and only when nothing in it forces the executor off the transactional path.

func (Plan) TransactionalWarning

func (p Plan) TransactionalWarning(engine Engine) string

TransactionalWarning says what a partial failure costs on this engine, for this plan. Empty for a plan of fewer than two statements, where "the ones before it stayed applied" describes nothing.

It used to be one unconditional sentence — "these run ONE AT A TIME, not in a transaction" — which was true when sql-push did not request a transaction. It now does, and the answer became engine- and content-dependent, so a single sentence can no longer be honest for every plan. Getting this wrong in the reassuring direction is the expensive one: a reader who believes a failed migration rolled back will not go and check what actually landed.

type Severity

type Severity string

Severity is what a statement can cost you.

const (
	// SeverityNone is additive: it creates or widens, and cannot fail against data
	// that is already there.
	SeverityNone Severity = ""
	// SeverityDataLoss means rows or column values disappear. This is the class
	// callers gate on.
	SeverityDataLoss Severity = "data_loss"
	// SeverityConstraintLoss means an index, key or constraint disappears. No data
	// is deleted, but a guarantee — and possibly a query plan — is.
	SeverityConstraintLoss Severity = "constraint_loss"
	// SeverityNarrowing means the statement may fail, or truncate, when applied to
	// rows that already exist.
	SeverityNarrowing Severity = "narrowing"
)

type Statement

type Statement struct {
	// Index is 1-based and is execution order.
	Index int `json:"index"`
	// SQL is the statement as it will be sent, without its trailing ";".
	SQL      string   `json:"sql"`
	Kind     Kind     `json:"kind"`
	Severity Severity `json:"severity,omitempty"`
	// Object is a best-effort "table" or "table.column" — for pointing at the
	// thing being lost, not for programmatic use.
	Object string `json:"object,omitempty"`
	// Reason says what this costs, in a sentence a person can act on.
	Reason string `json:"reason,omitempty"`
	// Hazards is every hazard this statement carries, worst first; Kind, Severity,
	// Object and Reason above are the first of them.
	//
	// One bundled ALTER can lose several different things at once — `ALTER TABLE lot
	// DROP KEY …, DROP COLUMN moisture_pct, MODIFY COLUMN warehouse_bin int` drops an
	// index, deletes a column, AND coerces every value of a char column to an int —
	// and the single worst-action reason answered only a third of "what exactly do I
	// lose". The severity a caller GATES on is still the worst one; this is what the
	// reader is owed.
	Hazards []Hazard `json:"hazards,omitempty"`
}

Statement is one fragment of the migration, as it will be executed.

func (Statement) Destructive

func (s Statement) Destructive() bool

Destructive reports whether this statement deletes data.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL