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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All changes that a user can see are in this file. The version is 0.M.patch, wher

- `EXPLAIN` in the text, JSON, XML and YAML forms, with a planner that makes the plan of a query with the costs of PostgreSQL (#439).
- `ALTER DATABASE ... SET` and `RESET`, with the settings applied at the start of each session of the database and shown in `pg_db_role_setting` (#440).
- `COPY ... FROM 'file'`, which reads a file of the server, for a superuser (#441).

## 0.1.51 (2026-10-20)

Expand Down
2 changes: 1 addition & 1 deletion crates/rupg-analyze/src/ddl/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ pub fn attnums(columns: &[String], names: &[String], rel: Option<&str>) -> Resul
}

/// The names of the user columns of a relation of the catalog or of the built-in catalog.
fn relation_columns(env: &dyn Env, relid: u32) -> Vec<String> {
pub(super) fn relation_columns(env: &dyn Env, relid: u32) -> Vec<String> {
if let Some(rel) = env.catalog().and_then(|c| c.relation(relid)) {
return rel.columns.iter().filter(|c| !c.dropped).map(|c| c.name.clone()).collect();
}
Expand Down
2 changes: 1 addition & 1 deletion crates/rupg-analyze/src/ddl/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ pub(crate) fn relkind(env: &dyn Env, relid: u32) -> u8 {
}

/// The name of a relation of the catalog or of the built-in catalog.
fn class_name(env: &dyn Env, relid: u32) -> String {
pub(super) fn class_name(env: &dyn Env, relid: u32) -> String {
match env.catalog().and_then(|c| c.relation(relid)) {
Some(rel) => rel.name.clone(),
None => builtin::named_by_oid(Named::Class, relid)
Expand Down
2 changes: 2 additions & 0 deletions crates/rupg-analyze/src/ddl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod tests;
mod truncate;
mod tsearch;
mod type_change;
mod vacuum;
mod view;

pub use copy::{
Expand All @@ -44,6 +45,7 @@ pub(crate) use lock::relkind;
pub use lock::{Allowed, lock_table};
pub use rule::{RuleTree, rule_tree};
pub(crate) use table::check_attribute_type;
pub use vacuum::vacuum_relations;

use rupg_catalog::{Catalog, ObjRef, PG_CLASS, PG_TYPE, RelKind};
use rupg_common::{Error, Result, SqlState};
Expand Down
81 changes: 81 additions & 0 deletions crates/rupg-analyze/src/ddl/vacuum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! The relations of `VACUUM` and `ANALYZE`, as `expand_vacuum_rel` and `vacuum_rel` of `vacuum.c` and `analyze_rel` of `analyze.c` check them. rupg keeps no dead row versions and no statistics yet, so the statement checks the names and changes nothing.

use rupg_common::{Error, Result, SqlState};
use rupg_sql::nodes::{List, Node};

use super::copy::relation_columns;
use super::lock::{class_name, lookup, relkind};
use crate::{Analyzer, Env, Params};

/// The OID of `pg_statistic`, which `analyze_rel` skips.
const PG_STATISTIC: u32 = 2619;

/// `vacuum` for the relations `rels` of a `VacuumStmt`, after `ExecVacuum` has read the options. `vacuum` is true for `VACUUM`, and `analyze` is true for `ANALYZE` or for `VACUUM` with the option `ANALYZE`. The warnings for the relations that the statement skips go to `warnings`, also when a later relation gives an error.
///
/// # Errors
///
/// The errors of `RangeVarGetRelid` for a name, and `42703` and `42701` for a column list of `ANALYZE` with a name that is not a column or that the list gives twice.
pub fn vacuum_relations(
rels: &List,
env: &dyn Env,
vacuum: bool,
analyze: bool,
warnings: &mut Vec<Error>,
) -> Result<()> {
let params = Params::default();
let an = Analyzer::new(env, &params);
let mut found = Vec::new();
for node in rels.iter().flatten() {
let Node::VacuumRelation(vrel) = node else { continue };
let Some(rv) = &vrel.relation else { continue };
found.push((lookup(&an, rv)?, &vrel.va_cols));
}
for (relid, va_cols) in found {
let kind = relkind(env, relid);
let name = class_name(env, relid);
if vacuum && !matches!(kind, b'r' | b'm' | b't' | b'p') {
warnings.push(skipping(&name, "vacuum"));
continue;
}
if !analyze || relid == PG_STATISTIC {
continue;
}
check_columns(env, relid, &name, va_cols)?;
if !vacuum && !matches!(kind, b'r' | b'm' | b'p') {
warnings.push(skipping(&name, "analyze"));
}
}
Ok(())
}

/// `validate_va_cols_list`: each name of the list is a user column of the relation, and no name comes twice.
fn check_columns(env: &dyn Env, relid: u32, rel: &str, va_cols: &List) -> Result<()> {
let columns = relation_columns(env, relid);
let mut seen = Vec::new();
for node in va_cols.iter().flatten() {
let Node::String(name) = node else { continue };
let name: &str = name;
let Some(at) = columns.iter().position(|c| c == name) else {
return Err(Error::new(
SqlState::UNDEFINED_COLUMN,
format!("column \"{name}\" of relation \"{rel}\" does not exist"),
));
};
if seen.contains(&at) {
return Err(Error::new(
SqlState::DUPLICATE_COLUMN,
format!("column \"{name}\" of relation \"{rel}\" appears more than once"),
));
}
seen.push(at);
}
Ok(())
}

/// The warning of `vacuum_rel` or `analyze_rel` for a relation that is not a table.
fn skipping(name: &str, what: &str) -> Error {
Error::new(
SqlState::WARNING,
format!("skipping \"{name}\" --- cannot {what} non-tables or special system tables"),
)
}
2 changes: 1 addition & 1 deletion crates/rupg-analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub use cte::{Cte, CteRef};
pub use ddl::{
Allowed, Body, CopyRelation, CopyTarget, Defined, Message, RuleTree, Transform, attnums,
copy_filter, copy_insert, copy_relation, copy_target, define, function_body, is_definition,
lock_table, rewrite_insert, rule_tree,
lock_table, rewrite_insert, rule_tree, vacuum_relations,
};
pub use expr::{
Aggref, BoolOp, BoolTest, Case, CastForm, Expr, ExprKind, Func, FuncForm, GroupingFunc, IsJson,
Expand Down
10 changes: 10 additions & 0 deletions crates/rupg-session/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod prepare;
mod rewrite;
mod savepoint;
mod upsert;
mod vacuum;
mod writes;

pub(crate) use activity::Activity;
Expand Down Expand Up @@ -1301,6 +1302,9 @@ impl Connection {
} else if let Node::AlterDatabaseSetStmt(stmt) = node {
drop(self.portals.close(b""));
self.alter_database_set(stmt)
} else if let Node::VacuumStmt(stmt) = node {
drop(self.portals.close(b""));
self.vacuum(stmt, &mut notices)
} else if let Node::DeclareCursorStmt(stmt) = node {
drop(self.portals.close(b""));
self.declare(stmt, text, None, &[], &mut notices)
Expand Down Expand Up @@ -3493,6 +3497,12 @@ mod tests {
big_stack(|| script(include_str!("connection/create_database.test")));
}

/// `VACUUM` and `ANALYZE`: the options and their checks, the column lists, a view, a sequence, an index and a view of the catalog that the statement skips, names that do not exist, a catalog, and a transaction block. `connection/vacuum.test` is the output of PostgreSQL 19 for the same script in a new database.
#[test]
fn vacuum() {
big_stack(|| script(include_str!("connection/vacuum.test")));
}

/// `ALTER DATABASE ... SET`: the checks of the names and the values, and the rows of `pg_db_role_setting`. `connection/alter_database_set.test` is the output of PostgreSQL 19 for the same script in a new database.
#[test]
fn alter_database_set() {
Expand Down
4 changes: 2 additions & 2 deletions crates/rupg-session/src/connection/databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ fn not_supported(what: String) -> Error {
}

impl Connection {
/// `PreventInTransactionBlock` for `CREATE DATABASE` and `DROP DATABASE`. The caller commits the statement at once, also in the extended protocol.
fn prevent_block(&self, statement: &str) -> Result<(), Error> {
/// `PreventInTransactionBlock` for `CREATE DATABASE`, `DROP DATABASE` and `VACUUM`. The caller commits the statement at once, also in the extended protocol.
pub(super) fn prevent_block(&self, statement: &str) -> Result<(), Error> {
if self.transaction.in_block() {
return Err(Error::new(
SqlState::ACTIVE_SQL_TRANSACTION,
Expand Down
9 changes: 9 additions & 0 deletions crates/rupg-session/src/connection/extended.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,15 @@ impl Connection {
position: error.position().map(|at| character_position(&statement.text, at)),
error,
})?
} else if let Node::VacuumStmt(stmt) = node {
let mut notices = Vec::new();
let result = self.vacuum(stmt, &mut notices);
self.notices(&mut notices, &statement.text, &[], out);
immediate_commit = stmt.is_vacuumcmd;
result.map_err(|error| Failed {
position: error.position().map(|at| character_position(&statement.text, at)),
error,
})?
} else if let Node::AlterDatabaseSetStmt(stmt) = node {
self.alter_database_set(stmt)?
} else {
Expand Down
193 changes: 193 additions & 0 deletions crates/rupg-session/src/connection/vacuum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//! `VACUUM` and `ANALYZE`, as `ExecVacuum` and `vacuum` of `vacuum.c` run them: the options, the checks of the options and of the relations, and the warnings for the relations that the statement skips. rupg keeps no dead row versions and no statistics yet, so the statement changes nothing.

use rupg_common::{Error, SqlState};
use rupg_sql::nodes::{DefElem, Node, VacuumStmt};
use rupg_wire::CommandTag;

use super::copy::{def_boolean, def_string};
use super::{Connection, Done};
use crate::guc;
use crate::utility::Notice;

/// `MIN_BAS_VAC_RING_SIZE_KB` and `MAX_BAS_VAC_RING_SIZE_KB`.
const RING_SIZE_KB: (i32, i32) = (128, 16 * 1024 * 1024);

/// `MAX_PARALLEL_WORKER_LIMIT`.
const MAX_PARALLEL_WORKERS: i32 = 1024;

/// The options of `VACUUM` and `ANALYZE` that the checks of `ExecVacuum` read.
#[derive(Debug, Default)]
struct Parsed {
analyze: bool,
freeze: bool,
full: bool,
disable_page_skipping: bool,
process_toast: bool,
only_database_stats: bool,
/// True when a flag of `params.options` other than `VERBOSE`, `PROCESS_MAIN` and `PROCESS_TOAST` is on, for the check of `ONLY_DATABASE_STATS`.
other: bool,
workers: i32,
ring_size: Option<i32>,
}

/// `defGetInt32`.
fn def_int(def: &DefElem) -> Result<i32, Error> {
match def.arg {
Some(Node::Integer(n)) => Ok(n),
_ => {
let name = def.defname.as_deref().unwrap_or_default();
Err(Error::new(SqlState::SYNTAX_ERROR, format!("{name} requires an integer value")))
}
}
}

/// The option list of `ExecVacuum`.
fn parse_options(stmt: &VacuumStmt) -> Result<Parsed, Error> {
let command = if stmt.is_vacuumcmd { "VACUUM" } else { "ANALYZE" };
let mut parsed = Parsed { process_toast: true, ..Parsed::default() };
for def in stmt.options.iter().flatten() {
let Node::DefElem(def) = def else { continue };
let name = def.defname.as_deref().unwrap_or_default();
let at = usize::try_from(def.location).ok();
match name {
"verbose" | "skip_locked" => {
let on = def_boolean(def)?;
parsed.other |= on && name == "skip_locked";
}
"buffer_usage_limit" => {
let text = def_string(def)?;
let limit = guc::find("vacuum_buffer_usage_limit")
.ok_or_else(|| Error::internal("no parameter vacuum_buffer_usage_limit"));
let value = limit?.parse_int(&text);
let (min, max) = RING_SIZE_KB;
match value {
Ok(size) if size == 0 || (min..=max).contains(&size) => {
parsed.ring_size = Some(size);
}
Ok(_) | Err(_) => {
let error = Error::new(
SqlState::INVALID_PARAMETER_VALUE,
format!(
"BUFFER_USAGE_LIMIT option must be 0 or between {min} kB and {max} kB"
),
);
return Err(match value {
Err(Some(hint)) => error.with_hint(hint),
_ => error,
});
}
}
}
_ if !stmt.is_vacuumcmd => return Err(unrecognized(command, name, at)),
"analyze" => parsed.analyze = def_boolean(def)?,
"freeze" => parsed.freeze = def_boolean(def)?,
"full" => parsed.full = def_boolean(def)?,
"disable_page_skipping" => parsed.disable_page_skipping = def_boolean(def)?,
"index_cleanup" => {
if def.arg.is_some() && !def_string(def)?.eq_ignore_ascii_case("auto") {
def_boolean(def)?;
}
}
"process_main" => {
def_boolean(def)?;
}
"process_toast" => parsed.process_toast = def_boolean(def)?,
"truncate" => {
def_boolean(def)?;
}
"parallel" => {
let workers = def_int(def)?;
if !(0..=MAX_PARALLEL_WORKERS).contains(&workers) {
return Err(Error::new(
SqlState::SYNTAX_ERROR,
format!("PARALLEL option must be between 0 and {MAX_PARALLEL_WORKERS}"),
)
.with_position(at));
}
parsed.workers = workers;
}
"skip_database_stats" => parsed.other |= def_boolean(def)?,
"only_database_stats" => parsed.only_database_stats = def_boolean(def)?,
_ => return Err(unrecognized(command, name, at)),
}
}
parsed.other |= parsed.analyze || parsed.freeze || parsed.full || parsed.disable_page_skipping;
Ok(parsed)
}

/// The error of an option that the command does not have.
fn unrecognized(command: &str, name: &str, at: Option<usize>) -> Error {
Error::new(SqlState::SYNTAX_ERROR, format!("unrecognized {command} option \"{name}\""))
.with_position(at)
}

/// The checks of `ExecVacuum` on the options together.
fn check_options(stmt: &VacuumStmt, parsed: &Parsed) -> Result<(), Error> {
let not_supported = |message: &str| Error::new(SqlState::FEATURE_NOT_SUPPORTED, message);
if parsed.full && parsed.workers > 0 {
return Err(not_supported("VACUUM FULL cannot be performed in parallel"));
}
if parsed.ring_size.is_some() && parsed.full && !parsed.analyze {
return Err(not_supported("BUFFER_USAGE_LIMIT cannot be specified for VACUUM FULL"));
}
let analyze = parsed.analyze || !stmt.is_vacuumcmd;
let columns = stmt.rels.iter().flatten().any(|node| match node {
Node::VacuumRelation(vrel) => !vrel.va_cols.is_empty(),
_ => false,
});
if !analyze && columns {
return Err(not_supported(
"ANALYZE option must be specified when a column list is provided",
));
}
if parsed.full && parsed.disable_page_skipping {
return Err(not_supported("VACUUM option DISABLE_PAGE_SKIPPING cannot be used with FULL"));
}
if parsed.full && !parsed.process_toast {
return Err(not_supported("PROCESS_TOAST required with VACUUM FULL"));
}
if parsed.only_database_stats {
if !stmt.rels.is_empty() {
return Err(not_supported(
"ONLY_DATABASE_STATS cannot be specified with a list of tables",
));
}
if parsed.other {
return Err(not_supported(
"ONLY_DATABASE_STATS cannot be specified with other VACUUM options",
));
}
}
Ok(())
}

impl Connection {
/// `ExecVacuum`, then `vacuum`: `PreventInTransactionBlock` for `VACUUM`, and the checks of [`rupg_analyze::vacuum_relations`] on each relation of the statement. The session has only a superuser role, so the check of `vacuum_is_permitted_for_relation` always passes.
pub(super) fn vacuum(
&self,
stmt: &VacuumStmt,
notices: &mut Vec<Notice>,
) -> Result<Done, Error> {
let parsed = parse_options(stmt)?;
check_options(stmt, &parsed)?;
let tag = if stmt.is_vacuumcmd {
self.prevent_block("VACUUM")?;
CommandTag::Vacuum
} else {
CommandTag::Analyze
};
let analyze = parsed.analyze || !stmt.is_vacuumcmd;
let reader = self.reader();
let mut warnings = Vec::new();
let result = rupg_analyze::vacuum_relations(
&stmt.rels,
&reader,
stmt.is_vacuumcmd,
analyze,
&mut warnings,
);
notices.extend(warnings.into_iter().map(Notice::warning));
result?;
Ok(Done::Tag(tag))
}
}
Loading
Loading