1use crate::{
9 ast::{VacuumOption, VacuumOptionValue, VacuumStmt},
10 SQLError,
11};
12use std::collections::BTreeSet;
13pub struct VacuumOptions {
14 flags: VacuumFlags,
15}
16pub struct ResolvedVacuumTarget {
17 pub table: String,
18 pub include_descendants: bool,
19 pub columns: Vec<String>,
20}
21pub trait VacuumRelation {
22 fn column_names(&self) -> BTreeSet<String>;
23}
24pub trait VacuumCatalog {
25 fn resolve_relation_kind(&self, name: &str)
26 -> Result<Option<(String, &'static str)>, SQLError>;
27 fn require_table(&self, name: &str) -> Result<Box<dyn VacuumRelation + '_>, SQLError>;
28}
29pub trait VacuumPrivileges {
30 fn ensure_maintain(&self, table: &str) -> Result<(), SQLError>;
31}
32#[derive(Clone, Copy, Default)]
33struct VacuumFlags(u8);
34
35impl VacuumFlags {
36 const ANALYZE: u8 = 1 << 0;
37 const FULL: u8 = 1 << 1;
38 const DISABLE_PAGE_SKIPPING: u8 = 1 << 2;
39 const PROCESS_TOAST: u8 = 1 << 3;
40 const ONLY_DATABASE_STATS: u8 = 1 << 4;
41 const ONLY_DATABASE_STATS_CONFLICT: u8 = 1 << 5;
42
43 fn insert(&mut self, flag: u8, enabled: bool) {
44 if enabled {
45 self.0 |= flag;
46 }
47 }
48
49 pub const fn contains(self, flag: u8) -> bool {
50 self.0 & flag != 0
51 }
52}
53
54impl VacuumOptions {
55 pub const fn analyze(&self) -> bool {
56 self.flags.contains(VacuumFlags::ANALYZE)
57 }
58
59 pub const fn full(&self) -> bool {
60 self.flags.contains(VacuumFlags::FULL)
61 }
62
63 pub const fn disable_page_skipping(&self) -> bool {
64 self.flags.contains(VacuumFlags::DISABLE_PAGE_SKIPPING)
65 }
66
67 pub const fn process_toast(&self) -> bool {
68 self.flags.contains(VacuumFlags::PROCESS_TOAST)
69 }
70
71 pub const fn only_database_stats(&self) -> bool {
72 self.flags.contains(VacuumFlags::ONLY_DATABASE_STATS)
73 }
74
75 pub const fn has_only_database_stats_conflict(&self) -> bool {
76 self.flags
77 .contains(VacuumFlags::ONLY_DATABASE_STATS_CONFLICT)
78 }
79}
80
81fn vacuum_syntax_error(message: impl Into<String>) -> SQLError {
82 SQLError::Routine {
83 sqlstate: "42601".into(),
84 message: message.into(),
85 }
86}
87
88fn invalid_buffer_usage_limit() -> SQLError {
89 SQLError::Routine {
90 sqlstate: "22023".into(),
91 message: "BUFFER_USAGE_LIMIT option must be 0 or between 128 kB and 16777216 kB".into(),
92 }
93}
94
95fn vacuum_feature_error(message: impl Into<String>) -> SQLError {
96 SQLError::Routine {
97 sqlstate: "0A000".into(),
98 message: message.into(),
99 }
100}
101
102fn buffer_usage_limit_kib(value: &VacuumOptionValue) -> Result<u64, SQLError> {
103 let (amount, multiplier) = match value {
104 VacuumOptionValue::Integer(value) => {
105 let amount = u64::try_from(*value).map_err(|_| invalid_buffer_usage_limit())?;
106 return Ok(amount);
107 }
108 VacuumOptionValue::String(value) => {
109 let value = value.trim();
110 let split = value
111 .find(|character: char| !(character.is_ascii_digit() || character == '.'))
112 .unwrap_or(value.len());
113 let amount = value[..split]
114 .parse::<f64>()
115 .map_err(|_| invalid_buffer_usage_limit())?;
116 let unit = value[split..].trim().to_ascii_lowercase();
117 let multiplier = match unit.as_str() {
118 "" | "kb" => 1_f64,
119 "mb" => 1024_f64,
120 "gb" => 1024_f64 * 1024_f64,
121 "tb" => 1024_f64 * 1024_f64 * 1024_f64,
122 _ => return Err(invalid_buffer_usage_limit()),
123 };
124 (amount, multiplier)
125 }
126 VacuumOptionValue::Boolean(_) => return Err(invalid_buffer_usage_limit()),
127 };
128 let kib = amount * multiplier;
129 if !kib.is_finite() || kib < 0.0 || kib.round() > u64::MAX as f64 {
130 return Err(invalid_buffer_usage_limit());
131 }
132 Ok(kib.round() as u64)
133}
134
135fn boolean_option(option: &VacuumOption) -> Result<bool, SQLError> {
136 let Some(value) = option.value.as_ref() else {
137 return Ok(true);
138 };
139 match value {
140 VacuumOptionValue::Boolean(value) => Ok(*value),
141 VacuumOptionValue::String(value) => match value.to_ascii_lowercase().as_str() {
142 "true" | "on" => Ok(true),
143 "false" | "off" => Ok(false),
144 _ => Err(vacuum_syntax_error(format!(
145 "{} requires a Boolean value",
146 option.name
147 ))),
148 },
149 VacuumOptionValue::Integer(0) => Ok(false),
150 VacuumOptionValue::Integer(1) => Ok(true),
151 VacuumOptionValue::Integer(_) => Err(vacuum_syntax_error(format!(
152 "{} requires a Boolean value",
153 option.name
154 ))),
155 }
156}
157
158#[expect(
159 clippy::too_many_lines,
160 reason = "preserves VACUUM option validation order"
161)]
162fn validate_options(options: &[VacuumOption]) -> Result<VacuumOptions, SQLError> {
163 let mut analyze = false;
164 let mut full = false;
165 let mut parallel = 0_i64;
166 let mut buffer_usage_limit_specified = false;
167 let mut disable_page_skipping = false;
168 let mut process_toast = true;
169 let mut only_database_stats = false;
170 let mut effective_options = BTreeSet::new();
171 for option in options {
172 match option.name.as_str() {
173 "analyze" => {
174 analyze = boolean_option(option)?;
175 if analyze {
176 effective_options.insert(option.name.as_str());
177 } else {
178 effective_options.remove(option.name.as_str());
179 }
180 }
181 "full" => {
182 full = boolean_option(option)?;
183 if full {
184 effective_options.insert(option.name.as_str());
185 } else {
186 effective_options.remove(option.name.as_str());
187 }
188 }
189 "only_database_stats" => only_database_stats = boolean_option(option)?,
190 "freeze" | "skip_locked" | "skip_database_stats" => {
191 if boolean_option(option)? {
192 effective_options.insert(option.name.as_str());
193 } else {
194 effective_options.remove(option.name.as_str());
195 }
196 }
197 "disable_page_skipping" => {
198 disable_page_skipping = boolean_option(option)?;
199 if disable_page_skipping {
200 effective_options.insert(option.name.as_str());
201 } else {
202 effective_options.remove(option.name.as_str());
203 }
204 }
205 "process_toast" => process_toast = boolean_option(option)?,
206 "verbose" | "process_main" | "truncate" => {
208 boolean_option(option)?;
209 }
210 "index_cleanup" => {
211 if !matches!(
212 option.value.as_ref(),
213 Some(VacuumOptionValue::String(value)) if value.eq_ignore_ascii_case("auto")
214 ) {
215 boolean_option(option)?;
216 }
217 }
218 "parallel" => {
219 let Some(VacuumOptionValue::Integer(workers)) = option.value.as_ref() else {
220 return Err(vacuum_syntax_error(
221 "parallel requires a non-negative integer value",
222 ));
223 };
224 if !(0..=1024).contains(workers) {
225 return Err(vacuum_syntax_error(
226 "parallel workers for vacuum must be between 0 and 1024",
227 ));
228 }
229 parallel = i64::from(*workers);
230 }
231 "buffer_usage_limit" => {
232 buffer_usage_limit_specified = true;
233 let kib = option
234 .value
235 .as_ref()
236 .ok_or_else(invalid_buffer_usage_limit)
237 .and_then(buffer_usage_limit_kib)?;
238 if kib != 0 && !(128..=16_777_216).contains(&kib) {
239 return Err(invalid_buffer_usage_limit());
240 }
241 }
242 name => {
243 return Err(vacuum_syntax_error(format!(
244 "unrecognized VACUUM option \"{name}\""
245 )));
246 }
247 }
248 }
249 if full && parallel > 0 {
250 return Err(vacuum_feature_error(
251 "VACUUM FULL cannot be performed in parallel",
252 ));
253 }
254 if buffer_usage_limit_specified && full && !analyze {
255 return Err(vacuum_feature_error(
256 "BUFFER_USAGE_LIMIT cannot be specified for VACUUM FULL",
257 ));
258 }
259 let mut flags = VacuumFlags::default();
260 flags.insert(VacuumFlags::ANALYZE, analyze);
261 flags.insert(VacuumFlags::FULL, full);
262 flags.insert(VacuumFlags::DISABLE_PAGE_SKIPPING, disable_page_skipping);
263 flags.insert(VacuumFlags::PROCESS_TOAST, process_toast);
264 flags.insert(VacuumFlags::ONLY_DATABASE_STATS, only_database_stats);
265 flags.insert(
266 VacuumFlags::ONLY_DATABASE_STATS_CONFLICT,
267 !effective_options.is_empty(),
268 );
269 Ok(VacuumOptions { flags })
270}
271
272pub fn analyze_vacuum(statement: &VacuumStmt) -> Result<VacuumOptions, SQLError> {
273 let execution = validate_options(&statement.options)?;
274 if !execution.analyze()
275 && statement
276 .targets
277 .iter()
278 .any(|target| !target.columns.is_empty())
279 {
280 return Err(SQLError::Routine {
281 sqlstate: "0A000".into(),
282 message: "ANALYZE option must be specified when a column list is provided".into(),
283 });
284 }
285 if execution.full() && execution.disable_page_skipping() {
286 return Err(vacuum_feature_error(
287 "VACUUM option DISABLE_PAGE_SKIPPING cannot be used with FULL",
288 ));
289 }
290 if execution.full() && !execution.process_toast() {
291 return Err(vacuum_feature_error(
292 "PROCESS_TOAST required with VACUUM FULL",
293 ));
294 }
295 if execution.only_database_stats() && !statement.targets.is_empty() {
296 return Err(vacuum_feature_error(
297 "ONLY_DATABASE_STATS cannot be specified with a list of tables",
298 ));
299 }
300 if execution.only_database_stats() && execution.has_only_database_stats_conflict() {
301 return Err(vacuum_feature_error(
302 "ONLY_DATABASE_STATS cannot be specified with other VACUUM options",
303 ));
304 }
305
306 Ok(execution)
307}
308pub fn bind_vacuum_targets(
309 catalog: &dyn VacuumCatalog,
310 privileges: &dyn VacuumPrivileges,
311 statement: &VacuumStmt,
312) -> Result<Vec<ResolvedVacuumTarget>, SQLError> {
313 let mut resolved_targets = Vec::with_capacity(statement.targets.len());
314 for target in &statement.targets {
315 if target
316 .catalog
317 .as_deref()
318 .is_some_and(|catalog| catalog != "uqa")
319 {
320 let qualified = format!(
321 "{}.{}",
322 target.catalog.as_deref().expect("checked catalog"),
323 target.table
324 );
325 return Err(SQLError::Routine {
326 sqlstate: "0A000".into(),
327 message: format!("cross-database references are not implemented: \"{qualified}\""),
328 });
329 }
330 let canonical = match catalog.resolve_relation_kind(&target.table)? {
331 Some((canonical, "table")) => canonical,
332 Some(_) | None => return Err(SQLError::UnknownTable(target.table.clone())),
333 };
334 let table = catalog.require_table(&canonical)?;
335 if !target.columns.is_empty() {
336 let available = table.column_names();
337 if let Some(column) = target
338 .columns
339 .iter()
340 .find(|column| !available.contains(column.as_str()))
341 {
342 return Err(SQLError::Routine {
343 sqlstate: "42703".into(),
344 message: format!(
345 "column \"{column}\" of relation \"{}\" does not exist",
346 target.table
347 ),
348 });
349 }
350 }
351 privileges.ensure_maintain(&canonical)?;
352 resolved_targets.push(ResolvedVacuumTarget {
353 table: canonical,
354 include_descendants: target.include_descendants,
355 columns: target.columns.clone(),
356 });
357 }
358
359 Ok(resolved_targets)
360}