///|
priv enum Command {
Index(Bool)
Reindex(Bool, Bool)
RenameIndex
MoveIndex
LockTable(LockMode, Bool)
Analyze(skip_locked~ : Bool, database_wide~ : Bool)
Cluster(database_wide~ : Bool)
Refresh(concurrent~ : Bool, no_data~ : Bool)
ValidateConstraint
AddIndexConstraint(primary_key~ : Bool, deferrable~ : Bool)
AddForeignKey(not_valid~ : Bool, deferrable~ : Bool, mutates_rows~ : Bool)
SetNotNull
Vacuum(
full~ : Bool,
skip_locked~ : Bool,
truncate~ : Bool,
cleanup_off~ : Bool,
database_wide~ : Bool
)
InvalidIndex(String)
Begin(read_only~ : Bool, deferrable~ : Bool)
Finish(rollback~ : Bool, chain~ : Bool)
SetTimeout(scoped~ : Bool, Timeout)
Savepoint(String)
RollbackTo(String)
Release(String)
Unsupported
}
///|
priv enum Transaction {
Idle
Implicit
Explicit
External
Uncertain
} derive(Eq)
///|
// A deliberately closed grammar for state-changing statements. The dependency
// validates index syntax; unrecognized SQL never becomes an assumed no-op.
fn classify(fragment : Fragment) -> Command {
match fragment.words[:] {
["SET", "LOCAL", "LOCK_TIMEOUT", "=" | "TO", value] =>
match parse_timeout(value) {
Some(t) => SetTimeout(scoped=true, t)
None => Unsupported
}
["SET", "SESSION", "LOCK_TIMEOUT", "=" | "TO", value]
| ["SET", "LOCK_TIMEOUT", "=" | "TO", value] =>
match parse_timeout(value) {
Some(t) => SetTimeout(scoped=false, t)
None => Unsupported
}
["RESET", "LOCK_TIMEOUT" | "ALL"] => SetTimeout(scoped=false, Default)
["SAVEPOINT", name] =>
match savepoint_name(name) {
Some(n) => Savepoint(n)
None => Unsupported
}
["RELEASE", name] | ["RELEASE", "SAVEPOINT", name] =>
match savepoint_name(name) {
Some(n) => Release(n)
None => Unsupported
}
["ROLLBACK", "TO", name] | ["ROLLBACK", "TO", "SAVEPOINT", name] =>
match savepoint_name(name) {
Some(n) => RollbackTo(n)
None => Unsupported
}
["BEGIN", .. rest] => {
let modes = match rest {
["WORK" | "TRANSACTION", .. tail] => tail
_ => rest
}
classify_begin(modes)
}
["START", "TRANSACTION", .. modes] => classify_begin(modes)
["COMMIT" | "END" | "ROLLBACK" | "ABORT", .. rest] => {
let suffix = match rest {
["WORK" | "TRANSACTION", .. tail] => tail
_ => rest
}
let chain = match suffix {
[] | ["AND", "NO", "CHAIN"] => false
["AND", "CHAIN"] => true
_ => return Unsupported
}
Finish(
rollback=fragment.words[0] == "ROLLBACK" || fragment.words[0] == "ABORT",
chain~,
)
}
["DROP", "INDEX", ..] => classify_drop(fragment.words[:])
["ALTER", "INDEX", ..] => classify_alter_index(fragment.words[:])
["ALTER", "TABLE", ..] => classify_alter_table(fragment.words[:])
["REINDEX", ..] => classify_reindex(fragment.words[:])
["LOCK", ..] => classify_lock(fragment.words[:])
["ANALYZE", ..] => classify_analyze(fragment.words[:])
["VACUUM", ..] => classify_vacuum(fragment.words[:])
["CLUSTER", ..] => classify_cluster(fragment.words[:])
["REFRESH", "MATERIALIZED", "VIEW", ..] =>
classify_refresh(fragment.words[:])
["CREATE", ..] => {
let mut depth = 0
for word in fragment.words {
if word == "(" {
depth += 1
} else if word == ")" {
depth -= 1
}
if depth < 0 || depth > 128 {
return Unsupported
}
}
if depth != 0 {
return Unsupported
}
let parsed = @sql.parse_sql(dialect=@sql.Postgres::{ }, fragment.text) catch {
_ => return Unsupported
}
match parsed.stmts[:] {
[@sql.CreateIndex(index)] => Index(index.concurrently)
_ => Unsupported
}
}
_ => Unsupported
}
}
///|
/// Analyze PostgreSQL 18 index statements, maintenance, locks and transactions.
/// The caller must describe dispatch; comments never override the execution mode.
/// A positive max_lock_timeout_ms also enables timeout checking. Invalid limits
/// yield ML011 and an incomplete report without analyzing statements.
/// initial_lock_timeout_ms is a caller-assured session value with no unmodeled
/// SET LOCAL override. Existing-transaction rollback has an unknown earlier
/// baseline. Negative initial values yield ML012 without analyzing statements.
pub fn review_sql(
source : String,
execution~ : Execution,
initial_transaction~ : Bool = false,
require_lock_timeout~ : Bool = false,
max_lock_timeout_ms? : Int32,
initial_lock_timeout_ms? : Int32,
) -> Report {
if max_lock_timeout_ms is Some(limit) && limit <= 0 {
return {
source,
statements: 0,
complete: false,
diagnostics: [
finding(
"ML011",
Incomplete,
{ start: 0, end: source.length(), },
"max_lock_timeout_ms must be a positive 32-bit integer",
"Use a limit from 1 through 2147483647 milliseconds, or omit the maximum.",
),
],
}
}
if initial_lock_timeout_ms is Some(value) && value < 0 {
return {
source,
statements: 0,
complete: false,
diagnostics: [
finding(
"ML012",
Incomplete,
{ start: 0, end: source.length(), },
"initial_lock_timeout_ms must be a nonnegative 32-bit integer",
"Supply an assured session-level value from 0 through 2147483647, or omit it when unknown.",
),
],
}
}
let scanned = scan(source)
let diagnostics = scanned.errors.copy()
let mut complete = diagnostics.is_empty()
let mut tx : Transaction = if execution == Unknown {
Uncertain
} else if execution == ExistingTransaction || initial_transaction {
External
} else {
Idle
}
let batch = execution == SimpleQuery && scanned.fragments.length() > 1
let mut open_span : Span = { start: 0, end: 0, }
let timeouts = TimeoutState::new(initial_lock_timeout_ms)
if execution == Unknown {
complete = false
diagnostics.push(
finding(
"ML010",
Incomplete,
{ start: 0, end: source.length(), },
"Execution mode is unknown",
"Declare separate dispatch, one simple-query message, or an existing transaction.",
),
)
}
for fragment in scanned.fragments {
// PostgreSQL begins an implicit block for each transaction segment in a
// multi-statement simple-query message, including after an explicit COMMIT.
if batch && tx == Idle {
timeouts.begin()
tx = Implicit
}
let command = classify(fragment)
match command {
Begin(read_only~, deferrable~) => {
if read_only {
diagnostics.push(
finding(
"ML130",
Warning,
fragment.span,
"READ ONLY restricts DDL and writes",
"Review DDL permissions and transaction characteristics. This review does not enforce read-only restrictions; nested BEGIN does not change existing transaction characteristics.",
),
)
}
if deferrable {
diagnostics.push(
finding(
"ML131",
Warning,
fragment.span,
"DEFERRABLE can wait for a safe snapshot in a SERIALIZABLE READ ONLY transaction",
"Budget snapshot wait separately from lock_timeout. DEFERRABLE has no effect in other modes; runner defaults and existing transaction characteristics are not inferred.",
),
)
}
if tx == Explicit || tx == External {
diagnostics.push(
finding(
"ML104",
Warning,
fragment.span,
"BEGIN does not create a nested transaction",
"Use SAVEPOINT when a nested rollback boundary is required.",
),
)
} else if tx != Uncertain {
if tx == Idle {
timeouts.begin()
}
tx = Explicit
open_span = fragment.span
}
}
Finish(rollback~, chain~) => {
if chain && (tx == Idle || tx == Implicit) {
diagnostics.push(
finding(
"ML106",
Error,
fragment.span,
"AND CHAIN requires an explicit transaction",
"Begin an explicit transaction before chaining; an implicit simple-query block is insufficient.",
),
)
timeouts.taint()
tx = Uncertain
continue
}
if tx == Uncertain {
timeouts.taint()
} else if tx != Idle {
timeouts.finish(rollback~)
}
if tx == Idle {
diagnostics.push(
finding(
"ML104",
Warning,
fragment.span,
"Transaction end has no active transaction",
"Check the runner's transaction boundary.",
),
)
}
if tx == External {
diagnostics.push(
finding(
"ML105",
Warning,
fragment.span,
"SQL ends the caller's existing transaction",
"Confirm the runner permits migration SQL to close its transaction.",
),
)
}
// Unknown SQL can also change the session; do not restore certainty.
if tx != Uncertain {
if chain {
timeouts.begin()
tx = Explicit
open_span = fragment.span
} else {
tx = Idle
}
}
}
InvalidIndex(message) => {
diagnostics.push(
finding(
"ML107",
Error,
fragment.span,
message,
"DROP INDEX CONCURRENTLY accepts one index and no CASCADE; split names into separately dispatched statements and inspect dependencies.",
),
)
timeouts.taint()
tx = Uncertain
}
LockTable(mode, nowait) => {
if tx == Idle || tx == Uncertain {
let uncertain = tx == Uncertain
diagnostics.push(
finding(
"ML121",
if uncertain {
Incomplete
} else {
Error
},
fragment.span,
if uncertain {
"Cannot establish transaction context for LOCK TABLE"
} else {
"LOCK TABLE requires an active transaction block"
},
"Begin a transaction, declare an existing transaction, or send a multi-statement simple-query message; a separately dispatched idle LOCK fails.",
),
)
if uncertain {
complete = false
}
timeouts.taint()
tx = Uncertain
} else if !nowait &&
(require_lock_timeout || max_lock_timeout_ms is Some(_)) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
diagnostics.push(
finding(
"ML120",
Warning,
fragment.span,
"LOCK TABLE requests \{mode.name()}: \{mode.impact()}",
(if nowait {
"NOWAIT rejects conflicting table-lock acquisition immediately; it does not shorten lock lifetime. "
} else {
"Review lock acquisition waits and acquisition order. "
}) +
"Acquired locks are normally held until transaction end; rollback to an earlier savepoint can release them. Check descendant/view expansion, privileges and workload manually.",
),
)
}
SetNotNull => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
not_null_findings(fragment.span, diagnostics)
}
AddForeignKey(not_valid~, deferrable~, mutates_rows~) => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
foreign_key_findings(
not_valid,
deferrable,
mutates_rows,
fragment.span,
diagnostics,
)
}
AddIndexConstraint(primary_key~, deferrable~) => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
index_constraint_findings(
primary_key,
deferrable,
fragment.span,
diagnostics,
)
}
ValidateConstraint => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
constraint_validation_findings(fragment.span, diagnostics)
}
Refresh(concurrent~, no_data~) =>
if concurrent && no_data {
diagnostics.push(
finding(
"ML173",
Error,
fragment.span,
"CONCURRENTLY and WITH NO DATA cannot be combined",
"Use a populated view with CONCURRENTLY WITH DATA, or a non-concurrent WITH NO DATA operation with a planned reader outage. Resolve this server error before assuming later SQL executes.",
),
)
timeouts.taint()
tx = Uncertain
} else {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
refresh_findings(concurrent, no_data, fragment.span, diagnostics)
}
Cluster(database_wide~) => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
if database_wide && tx != Idle {
let uncertain = tx == Uncertain
diagnostics.push(
finding(
"ML162",
if uncertain {
Incomplete
} else {
Error
},
fragment.span,
if uncertain {
"Cannot establish transaction context for database-wide CLUSTER"
} else {
"Database-wide CLUSTER cannot run in a transaction block"
},
"Dispatch it separately outside explicit/caller transactions and multi-statement simple-query blocks. A named ordinary table can be clustered in a transaction; partitioned tables cannot.",
),
)
if uncertain {
complete = false
}
timeouts.taint()
tx = Uncertain
}
cluster_findings(database_wide, fragment.span, diagnostics)
}
Vacuum(full~, skip_locked~, truncate~, cleanup_off~, database_wide~) => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
if tx != Idle {
let uncertain = tx == Uncertain
diagnostics.push(
finding(
"ML151",
if uncertain {
Incomplete
} else {
Error
},
fragment.span,
if uncertain {
"Cannot establish transaction context for VACUUM"
} else {
"VACUUM cannot run in a transaction block"
},
"Dispatch VACUUM separately outside caller or explicit transactions; multi-statement simple-query messages also form transaction blocks. Resolve coverage before assuming later SQL executes.",
),
)
if uncertain {
complete = false
}
timeouts.taint()
tx = Uncertain
}
diagnostics.push(
finding(
"ML150",
Warning,
fragment.span,
if full {
"VACUUM FULL rewrites the table and requires ACCESS EXCLUSIVE locks"
} else {
"VACUUM performs ordinary maintenance with SHARE UPDATE EXCLUSIVE locks"
},
(if full {
"Plan for blocked reads/writes, rewrite duration and temporary extra disk space. "
} else {
"Review contention with concurrent maintenance/schema changes and tuple/index processing work. "
}) +
(if skip_locked {
"SKIP_LOCKED can omit busy relations and still wait on indexes or sample rows. "
} else {
""
}) +
"lock_timeout bounds individual lock waits, not total maintenance duration. Relation kinds, privileges, workload and reclaimed space are not inferred.",
),
)
if !full && truncate {
diagnostics.push(
finding(
"ML152",
Warning,
fragment.span,
"Ordinary VACUUM may request ACCESS EXCLUSIVE for tail-page truncation",
"TRUNCATE FALSE avoids this truncation lock; omitted TRUNCATE depends on server/table settings. This does not remove other maintenance waits or guarantee returned disk space.",
),
)
}
if !full && cleanup_off {
diagnostics.push(
finding(
"ML153",
Warning,
fragment.span,
"INDEX_CLEANUP OFF skips normal index vacuuming",
"Budget follow-up index cleanup and monitor dead tuples/bloat. AUTO/ON still do not guarantee cleanup: catalog facts and the wraparound failsafe can change actual work.",
),
)
}
if database_wide {
diagnostics.push(
finding(
"ML154",
Warning,
fragment.span,
"VACUUM without a relation list has database-wide scope",
"Name intended relations to bound maintenance work; actual processed relations depend on privileges and catalog state.",
),
)
}
}
Analyze(skip_locked~, database_wide~) => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
diagnostics.push(
finding(
"ML140",
Warning,
fragment.span,
"ANALYZE collects planner statistics and acquires SHARE UPDATE EXCLUSIVE locks",
(if skip_locked {
"SKIP_LOCKED may omit busy relations but can still wait on indexes or sample rows; it does not replace lock_timeout. "
} else {
"Budget lock waits and sampling work. "
}) +
"Review maintenance/schema contention, privileges, relation kind and descendant expansion; statistics and plan changes are not predicted.",
),
)
if database_wide {
diagnostics.push(
finding(
"ML141",
Warning,
fragment.span,
"ANALYZE without a relation list has database-wide scope",
"Name the intended relations and columns to bound maintenance work. Privilege checks may cause relations to be skipped; coverage of database contents is not inferred.",
),
)
}
}
MoveIndex => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
diagnostics.push(
finding(
"ML112",
Warning,
fragment.span,
"Index tablespace move requires an ACCESS EXCLUSIVE lock on the index",
"Review index lock contention, destination CREATE privileges, ownership, free space and partition/temporary-relation behavior. Existing placement and copy work require catalog checks; keep the transaction short.",
),
)
}
RenameIndex => {
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
diagnostics.push(
finding(
"ML110",
Warning,
fragment.span,
"Index rename requires a SHARE UPDATE EXCLUSIVE lock",
"Review lock contention and constraint naming: a linked UNIQUE, PRIMARY KEY or EXCLUDE constraint is renamed too. Catalog existence and target-name collisions are not checked.",
),
)
}
Index(concurrent) | Reindex(concurrent, _) => {
if command is Reindex(_, true) {
diagnostics.push(
finding(
"ML111",
Warning,
fragment.span,
"REINDEX names a target tablespace and may move index storage",
"Check tablespace existence, privileges, free space and partition behavior; these catalog and capacity facts are not inferred.",
),
)
}
if command is Reindex(false, _) && tx != Idle {
diagnostics.push(
finding(
"ML108",
Warning,
fragment.span,
"Non-concurrent REINDEX in a transaction requires a non-partitioned index",
"Check the relation kind in the catalog; partitioned indexes cannot be reindexed in a transaction block.",
),
)
}
if require_lock_timeout || max_lock_timeout_ms is Some(_) {
timeouts.check(fragment.span, diagnostics, max_lock_timeout_ms)
}
if concurrent {
if tx == Uncertain {
complete = false
diagnostics.push(
finding(
"ML102",
Incomplete,
fragment.span,
"Cannot establish transaction context for concurrent index operation",
"Resolve preceding unsupported SQL and declare the execution mode.",
),
)
} else if tx != Idle {
diagnostics.push(
finding(
"ML101",
Error,
fragment.span,
"Concurrent index operation cannot run in this transaction context",
"Dispatch outside a transaction; a multi-statement simple-query message also forms a transaction.",
),
)
}
} else {
diagnostics.push(
finding(
"ML100",
Warning,
fragment.span,
"Ordinary index operation can block concurrent access",
"Check relation kind and lock impact; do not blindly add CONCURRENTLY.",
),
)
}
}
SetTimeout(scoped~, value) =>
if tx == Uncertain {
timeouts.taint()
} else if scoped {
if tx == Idle {
diagnostics.push(
finding(
"ML200",
Warning,
fragment.span,
"SET LOCAL outside a transaction has no effect",
"Use a session setting for separately dispatched concurrent index operations.",
),
)
} else {
timeouts.scoped = Some(value)
}
} else {
timeouts.session = value
timeouts.scoped = None
}
Savepoint(name) | RollbackTo(name) | Release(name) =>
if tx != Explicit && tx != External {
let uncertain = tx == Uncertain
diagnostics.push(
finding(
"ML203",
if uncertain {
Incomplete
} else {
Error
},
fragment.span,
"Savepoint operation requires an explicit transaction",
"Declare or begin an explicit transaction; an implicit simple-query block is insufficient.",
),
)
if uncertain {
complete = false
}
timeouts.taint()
tx = Uncertain
} else if command is Savepoint(_) {
timeouts.mark(name)
} else if !timeouts.restore(name, release=command is Release(_)) {
let external = tx == External
diagnostics.push(
finding(
"ML204",
if external {
Incomplete
} else {
Error
},
fragment.span,
"Savepoint target has not been established by this input",
"Use an existing savepoint name; external savepoint state is not inferred.",
),
)
if external {
complete = false
}
timeouts.taint()
tx = Uncertain
}
Unsupported => {
timeouts.taint()
complete = false
tx = Uncertain
diagnostics.push(
finding(
"ML003",
Incomplete,
fragment.span,
"Unsupported or malformed SQL statement",
"Review manually; downstream transaction context is now unknown.",
),
)
}
}
}
if tx == Explicit {
diagnostics.push(
finding(
"ML103",
Warning,
open_span,
"Explicit transaction remains open at end of input",
"Include COMMIT/ROLLBACK or document an enclosing workflow.",
),
)
}
{ source, statements: scanned.fragments.length(), complete, diagnostics, }
}