Skip to main content

pgevolve_core/ir/
reloptions.rs

1//! Storage parameters / reloptions for Table, Index, `MaterializedView`.
2//!
3//! Typed fields for well-known keys + `extra: BTreeMap<String, String>` for
4//! extension-registered or otherwise-unknown options. Tables and MVs share
5//! the autovacuum substruct because PG documents identical key sets.
6
7use std::cmp::Ordering;
8use std::collections::BTreeMap;
9use std::hash::{Hash, Hasher};
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14/// `f64` wrapper that excludes NaN — required so `Option<f64>` reloptions
15/// can participate in `Eq` / `Hash` / `Ord` derived implementations.
16///
17/// The catalog reader and source parser both reject NaN values explicitly.
18#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
19pub struct NotNanF64(f64);
20
21impl NotNanF64 {
22    /// Construct, rejecting NaN.
23    ///
24    /// # Errors
25    ///
26    /// Returns the input value when NaN.
27    pub const fn new(v: f64) -> Result<Self, f64> {
28        if v.is_nan() { Err(v) } else { Ok(Self(v)) }
29    }
30
31    /// Return the inner `f64` value.
32    #[must_use]
33    pub const fn get(self) -> f64 {
34        self.0
35    }
36}
37
38impl PartialEq for NotNanF64 {
39    fn eq(&self, other: &Self) -> bool {
40        self.0.to_bits() == other.0.to_bits()
41    }
42}
43impl Eq for NotNanF64 {}
44impl Hash for NotNanF64 {
45    fn hash<H: Hasher>(&self, state: &mut H) {
46        self.0.to_bits().hash(state);
47    }
48}
49impl PartialOrd for NotNanF64 {
50    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
51        Some(self.cmp(other))
52    }
53}
54impl Ord for NotNanF64 {
55    fn cmp(&self, other: &Self) -> Ordering {
56        // Safe because NaN is excluded by construction.
57        self.0.partial_cmp(&other.0).unwrap_or(Ordering::Equal)
58    }
59}
60
61/// Shared autovacuum options — apply to both `Table` and `MaterializedView`.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
63pub struct AutovacuumOptions {
64    /// `autovacuum_enabled` — disable autovacuum for this relation.
65    pub enabled: Option<bool>,
66    /// `autovacuum_vacuum_threshold` — minimum tuple updates/deletes before vacuum.
67    pub vacuum_threshold: Option<u64>,
68    /// `autovacuum_vacuum_scale_factor` — fraction of table size added to threshold.
69    pub vacuum_scale_factor: Option<NotNanF64>,
70    /// `autovacuum_vacuum_cost_delay` — cost delay in milliseconds.
71    pub vacuum_cost_delay: Option<u64>,
72    /// `autovacuum_vacuum_cost_limit` — cost limit before napping.
73    pub vacuum_cost_limit: Option<u64>,
74    /// `autovacuum_analyze_threshold` — minimum tuple inserts/updates/deletes before analyze.
75    pub analyze_threshold: Option<u64>,
76    /// `autovacuum_analyze_scale_factor` — fraction of table size added to analyze threshold.
77    pub analyze_scale_factor: Option<NotNanF64>,
78    /// `autovacuum_freeze_max_age` — max age before forced vacuum to prevent wraparound.
79    pub freeze_max_age: Option<u64>,
80    /// `autovacuum_freeze_min_age` — min age before freezing tuples.
81    pub freeze_min_age: Option<u64>,
82    /// `autovacuum_freeze_table_age` — table age at which whole-table freeze scan runs.
83    pub freeze_table_age: Option<u64>,
84    /// `autovacuum_multixact_freeze_max_age` — max multixact age before forced vacuum.
85    pub multixact_freeze_max_age: Option<u64>,
86    /// `autovacuum_multixact_freeze_min_age` — min multixact age before freezing.
87    pub multixact_freeze_min_age: Option<u64>,
88    /// `autovacuum_multixact_freeze_table_age` — table multixact age before whole-table scan.
89    pub multixact_freeze_table_age: Option<u64>,
90    /// `autovacuum_vacuum_insert_threshold` (PG 13+).
91    pub vacuum_insert_threshold: Option<u64>,
92    /// `autovacuum_vacuum_insert_scale_factor` (PG 13+).
93    pub vacuum_insert_scale_factor: Option<NotNanF64>,
94    /// `log_autovacuum_min_duration` — `-1` disables. Stored as i64.
95    pub log_min_duration: Option<i64>,
96}
97
98impl AutovacuumOptions {
99    /// `true` iff every field is `None`. Used by the differ to short-circuit.
100    #[must_use]
101    pub const fn is_empty(&self) -> bool {
102        self.enabled.is_none()
103            && self.vacuum_threshold.is_none()
104            && self.vacuum_scale_factor.is_none()
105            && self.vacuum_cost_delay.is_none()
106            && self.vacuum_cost_limit.is_none()
107            && self.analyze_threshold.is_none()
108            && self.analyze_scale_factor.is_none()
109            && self.freeze_max_age.is_none()
110            && self.freeze_min_age.is_none()
111            && self.freeze_table_age.is_none()
112            && self.multixact_freeze_max_age.is_none()
113            && self.multixact_freeze_min_age.is_none()
114            && self.multixact_freeze_table_age.is_none()
115            && self.vacuum_insert_threshold.is_none()
116            && self.vacuum_insert_scale_factor.is_none()
117            && self.log_min_duration.is_none()
118    }
119}
120
121/// Storage options for tables. MV reuses via type alias since PG documents
122/// identical reloptions for both.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
124pub struct TableStorageOptions {
125    /// `fillfactor` — target heap page density (10..=100).
126    pub fillfactor: Option<u32>,
127    /// Autovacuum parameters. All keys are prefixed `autovacuum_` in PG.
128    pub autovacuum: AutovacuumOptions,
129    /// `parallel_workers` — number of parallel workers (0..=1024).
130    pub parallel_workers: Option<u32>,
131    /// `toast_tuple_target` — TOAST compression threshold in bytes (128..=8160).
132    pub toast_tuple_target: Option<u32>,
133    /// `user_catalog_table` — treat as a catalog table for logical replication.
134    pub user_catalog_table: Option<bool>,
135    /// `vacuum_truncate` — allow VACUUM to truncate trailing empty pages (PG 12+).
136    pub vacuum_truncate: Option<bool>,
137    /// Unknown / extension-registered options. Always sorted by key (`BTreeMap`).
138    pub extra: BTreeMap<String, String>,
139}
140
141impl TableStorageOptions {
142    /// `true` iff every typed field is `None` and `extra` is empty.
143    #[must_use]
144    pub fn is_empty(&self) -> bool {
145        self.fillfactor.is_none()
146            && self.autovacuum.is_empty()
147            && self.parallel_workers.is_none()
148            && self.toast_tuple_target.is_none()
149            && self.user_catalog_table.is_none()
150            && self.vacuum_truncate.is_none()
151            && self.extra.is_empty()
152    }
153}
154
155/// MVs share table reloption semantics in PG.
156pub type MaterializedViewStorageOptions = TableStorageOptions;
157
158/// Storage options for indexes. Valid keys depend on access method;
159/// parse-time validation enforces per-AM rules.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
161pub struct IndexStorageOptions {
162    /// `fillfactor` — target index page density; valid range depends on access method.
163    pub fillfactor: Option<u32>,
164    /// `fastupdate` — GIN: defer index updates via a pending list.
165    pub fastupdate: Option<bool>,
166    /// `gin_pending_list_limit` — GIN: max size of pending list before cleanup (bytes).
167    pub gin_pending_list_limit: Option<u64>,
168    /// `buffering` — `GiST` / `SP-GiST`: controls buffered build strategy.
169    pub buffering: Option<BufferingMode>,
170    /// `deduplicate_items` — B-tree (PG 13+): enable deduplication of posting lists.
171    pub deduplicate_items: Option<bool>,
172    /// `pages_per_range` — BRIN: number of heap pages per BRIN range.
173    pub pages_per_range: Option<u32>,
174    /// `autosummarize` — BRIN: auto-summarize when `pages_per_range` fills.
175    pub autosummarize: Option<bool>,
176    /// Unknown / extension-registered options. Always sorted by key (`BTreeMap`).
177    pub extra: BTreeMap<String, String>,
178}
179
180impl IndexStorageOptions {
181    /// `true` iff every typed field is `None` and `extra` is empty.
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.fillfactor.is_none()
185            && self.fastupdate.is_none()
186            && self.gin_pending_list_limit.is_none()
187            && self.buffering.is_none()
188            && self.deduplicate_items.is_none()
189            && self.pages_per_range.is_none()
190            && self.autosummarize.is_none()
191            && self.extra.is_empty()
192    }
193}
194
195/// `buffering` setting for GiST/SP-GiST index builds.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum BufferingMode {
199    /// Enable buffered index build.
200    On,
201    /// Disable buffered index build.
202    Off,
203    /// Let PG decide based on available memory (default).
204    Auto,
205}
206
207impl BufferingMode {
208    /// Returns the lowercase SQL keyword for this mode.
209    #[must_use]
210    pub const fn sql_keyword(self) -> &'static str {
211        match self {
212            Self::On => "on",
213            Self::Off => "off",
214            Self::Auto => "auto",
215        }
216    }
217}
218
219impl FromStr for BufferingMode {
220    type Err = ();
221
222    /// Parse from `pg_class.reloptions` text or source SQL value.
223    fn from_str(s: &str) -> Result<Self, Self::Err> {
224        match s.to_ascii_lowercase().as_str() {
225            "on" => Ok(Self::On),
226            "off" => Ok(Self::Off),
227            "auto" => Ok(Self::Auto),
228            _ => Err(()),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn not_nan_rejects_nan() {
239        assert!(NotNanF64::new(f64::NAN).is_err());
240        assert!(NotNanF64::new(1.5).is_ok());
241        assert!(NotNanF64::new(0.0).is_ok());
242        assert!(NotNanF64::new(f64::INFINITY).is_ok());
243    }
244
245    #[test]
246    fn not_nan_equality_is_bit_exact() {
247        let a = NotNanF64::new(0.1).unwrap();
248        let b = NotNanF64::new(0.1).unwrap();
249        assert_eq!(a, b);
250    }
251
252    #[test]
253    fn default_storage_is_empty() {
254        assert!(TableStorageOptions::default().is_empty());
255        assert!(IndexStorageOptions::default().is_empty());
256        assert!(AutovacuumOptions::default().is_empty());
257    }
258
259    #[test]
260    fn non_empty_storage_detected() {
261        let s = TableStorageOptions {
262            fillfactor: Some(80),
263            ..Default::default()
264        };
265        assert!(!s.is_empty());
266    }
267
268    #[test]
269    fn buffering_mode_roundtrips() {
270        for m in [BufferingMode::On, BufferingMode::Off, BufferingMode::Auto] {
271            assert_eq!(m.sql_keyword().parse::<BufferingMode>(), Ok(m));
272        }
273        assert!("bogus".parse::<BufferingMode>().is_err());
274    }
275
276    #[test]
277    fn extra_is_sorted_via_btreemap() {
278        let mut s = TableStorageOptions::default();
279        s.extra.insert("zebra".into(), "1".into());
280        s.extra.insert("alpha".into(), "2".into());
281        let keys: Vec<_> = s.extra.keys().cloned().collect();
282        assert_eq!(keys, vec!["alpha", "zebra"]);
283    }
284}