Skip to main content

pglite_oxide/pglite/
extensions.rs

1use std::collections::BTreeSet;
2
3use anyhow::{Result, bail};
4
5#[path = "generated_extensions.rs"]
6mod generated;
7
8pub use generated::*;
9
10/// A bundled Postgres extension that can be installed into a PGlite database.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct Extension {
13    name: &'static str,
14    sql_name: &'static str,
15    archive_name: &'static str,
16    native_module_file: Option<&'static str>,
17    aot_name: Option<&'static str>,
18    dependencies: &'static [&'static str],
19    setup: ExtensionSetup,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub(crate) struct ExtensionSetup {
24    create_extension: bool,
25    create_schema: Option<&'static str>,
26    load_sql: &'static [&'static str],
27    post_create_sql: &'static [&'static str],
28}
29
30impl ExtensionSetup {
31    pub(crate) const fn new(
32        create_extension: bool,
33        create_schema: Option<&'static str>,
34        load_sql: &'static [&'static str],
35        post_create_sql: &'static [&'static str],
36    ) -> Self {
37        Self {
38            create_extension,
39            create_schema,
40            load_sql,
41            post_create_sql,
42        }
43    }
44}
45
46impl Extension {
47    #[allow(dead_code)]
48    pub(crate) const fn new(
49        name: &'static str,
50        sql_name: &'static str,
51        archive_name: &'static str,
52        native_module_file: Option<&'static str>,
53        aot_name: Option<&'static str>,
54        dependencies: &'static [&'static str],
55        setup: ExtensionSetup,
56    ) -> Self {
57        Self {
58            name,
59            sql_name,
60            archive_name,
61            native_module_file,
62            aot_name,
63            dependencies,
64            setup,
65        }
66    }
67
68    /// Human-facing extension name.
69    pub const fn name(self) -> &'static str {
70        self.name
71    }
72
73    /// SQL extension name used in `CREATE EXTENSION`.
74    pub const fn sql_name(self) -> &'static str {
75        self.sql_name
76    }
77
78    /// Archive path inside the asset manifest.
79    pub const fn archive_name(self) -> &'static str {
80        self.archive_name
81    }
82
83    /// AOT artifact key for the extension side module.
84    pub const fn aot_name(self) -> Option<&'static str> {
85        self.aot_name
86    }
87
88    /// Native side-module file installed into `/lib/postgresql`, when the
89    /// extension has one.
90    pub const fn native_module_file(self) -> Option<&'static str> {
91        self.native_module_file
92    }
93
94    /// SQL extension names that must be installed before this extension.
95    pub const fn dependencies(self) -> &'static [&'static str] {
96        self.dependencies
97    }
98
99    pub(crate) const fn setup(self) -> ExtensionSetup {
100        self.setup
101    }
102}
103
104pub fn by_sql_name(sql_name: &str) -> Option<Extension> {
105    ALL.iter()
106        .copied()
107        .find(|extension| extension.sql_name == sql_name)
108}
109
110pub(crate) fn candidate_by_sql_name(sql_name: &str) -> Option<Extension> {
111    generated::CANDIDATES
112        .iter()
113        .copied()
114        .find(|extension| extension.sql_name == sql_name)
115}
116
117pub(crate) fn resolve_extension_set(extensions: &[Extension]) -> Result<Vec<Extension>> {
118    let mut visiting = BTreeSet::new();
119    let mut visited = BTreeSet::new();
120    let mut resolved = Vec::new();
121    let mut requested = extensions.to_vec();
122    requested.sort_by_key(|extension| extension.sql_name());
123    for extension in requested {
124        visit_extension(extension, &mut visiting, &mut visited, &mut resolved)?;
125    }
126    Ok(resolved)
127}
128
129fn visit_extension(
130    extension: Extension,
131    visiting: &mut BTreeSet<&'static str>,
132    visited: &mut BTreeSet<&'static str>,
133    resolved: &mut Vec<Extension>,
134) -> Result<()> {
135    if visited.contains(extension.sql_name()) {
136        return Ok(());
137    }
138    if !visiting.insert(extension.sql_name()) {
139        bail!(
140            "cyclic bundled extension dependency involving '{}'",
141            extension.sql_name()
142        );
143    }
144    for dependency in extension.dependencies() {
145        let dependency_extension = candidate_by_sql_name(dependency).ok_or_else(|| {
146            anyhow::anyhow!(
147                "bundled extension '{}' depends on missing packaged extension '{}'",
148                extension.sql_name(),
149                dependency
150            )
151        })?;
152        visit_extension(dependency_extension, visiting, visited, resolved)?;
153    }
154    visiting.remove(extension.sql_name());
155    visited.insert(extension.sql_name());
156    resolved.push(extension);
157    Ok(())
158}
159
160pub(crate) fn extension_setup_sql(extension: Extension) -> Vec<String> {
161    let setup = extension.setup();
162    let mut statements = Vec::new();
163    if setup.create_extension {
164        if let Some(schema) = setup.create_schema.filter(|schema| *schema != "pg_catalog") {
165            statements.push(format!(
166                "CREATE SCHEMA IF NOT EXISTS {};",
167                crate::pglite::templating::quote_identifier(schema)
168            ));
169        }
170        let mut sql = format!(
171            "CREATE EXTENSION IF NOT EXISTS {}",
172            crate::pglite::templating::quote_identifier(extension.sql_name())
173        );
174        if let Some(schema) = setup.create_schema {
175            sql.push_str(" WITH SCHEMA ");
176            sql.push_str(&crate::pglite::templating::quote_identifier(schema));
177        }
178        sql.push(';');
179        statements.push(sql);
180    }
181    statements.extend(setup.load_sql.iter().map(|sql| (*sql).to_owned()));
182    statements.extend(setup.post_create_sql.iter().map(|sql| (*sql).to_owned()));
183    statements
184}
185
186pub(crate) fn extension_session_setup_sql(extension: Extension) -> Vec<String> {
187    let setup = extension.setup();
188    let mut statements = Vec::new();
189    statements.extend(setup.load_sql.iter().map(|sql| (*sql).to_owned()));
190    statements.extend(setup.post_create_sql.iter().map(|sql| (*sql).to_owned()));
191    statements
192}
193
194#[cfg(all(test, feature = "extensions"))]
195mod candidate_tests {
196    use super::*;
197    use crate::{Pglite, PgliteServer};
198    use anyhow::{Context, Result, ensure};
199    use sqlx::{Connection, PgConnection};
200    use std::collections::BTreeSet;
201    use std::path::{Path, PathBuf};
202
203    #[test]
204    fn public_extensions_pass_direct_and_restart_smoke() -> Result<()> {
205        run_direct_and_restart_smoke_set(generated::ALL)
206    }
207
208    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
209    async fn public_extensions_pass_server_smoke() -> Result<()> {
210        run_server_smoke_set(generated::ALL).await
211    }
212
213    #[test]
214    fn public_extensions_materialize_only_requested_libraries() -> Result<()> {
215        run_lifecycle_materialization_set(generated::ALL)
216    }
217
218    #[test]
219    #[ignore = "promotion gate: run manually before marking packaged candidates stable"]
220    fn packaged_candidate_extensions_pass_direct_and_restart_smoke() -> Result<()> {
221        run_direct_and_restart_smoke_set(generated::CANDIDATES)
222    }
223
224    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
225    #[ignore = "promotion gate: run manually before marking packaged candidates stable"]
226    async fn packaged_candidate_extensions_pass_server_smoke() -> Result<()> {
227        run_server_smoke_set(generated::CANDIDATES).await
228    }
229
230    #[test]
231    #[ignore = "promotion gate: run manually before marking packaged candidates stable"]
232    fn packaged_candidate_extensions_materialize_only_requested_libraries() -> Result<()> {
233        run_lifecycle_materialization_set(generated::CANDIDATES)
234    }
235
236    fn run_direct_and_restart_smoke_set(extensions: &[Extension]) -> Result<()> {
237        let mut failures = Vec::new();
238        for extension in extensions {
239            if let Err(error) = run_one_direct_and_restart_smoke(*extension) {
240                failures.push(format!("{}: {error:?}", extension.sql_name()));
241            }
242        }
243        ensure!(
244            failures.is_empty(),
245            "extension direct/restart smoke failures:\n{}",
246            failures.join("\n\n")
247        );
248        Ok(())
249    }
250
251    fn run_one_direct_and_restart_smoke(extension: Extension) -> Result<()> {
252        let name = extension.sql_name();
253        {
254            let mut db = Pglite::builder()
255                .temporary()
256                .extension(extension)
257                .open()
258                .with_context(|| format!("open temporary database with extension {name}"))?;
259            run_direct_smoke(&mut db, extension)?;
260            db.close()
261                .with_context(|| format!("close temporary database with extension {name}"))?;
262        }
263
264        let root = tempfile::TempDir::new()
265            .with_context(|| format!("create restart root for extension {name}"))?;
266        {
267            let mut db = Pglite::builder()
268                .path(root.path())
269                .extension(extension)
270                .open()
271                .with_context(|| {
272                    format!("open persistent database with extension {name} before restart")
273                })?;
274            run_direct_smoke(&mut db, extension)?;
275            assert_extension_catalog_state(&mut db, extension)?;
276            db.close()
277                .with_context(|| format!("close persistent database with extension {name}"))?;
278        }
279        {
280            let mut db = Pglite::builder()
281                .path(root.path())
282                .extension(extension)
283                .open()
284                .with_context(|| {
285                    format!("reopen persistent database with extension {name} after restart")
286                })?;
287            assert_extension_catalog_state(&mut db, extension)?;
288            db.close()
289                .with_context(|| format!("close restarted database with extension {name}"))?;
290        }
291        Ok(())
292    }
293
294    async fn run_server_smoke_set(extensions: &[Extension]) -> Result<()> {
295        let mut failures = Vec::new();
296        for extension in extensions {
297            if let Err(error) = run_one_server_smoke(*extension).await {
298                failures.push(format!("{}: {error:?}", extension.sql_name()));
299            }
300        }
301        ensure!(
302            failures.is_empty(),
303            "extension server smoke failures:\n{}",
304            failures.join("\n\n")
305        );
306        Ok(())
307    }
308
309    async fn run_one_server_smoke(extension: Extension) -> Result<()> {
310        let name = extension.sql_name();
311        let server = PgliteServer::builder()
312            .temporary()
313            .extension(extension)
314            .start()
315            .with_context(|| format!("start server with extension {name}"))?;
316        let mut conn = PgConnection::connect(&server.database_url())
317            .await
318            .with_context(|| format!("connect server with extension {name}"))?;
319        run_server_smoke(&mut conn, extension).await?;
320        drop(conn);
321        server
322            .shutdown()
323            .with_context(|| format!("shutdown server with extension {name}"))?;
324        Ok(())
325    }
326
327    fn run_lifecycle_materialization_set(extensions: &[Extension]) -> Result<()> {
328        let mut failures = Vec::new();
329        for extension in extensions {
330            if let Err(error) = run_one_lifecycle_materialization(*extension) {
331                failures.push(format!("{}: {error:?}", extension.sql_name()));
332            }
333        }
334        ensure!(
335            failures.is_empty(),
336            "extension lifecycle/materialization failures:\n{}",
337            failures.join("\n\n")
338        );
339        Ok(())
340    }
341
342    fn run_one_lifecycle_materialization(extension: Extension) -> Result<()> {
343        let name = extension.sql_name();
344        let root = tempfile::TempDir::new()
345            .with_context(|| format!("create lifecycle root for extension {name}"))?;
346        {
347            let mut db = Pglite::builder()
348                .path(root.path())
349                .extension(extension)
350                .open()
351                .with_context(|| format!("open lifecycle database with extension {name}"))?;
352            db.close()
353                .with_context(|| format!("close lifecycle database with extension {name}"))?;
354        }
355        assert_only_resolved_extension_libraries_are_materialized(root.path(), extension)
356    }
357
358    fn run_direct_smoke(db: &mut Pglite, extension: Extension) -> Result<()> {
359        for statement in smoke_sql(extension.sql_name()) {
360            db.exec(statement, None).with_context(|| {
361                format!(
362                    "direct smoke failed for extension {} while running:\n{}",
363                    extension.sql_name(),
364                    statement
365                )
366            })?;
367        }
368        Ok(())
369    }
370
371    async fn run_server_smoke(conn: &mut PgConnection, extension: Extension) -> Result<()> {
372        for statement in smoke_sql(extension.sql_name()) {
373            sqlx::query(statement)
374                .fetch_all(&mut *conn)
375                .await
376                .with_context(|| {
377                    format!(
378                        "server smoke failed for extension {} while running:\n{}",
379                        extension.sql_name(),
380                        statement
381                    )
382                })?;
383        }
384        Ok(())
385    }
386
387    fn assert_extension_catalog_state(db: &mut Pglite, extension: Extension) -> Result<()> {
388        if extension.setup().create_extension {
389            let result = db.query(
390                "SELECT count(*)::int4 AS count FROM pg_extension WHERE extname = $1",
391                &[serde_json::json!(extension.sql_name())],
392                None,
393            )?;
394            ensure!(
395                result.rows[0]["count"] == serde_json::json!(1),
396                "extension {} should survive restart in pg_extension",
397                extension.sql_name()
398            );
399        } else {
400            let result = db.query("SELECT 1::int4 AS ok", &[], None)?;
401            ensure!(
402                result.rows[0]["ok"] == serde_json::json!(1),
403                "extension {} should reopen cleanly",
404                extension.sql_name()
405            );
406        }
407        Ok(())
408    }
409
410    fn assert_only_resolved_extension_libraries_are_materialized(
411        root: &Path,
412        extension: Extension,
413    ) -> Result<()> {
414        let expected = resolve_extension_set(&[extension])?
415            .into_iter()
416            .filter_map(|extension| extension.native_module_file().map(PathBuf::from))
417            .collect::<BTreeSet<_>>();
418        let actual = relative_files(&root.join("tmp/pglite/lib/postgresql"))
419            .into_iter()
420            .collect::<BTreeSet<_>>();
421        ensure!(
422            actual == expected,
423            "upper runtime library layer for {} should contain only resolved requested libraries; expected {:?}, got {:?}",
424            extension.sql_name(),
425            expected,
426            actual
427        );
428        Ok(())
429    }
430
431    fn relative_files(root: &Path) -> Vec<PathBuf> {
432        fn walk(base: &Path, current: &Path, files: &mut Vec<PathBuf>) {
433            let Ok(entries) = std::fs::read_dir(current) else {
434                return;
435            };
436            for entry in entries {
437                let entry = entry.expect("read runtime test directory entry");
438                let path = entry.path();
439                if path.is_dir() {
440                    walk(base, &path, files);
441                } else if path.is_file() {
442                    files.push(
443                        path.strip_prefix(base)
444                            .expect("relative extension library path")
445                            .to_path_buf(),
446                    );
447                }
448            }
449        }
450
451        let mut files = Vec::new();
452        walk(root, root, &mut files);
453        files.sort();
454        files
455    }
456
457    fn smoke_sql(sql_name: &str) -> &'static [&'static str] {
458        // These are compact Rust ports of the PGlite extension smoke tests in
459        // assets/checkouts/pglite/packages/pglite/tests.
460        match sql_name {
461            "age" => &[
462                "SELECT ag_catalog.create_graph('oxide_graph')",
463                "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM ag_catalog.ag_graph WHERE name = 'oxide_graph') THEN RAISE EXCEPTION 'age graph was not created'; END IF; END $$",
464                "SELECT * FROM ag_catalog.cypher('oxide_graph', $$ RETURN 1 $$) AS (one agtype)",
465            ],
466            "amcheck" => &[
467                "CREATE TEMP TABLE oxide_amcheck (id int PRIMARY KEY, value text)",
468                "INSERT INTO oxide_amcheck SELECT i, 'v' || i::text FROM generate_series(1, 8) AS i",
469                "SELECT bt_index_check('oxide_amcheck_pkey'::regclass)",
470            ],
471            "auto_explain" => &["EXPLAIN SELECT count(*) FROM pg_class"],
472            "bloom" => &[
473                "CREATE TEMP TABLE oxide_bloom (id int, value int)",
474                "CREATE INDEX oxide_bloom_idx ON oxide_bloom USING bloom (id, value)",
475                "INSERT INTO oxide_bloom SELECT i, i % 3 FROM generate_series(1, 20) AS i",
476                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_bloom WHERE id = 7 AND value = 1; IF n <> 1 THEN RAISE EXCEPTION 'bloom lookup failed: %', n; END IF; END $$",
477            ],
478            "btree_gin" => &[
479                "CREATE TEMP TABLE oxide_btree_gin (id int)",
480                "CREATE INDEX oxide_btree_gin_idx ON oxide_btree_gin USING gin (id)",
481                "INSERT INTO oxide_btree_gin SELECT generate_series(1, 10)",
482                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_btree_gin WHERE id = 5; IF n <> 1 THEN RAISE EXCEPTION 'btree_gin lookup failed: %', n; END IF; END $$",
483            ],
484            "btree_gist" => &[
485                "CREATE TEMP TABLE oxide_btree_gist (id int)",
486                "CREATE INDEX oxide_btree_gist_idx ON oxide_btree_gist USING gist (id)",
487                "INSERT INTO oxide_btree_gist SELECT generate_series(1, 10)",
488                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_btree_gist WHERE id = 5; IF n <> 1 THEN RAISE EXCEPTION 'btree_gist lookup failed: %', n; END IF; END $$",
489            ],
490            "citext" => &[
491                "CREATE TEMP TABLE oxide_citext (value citext)",
492                "INSERT INTO oxide_citext VALUES ('Postgres')",
493                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_citext WHERE value = 'postgres'; IF n <> 1 THEN RAISE EXCEPTION 'citext comparison failed: %', n; END IF; END $$",
494            ],
495            "cube" => &[
496                "DO $$ DECLARE d float8; BEGIN SELECT cube(array[1,2,3]) <-> cube(array[1,2,4]) INTO d; IF d <> 1 THEN RAISE EXCEPTION 'cube distance failed: %', d; END IF; END $$",
497            ],
498            "dict_int" => &[
499                "DO $$ DECLARE lex text; BEGIN SELECT array_to_string(ts_lexize('intdict', '40865854'), ',') INTO lex; IF lex <> '408658' THEN RAISE EXCEPTION 'dict_int lexize failed: %', lex; END IF; END $$",
500            ],
501            "dict_xsyn" => &[
502                "ALTER TEXT SEARCH DICTIONARY xsyn (RULES = 'xsyn_sample', KEEPORIG = true, MATCHORIG = true, KEEPSYNONYMS = true, MATCHSYNONYMS = false)",
503                "DO $$ DECLARE lex text; BEGIN SELECT array_to_string(ts_lexize('xsyn', 'supernova'), ',') INTO lex; IF lex IS NULL OR lex !~ 'sn' THEN RAISE EXCEPTION 'dict_xsyn lexize failed: %', lex; END IF; END $$",
504            ],
505            "earthdistance" => &[
506                "DO $$ DECLARE d float8; BEGIN SELECT earth_distance(ll_to_earth(0, 0), ll_to_earth(0, 1)) INTO d; IF d <= 0 THEN RAISE EXCEPTION 'earthdistance failed: %', d; END IF; END $$",
507            ],
508            "file_fdw" => &[
509                "CREATE SERVER oxide_file_server FOREIGN DATA WRAPPER file_fdw",
510                "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_foreign_data_wrapper WHERE fdwname = 'file_fdw') THEN RAISE EXCEPTION 'file_fdw wrapper missing'; END IF; END $$",
511            ],
512            "fuzzystrmatch" => &[
513                "DO $$ BEGIN IF levenshtein('kitten', 'sitting') <> 3 THEN RAISE EXCEPTION 'levenshtein failed'; END IF; IF soundex('kitten') <> 'K350' THEN RAISE EXCEPTION 'soundex failed'; END IF; END $$",
514            ],
515            "hstore" => &[
516                "CREATE TEMP TABLE oxide_hstore (attrs hstore)",
517                "INSERT INTO oxide_hstore VALUES ('a=>1,b=>2'::hstore)",
518                "DO $$ DECLARE v text; BEGIN SELECT attrs -> 'b' INTO v FROM oxide_hstore; IF v <> '2' THEN RAISE EXCEPTION 'hstore lookup failed: %', v; END IF; END $$",
519            ],
520            "intarray" => &[
521                "CREATE TEMP TABLE oxide_intarray (tags int[])",
522                "INSERT INTO oxide_intarray VALUES (ARRAY[1, 2, 5]), (ARRAY[3, 4])",
523                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_intarray WHERE tags && ARRAY[2, 9]; IF n <> 1 THEN RAISE EXCEPTION 'intarray overlap failed: %', n; END IF; SELECT count(*) INTO n FROM oxide_intarray WHERE tags @@ '1 & (2|3)'::query_int; IF n <> 1 THEN RAISE EXCEPTION 'intarray query_int failed: %', n; END IF; END $$",
524            ],
525            "isn" => &[
526                "DO $$ BEGIN IF isbn('978-0-393-04002-9')::text <> '0-393-04002-X' THEN RAISE EXCEPTION 'isbn failed'; END IF; IF isbn13('0901690546')::text <> '978-0-901690-54-8' THEN RAISE EXCEPTION 'isbn13 failed'; END IF; IF issn('1436-4522')::text <> '1436-4522' THEN RAISE EXCEPTION 'issn failed'; END IF; END $$",
527            ],
528            "lo" => &[
529                "CREATE TEMP TABLE oxide_lo (id int, data oid)",
530                "CREATE TRIGGER oxide_lo_manage BEFORE UPDATE OR DELETE ON oxide_lo FOR EACH ROW EXECUTE FUNCTION lo_manage(data)",
531                "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'oxide_lo_manage') THEN RAISE EXCEPTION 'lo trigger missing'; END IF; END $$",
532            ],
533            "ltree" => &[
534                "CREATE TEMP TABLE oxide_ltree (path ltree)",
535                "INSERT INTO oxide_ltree VALUES ('Top.Science.Astronomy'), ('Top.Collections.Pictures')",
536                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_ltree WHERE path <@ 'Top.Science'; IF n <> 1 THEN RAISE EXCEPTION 'ltree ancestor query failed: %', n; END IF; END $$",
537            ],
538            "pageinspect" => &[
539                "CREATE TEMP TABLE oxide_pageinspect (id int)",
540                "INSERT INTO oxide_pageinspect SELECT generate_series(1, 5)",
541                "SELECT * FROM page_header(get_raw_page('oxide_pageinspect', 0))",
542            ],
543            "pg_buffercache" => &[
544                "SELECT * FROM pg_buffercache_summary()",
545                "SELECT * FROM pg_buffercache_usage_counts()",
546            ],
547            "pg_freespacemap" => &[
548                "CREATE TEMP TABLE oxide_fsm (id int, value text)",
549                "INSERT INTO oxide_fsm SELECT i, repeat('x', 200) FROM generate_series(1, 20) AS i",
550                "DELETE FROM oxide_fsm WHERE id % 2 = 0",
551                "SELECT * FROM pg_freespace('oxide_fsm') LIMIT 1",
552            ],
553            "pg_hashids" => &[
554                "DO $$ BEGIN IF id_encode(1001) <> 'jNl' THEN RAISE EXCEPTION 'pg_hashids encode failed'; END IF; IF id_decode_once('jNl') <> 1001 THEN RAISE EXCEPTION 'pg_hashids decode failed'; END IF; END $$",
555            ],
556            "pg_ivm" => &[
557                "CREATE TABLE oxide_ivm_orders (id int, amount int)",
558                "INSERT INTO oxide_ivm_orders VALUES (1, 10), (2, 20)",
559                "SELECT pgivm.create_immv('oxide_ivm_summary', $$ SELECT id, amount FROM oxide_ivm_orders $$)",
560                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM oxide_ivm_summary; IF n <> 2 THEN RAISE EXCEPTION 'pg_ivm initial count failed: %', n; END IF; END $$",
561            ],
562            "pg_surgery" => &[
563                "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'heap_force_kill') THEN RAISE EXCEPTION 'pg_surgery function missing'; END IF; END $$",
564            ],
565            "pg_textsearch" => &[
566                "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_am WHERE amname = 'bm25') THEN RAISE EXCEPTION 'bm25 access method missing'; END IF; END $$",
567                "SELECT to_bm25query('postgres wasm')",
568            ],
569            "pg_trgm" => &[
570                "DO $$ DECLARE score float8; BEGIN SELECT similarity('postgres', 'postgrex') INTO score; IF score <= 0 THEN RAISE EXCEPTION 'pg_trgm similarity failed: %', score; END IF; END $$",
571            ],
572            "pg_uuidv7" => &[
573                "DO $$ DECLARE id uuid; ts timestamptz; BEGIN SELECT uuid_generate_v7() INTO id; IF length(id::text) <> 36 THEN RAISE EXCEPTION 'uuidv7 length failed'; END IF; SELECT uuid_v7_to_timestamptz('018570bb-4a7d-7c7e-8df4-6d47afd8c8fc') INTO ts; IF ts IS NULL THEN RAISE EXCEPTION 'uuidv7 timestamp failed'; END IF; END $$",
574            ],
575            "pg_visibility" => &[
576                "CREATE TEMP TABLE oxide_visibility (id int)",
577                "INSERT INTO oxide_visibility SELECT generate_series(1, 5)",
578                "SELECT * FROM pg_visibility('oxide_visibility') LIMIT 1",
579                "SELECT * FROM pg_visibility_map('oxide_visibility') LIMIT 1",
580            ],
581            "pg_walinspect" => &[
582                "CREATE TEMP TABLE oxide_walinspect (value text)",
583                "CREATE TEMP TABLE oxide_walinspect_lsn AS SELECT pg_current_wal_lsn() AS before_lsn",
584                "INSERT INTO oxide_walinspect SELECT 'row ' || i::text FROM generate_series(1, 5) AS i",
585                "SELECT * FROM pg_get_wal_block_info((SELECT before_lsn FROM oxide_walinspect_lsn), pg_current_wal_lsn()) ORDER BY start_lsn, block_id LIMIT 20",
586            ],
587            "pgtap" => &[
588                "BEGIN",
589                "SELECT plan(1)",
590                "SELECT pass('pgtap smoke')",
591                "SELECT * FROM finish()",
592                "ROLLBACK",
593            ],
594            "seg" => &[
595                "DO $$ BEGIN IF '7(+-)1'::seg::text <> '6 .. 8' THEN RAISE EXCEPTION 'seg cast failed'; END IF; END $$",
596            ],
597            "tablefunc" => &[
598                "DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM normal_rand(10, 5, 3); IF n <> 10 THEN RAISE EXCEPTION 'normal_rand failed: %', n; END IF; END $$",
599                "SELECT * FROM crosstab('SELECT 1, 1, 10 UNION ALL SELECT 1, 2, 20') AS ct(rowid int, c1 int, c2 int)",
600            ],
601            "tcn" => &[
602                "CREATE TEMP TABLE oxide_tcn (id int PRIMARY KEY, value text)",
603                "CREATE TRIGGER oxide_tcn_trigger AFTER INSERT OR UPDATE OR DELETE ON oxide_tcn FOR EACH ROW EXECUTE FUNCTION triggered_change_notification()",
604                "INSERT INTO oxide_tcn VALUES (1, 'one')",
605            ],
606            "tsm_system_rows" => &[
607                "CREATE TEMP TABLE oxide_tsm_rows AS SELECT i FROM generate_series(1, 20) AS i",
608                "SELECT * FROM oxide_tsm_rows TABLESAMPLE SYSTEM_ROWS(5)",
609            ],
610            "tsm_system_time" => &[
611                "CREATE TEMP TABLE oxide_tsm_time AS SELECT i FROM generate_series(1, 20) AS i",
612                "SELECT * FROM oxide_tsm_time TABLESAMPLE SYSTEM_TIME(50)",
613            ],
614            "unaccent" => &[
615                "DO $$ DECLARE lex text; BEGIN SELECT array_to_string(ts_lexize('unaccent', 'Hôtel'), ',') INTO lex; IF lex <> 'Hotel' THEN RAISE EXCEPTION 'unaccent failed: %', lex; END IF; END $$",
616            ],
617            "vector" => &[
618                "CREATE TEMP TABLE oxide_vector (embedding vector(3))",
619                "INSERT INTO oxide_vector VALUES ('[1,2,3]')",
620                "DO $$ DECLARE d float8; BEGIN SELECT embedding <-> '[1,2,4]'::vector INTO d FROM oxide_vector; IF d <> 1 THEN RAISE EXCEPTION 'vector distance failed: %', d; END IF; END $$",
621            ],
622            other => panic!("missing smoke SQL for packaged extension candidate {other}"),
623        }
624    }
625}