Skip to main content

gobby_code/
schema.rs

1use anyhow::{Context as _, bail};
2use gobby_core::search::BM25_SCORE_REGPROCEDURE;
3use postgres::Client;
4
5use crate::setup::DEFAULT_SCHEMA;
6
7const REQUIRED_TABLES: &[&str] = &[
8    "code_indexed_projects",
9    "code_indexed_files",
10    "code_symbols",
11    "code_content_chunks",
12    "code_imports",
13    "code_calls",
14];
15
16const REQUIRED_BM25_INDEXES: &[&str] = &["code_symbols_search_bm25", "code_content_search_bm25"];
17
18const MIGRATION_HINT: &str = "Configure the Gobby PostgreSQL hub with the required code-index schema, `pg_search` extension, and BM25 indexes. For standalone databases, run `gcode setup --standalone --database-url <dsn>`.";
19
20/// Validate that the Gobby-owned PostgreSQL hub schema exists.
21///
22/// gcode does not create, alter, or drop hub tables. Schema ownership stays in
23/// the Gobby baseline and migration chain.
24pub fn validate_runtime_schema(client: &mut Client) -> anyhow::Result<()> {
25    if !extension_exists(client, "pg_search")? {
26        bail!("PostgreSQL hub is missing required extension `pg_search`. {MIGRATION_HINT}");
27    }
28
29    if !procedure_exists(client, BM25_SCORE_REGPROCEDURE)? {
30        bail!(
31            "PostgreSQL hub is missing required BM25 score function `{BM25_SCORE_REGPROCEDURE}`. {MIGRATION_HINT}"
32        );
33    }
34
35    let missing_tables = missing_relations(client, REQUIRED_TABLES)?;
36    if !missing_tables.is_empty() {
37        bail!(
38            "PostgreSQL hub is missing required code-index tables: {}. {MIGRATION_HINT}",
39            missing_tables.join(", ")
40        );
41    }
42
43    let missing_indexes = missing_relations(client, REQUIRED_BM25_INDEXES)?;
44    if !missing_indexes.is_empty() {
45        bail!(
46            "PostgreSQL hub is missing required pg_search BM25 indexes: {}. {MIGRATION_HINT}",
47            missing_indexes.join(", ")
48        );
49    }
50
51    Ok(())
52}
53
54fn extension_exists(client: &mut Client, extension: &str) -> anyhow::Result<bool> {
55    client
56        .query_one(
57            "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = $1)",
58            &[&extension],
59        )
60        .with_context(|| format!("failed to check PostgreSQL extension `{extension}`"))?
61        .try_get(0)
62        .context("failed to decode PostgreSQL extension check")
63}
64
65fn procedure_exists(client: &mut Client, procedure: &str) -> anyhow::Result<bool> {
66    client
67        .query_one("SELECT to_regprocedure($1) IS NOT NULL", &[&procedure])
68        .with_context(|| format!("failed to check PostgreSQL procedure `{procedure}`"))?
69        .try_get(0)
70        .context("failed to decode PostgreSQL procedure check")
71}
72
73fn missing_relations(client: &mut Client, relations: &[&str]) -> anyhow::Result<Vec<String>> {
74    let mut missing = Vec::new();
75    for relation in relations {
76        let qualified = format!("{DEFAULT_SCHEMA}.{relation}");
77        let row = client
78            .query_one("SELECT to_regclass($1) IS NOT NULL", &[&qualified])
79            .with_context(|| format!("failed to check PostgreSQL relation `{qualified}`"))?;
80        let exists: bool = row
81            .try_get(0)
82            .context("failed to decode PostgreSQL relation check")?;
83        if !exists {
84            missing.push((*relation).to_string());
85        }
86    }
87    Ok(missing)
88}
89
90#[cfg(test)]
91fn required_relation_regclass_name(relation: &str) -> String {
92    format!("{DEFAULT_SCHEMA}.{relation}")
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    #[test]
99    fn required_schema_contract_names_code_index_tables_and_bm25_indexes() {
100        assert!(REQUIRED_TABLES.contains(&"code_symbols"));
101        assert!(REQUIRED_TABLES.contains(&"code_content_chunks"));
102        assert!(REQUIRED_BM25_INDEXES.contains(&"code_symbols_search_bm25"));
103        assert!(REQUIRED_BM25_INDEXES.contains(&"code_content_search_bm25"));
104        assert_eq!(BM25_SCORE_REGPROCEDURE, "pdb.score(anyelement)");
105    }
106
107    mod serial_db {
108        use super::*;
109
110        #[test]
111        #[serial_test::serial(serial_db)]
112        fn validates_runtime_schema_when_postgres_test_dsn_is_set() {
113            let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
114                return;
115            };
116
117            let mut client = gobby_core::postgres::connect_readwrite(&database_url)
118                .expect("connect test PostgreSQL hub");
119            assert!(validate_runtime_schema(&mut client).is_ok());
120        }
121    }
122
123    #[test]
124    fn missing_schema_requires_setup() {
125        assert!(
126            MIGRATION_HINT.contains("gcode setup --standalone"),
127            "missing runtime schema guidance must point standalone users at explicit setup"
128        );
129    }
130
131    #[test]
132    fn relation_validation_qualifies_public_schema() {
133        assert_eq!(
134            required_relation_regclass_name("code_symbols"),
135            "public.code_symbols"
136        );
137    }
138}