Skip to main content

oliphaunt_wasix/oliphaunt/
builder.rs

1use std::path::PathBuf;
2
3use anyhow::{Result, bail};
4
5#[cfg(feature = "extensions")]
6use crate::oliphaunt::base::install_missing_extension_archives;
7use crate::oliphaunt::base::{PreparedRoot, RootPlan, RootSource, RootTarget, prepare_root};
8use crate::oliphaunt::client::Oliphaunt;
9use crate::oliphaunt::config::{PostgresConfig, StartupConfig};
10#[cfg(feature = "extensions")]
11use crate::oliphaunt::extensions::{
12    Extension, postgres_config_with_extension_startup, resolve_extension_set,
13};
14use crate::oliphaunt::interface::DebugLevel;
15
16/// Builder for opening persistent or temporary [`Oliphaunt`] databases.
17#[derive(Debug, Clone)]
18pub struct OliphauntBuilder {
19    target: Option<OliphauntTarget>,
20    template_cache: bool,
21    postgres_config: PostgresConfig,
22    startup_config: StartupConfig,
23    load_data_dir_archive: Option<Vec<u8>>,
24    #[cfg(feature = "extensions")]
25    extensions: Vec<Extension>,
26}
27
28#[derive(Debug, Clone)]
29enum OliphauntTarget {
30    Path(PathBuf),
31    AppId {
32        qualifier: String,
33        organization: String,
34        application: String,
35    },
36    Temporary,
37}
38
39impl Default for OliphauntBuilder {
40    fn default() -> Self {
41        Self {
42            target: None,
43            template_cache: true,
44            postgres_config: PostgresConfig::default(),
45            startup_config: StartupConfig::default(),
46            load_data_dir_archive: None,
47            #[cfg(feature = "extensions")]
48            extensions: Vec::new(),
49        }
50    }
51}
52
53impl OliphauntBuilder {
54    /// Create a builder. Call [`path`](Self::path), [`app_id`](Self::app_id),
55    /// or [`temporary`](Self::temporary) before [`open`](Self::open).
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Open a persistent database rooted at `root`.
61    pub fn path(mut self, root: impl Into<PathBuf>) -> Self {
62        self.target = Some(OliphauntTarget::Path(root.into()));
63        self
64    }
65
66    /// Open a persistent database under the platform data directory.
67    pub fn app(
68        mut self,
69        qualifier: impl Into<String>,
70        organization: impl Into<String>,
71        application: impl Into<String>,
72    ) -> Self {
73        self.target = Some(OliphauntTarget::AppId {
74            qualifier: qualifier.into(),
75            organization: organization.into(),
76            application: application.into(),
77        });
78        self
79    }
80
81    /// Open a persistent database under the platform data directory.
82    pub fn app_id(self, app_id: (&str, &str, &str)) -> Self {
83        self.app(app_id.0, app_id.1, app_id.2)
84    }
85
86    /// Open an ephemeral database removed when the instance is dropped.
87    ///
88    /// Temporary databases use the process-local template cluster cache by
89    /// default, avoiding repeated `initdb` work in test suites.
90    pub fn temporary(mut self) -> Self {
91        self.target = Some(OliphauntTarget::Temporary);
92        self
93    }
94
95    /// Control whether new databases are cloned from the process-local or
96    /// embedded PGDATA template cache.
97    pub fn template_cache(mut self, enabled: bool) -> Self {
98        self.template_cache = enabled;
99        self
100    }
101
102    /// Open an ephemeral database with a fresh `initdb`.
103    ///
104    /// This is a compatibility alias for
105    /// `temporary().template_cache(false)`. Fresh initdb uses the bundled split
106    /// WASIX `initdb` module; cached temporary databases remain the production
107    /// fast path.
108    pub fn fresh_temporary(self) -> Self {
109        self.temporary().template_cache(false)
110    }
111
112    /// Set a PostgreSQL startup GUC for this embedded backend.
113    pub fn postgres_config(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
114        self.postgres_config.insert(name, value);
115        self
116    }
117
118    /// Set multiple PostgreSQL startup GUCs for this embedded backend.
119    pub fn postgres_configs<K, V>(mut self, settings: impl IntoIterator<Item = (K, V)>) -> Self
120    where
121        K: Into<String>,
122        V: Into<String>,
123    {
124        for (name, value) in settings {
125            self.postgres_config.insert(name, value);
126        }
127        self
128    }
129
130    /// Connect as a PostgreSQL role. The role must already exist in the
131    /// cluster.
132    pub fn username(mut self, username: impl Into<String>) -> Self {
133        self.startup_config.username = username.into();
134        self
135    }
136
137    /// Connect to a PostgreSQL database. The database must already exist in the
138    /// cluster.
139    pub fn database(mut self, database: impl Into<String>) -> Self {
140        self.startup_config.database = database.into();
141        self
142    }
143
144    /// Enable PostgreSQL debug logging level `0..=5` for the embedded backend.
145    pub fn debug_level(mut self, level: DebugLevel) -> Self {
146        self.startup_config.debug_level = Some(level);
147        self
148    }
149
150    /// Use lower durability settings for ephemeral or cacheable local
151    /// workloads.
152    pub fn relaxed_durability(mut self, enabled: bool) -> Self {
153        self.startup_config.relaxed_durability = enabled;
154        self
155    }
156
157    /// Append an advanced PostgreSQL startup argument. Prefer
158    /// [`postgres_config`](Self::postgres_config) for GUCs.
159    pub fn startup_arg(mut self, arg: impl Into<String>) -> Self {
160        self.startup_config.extra_args.push(arg.into());
161        self
162    }
163
164    /// Append advanced PostgreSQL startup arguments.
165    pub fn startup_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
166        self.startup_config
167            .extra_args
168            .extend(args.into_iter().map(Into::into));
169        self
170    }
171
172    /// Load a previously dumped PGDATA tar archive before opening the database.
173    pub fn load_data_dir_archive(mut self, archive: impl Into<Vec<u8>>) -> Self {
174        self.load_data_dir_archive = Some(archive.into());
175        self
176    }
177
178    /// Enable a bundled Postgres extension before returning the database.
179    #[cfg(feature = "extensions")]
180    pub fn extension(mut self, extension: Extension) -> Self {
181        self.extensions.push(extension);
182        self
183    }
184
185    /// Enable bundled Postgres extensions before returning the database.
186    #[cfg(feature = "extensions")]
187    pub fn extensions(mut self, extensions: impl IntoIterator<Item = Extension>) -> Self {
188        self.extensions.extend(extensions);
189        self
190    }
191
192    /// Install, initialize, and start the selected database.
193    pub fn open(self) -> Result<Oliphaunt> {
194        #[cfg(feature = "extensions")]
195        let (extensions, postgres_config) = self.resolved_extension_startup()?;
196        #[cfg(not(feature = "extensions"))]
197        let postgres_config = self.postgres_config.clone();
198        postgres_config.validate()?;
199        self.startup_config.validate()?;
200        let target = match self.target.clone() {
201            Some(OliphauntTarget::Path(root)) => RootTarget::Path(root),
202            Some(OliphauntTarget::AppId {
203                qualifier,
204                organization,
205                application,
206            }) => RootTarget::AppId {
207                qualifier,
208                organization,
209                application,
210            },
211            Some(OliphauntTarget::Temporary) => RootTarget::Temporary,
212            None => {
213                bail!(
214                    "OliphauntBuilder target is not set; call path, app_id, or temporary before open"
215                )
216            }
217        };
218        let source = if let Some(archive) = self.load_data_dir_archive.clone() {
219            RootSource::DataDirArchive(archive)
220        } else if self.template_cache {
221            RootSource::Template
222        } else {
223            RootSource::FreshInitdb
224        };
225        let plan = RootPlan::new(target, source);
226        #[cfg(feature = "extensions")]
227        let plan = plan.with_extensions(extensions.clone(), postgres_config.clone());
228        let prepared = prepare_root(plan)?;
229        #[cfg(feature = "extensions")]
230        {
231            self.open_prepared_root(prepared, extensions, postgres_config)
232        }
233        #[cfg(not(feature = "extensions"))]
234        {
235            self.open_prepared_root(prepared, postgres_config)
236        }
237    }
238
239    #[cfg(feature = "extensions")]
240    fn resolved_extension_startup(&self) -> Result<(Vec<Extension>, PostgresConfig)> {
241        let extensions = resolve_extension_set(&self.extensions)?;
242        let postgres_config =
243            postgres_config_with_extension_startup(self.postgres_config.clone(), &extensions)?;
244        Ok((extensions, postgres_config))
245    }
246
247    fn open_prepared_root(
248        self,
249        prepared: PreparedRoot,
250        #[cfg(feature = "extensions")] extensions: Vec<Extension>,
251        postgres_config: PostgresConfig,
252    ) -> Result<Oliphaunt> {
253        let PreparedRoot {
254            temp_dir,
255            root_lock,
256            outcome,
257            ..
258        } = prepared;
259        #[cfg(feature = "extensions")]
260        install_missing_extension_archives(&outcome, &extensions)?;
261        #[cfg(feature = "extensions")]
262        let mut instance = Oliphaunt::new_prepared_with_config_and_extension_preload(
263            outcome,
264            postgres_config,
265            self.startup_config,
266            &extensions,
267        )?;
268        #[cfg(not(feature = "extensions"))]
269        let mut instance =
270            Oliphaunt::new_prepared_with_config(outcome, postgres_config, self.startup_config)?;
271        if let Some(lock) = root_lock {
272            instance.attach_root_lock(lock);
273        }
274        if let Some(temp_dir) = temp_dir {
275            instance.attach_temp_dir(temp_dir);
276        }
277        #[cfg(feature = "extensions")]
278        instance.enable_startup_extensions(&extensions)?;
279        Ok(instance)
280    }
281}
282
283#[cfg(all(test, feature = "extensions"))]
284mod tests {
285    use super::*;
286    use crate::oliphaunt::extensions::PG_TEXTSEARCH;
287
288    #[test]
289    fn direct_path_merges_pg_textsearch_preload_once_before_open() {
290        let builder = OliphauntBuilder::new()
291            .postgres_config("shared_preload_libraries", "auto_explain")
292            .postgres_config("work_mem", "16MB")
293            .extensions([PG_TEXTSEARCH, PG_TEXTSEARCH]);
294
295        let (_, postgres_config) = builder.resolved_extension_startup().unwrap();
296
297        assert_eq!(
298            postgres_config.get("shared_preload_libraries"),
299            Some("auto_explain,pg_textsearch")
300        );
301        assert_eq!(postgres_config.get("work_mem"), Some("16MB"));
302        assert_eq!(
303            postgres_config
304                .get("shared_preload_libraries")
305                .unwrap()
306                .split(',')
307                .filter(|library| *library == "pg_textsearch")
308                .count(),
309            1
310        );
311    }
312}