daimon_plugin_pgvector/
builder.rs1use daimon_core::{DaimonError, Result};
4use deadpool_postgres::{Config, Pool, Runtime};
5use tokio_postgres::NoTls;
6
7use crate::DistanceMetric;
8use crate::migrations;
9use crate::store::PgVectorStore;
10
11pub struct PgVectorStoreBuilder {
28 connection_string: String,
29 dimensions: usize,
30 table: String,
31 distance_metric: DistanceMetric,
32 auto_migrate: bool,
33 hnsw_m: Option<usize>,
34 hnsw_ef_construction: Option<usize>,
35 pool_size: usize,
36}
37
38impl PgVectorStoreBuilder {
39 pub fn new(connection_string: impl Into<String>, dimensions: usize) -> Self {
46 Self {
47 connection_string: connection_string.into(),
48 dimensions,
49 table: "daimon_vectors".into(),
50 distance_metric: DistanceMetric::Cosine,
51 auto_migrate: true,
52 hnsw_m: None,
53 hnsw_ef_construction: None,
54 pool_size: 16,
55 }
56 }
57
58 pub fn table(mut self, table: impl Into<String>) -> Self {
60 self.table = table.into();
61 self
62 }
63
64 pub fn distance_metric(mut self, metric: DistanceMetric) -> Self {
66 self.distance_metric = metric;
67 self
68 }
69
70 pub fn auto_migrate(mut self, enabled: bool) -> Self {
76 self.auto_migrate = enabled;
77 self
78 }
79
80 pub fn hnsw_m(mut self, m: usize) -> Self {
83 self.hnsw_m = Some(m);
84 self
85 }
86
87 pub fn hnsw_ef_construction(mut self, ef: usize) -> Self {
90 self.hnsw_ef_construction = Some(ef);
91 self
92 }
93
94 pub fn pool_size(mut self, size: usize) -> Self {
96 self.pool_size = size;
97 self
98 }
99
100 pub async fn build(self) -> Result<PgVectorStore> {
102 validate_table_name(&self.table)?;
109
110 let pool = self.create_pool()?;
111
112 if self.auto_migrate {
113 self.run_migrations(&pool).await?;
114 }
115
116 Ok(PgVectorStore {
117 pool,
118 table: self.table,
119 dimensions: self.dimensions,
120 distance_metric: self.distance_metric,
121 })
122 }
123
124 fn create_pool(&self) -> Result<Pool> {
127 let mut cfg = Config::new();
128 cfg.url = Some(self.connection_string.clone());
129 cfg.pool = Some(deadpool_postgres::PoolConfig {
130 max_size: self.pool_size,
131 ..Default::default()
132 });
133
134 cfg.create_pool(Some(Runtime::Tokio1), NoTls)
135 .map_err(|e| DaimonError::Other(format!("pgvector pool creation error: {e}")))
136 }
137
138 async fn run_migrations(&self, pool: &Pool) -> Result<()> {
139 let client = pool
140 .get()
141 .await
142 .map_err(|e| DaimonError::Other(format!("pgvector migration pool error: {e}")))?;
143
144 tracing::info!("pgvector: creating extension and table '{}'", self.table);
145
146 client
147 .execute(migrations::CREATE_EXTENSION, &[])
148 .await
149 .map_err(|e| DaimonError::Other(format!("pgvector CREATE EXTENSION error: {e}")))?;
150
151 let create_table = migrations::create_table_sql(&self.table, self.dimensions);
152 client
153 .execute(&create_table as &str, &[])
154 .await
155 .map_err(|e| DaimonError::Other(format!("pgvector CREATE TABLE error: {e}")))?;
156
157 let ops_class = self.distance_metric.ops_class();
158 let create_index = migrations::create_hnsw_index_sql(
159 &self.table,
160 ops_class,
161 self.hnsw_m,
162 self.hnsw_ef_construction,
163 );
164 client
165 .execute(&create_index as &str, &[])
166 .await
167 .map_err(|e| DaimonError::Other(format!("pgvector CREATE INDEX error: {e}")))?;
168
169 tracing::info!("pgvector: migration complete for '{}'", self.table);
170 Ok(())
171 }
172}
173
174fn validate_table_name(table: &str) -> Result<()> {
182 fn is_valid_part(part: &str) -> bool {
183 let mut chars = part.chars();
184 match chars.next() {
185 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
186 _ => return false,
187 }
188 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
189 }
190
191 if table.is_empty() {
192 return Err(DaimonError::Other(
193 "pgvector: table name must not be empty".to_string(),
194 ));
195 }
196
197 let parts: Vec<&str> = table.split('.').collect();
198 if parts.len() > 2 || !parts.iter().all(|p| is_valid_part(p)) {
199 return Err(DaimonError::Other(format!(
200 "pgvector: invalid table name '{table}': expected an identifier matching \
201 [A-Za-z_][A-Za-z0-9_]* (optionally schema-qualified as schema.table)"
202 )));
203 }
204
205 Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_valid_table_names() {
214 assert!(validate_table_name("embeddings").is_ok());
215 assert!(validate_table_name("daimon_vectors").is_ok());
216 assert!(validate_table_name("_private").is_ok());
217 assert!(validate_table_name("Table123").is_ok());
218 assert!(validate_table_name("public.embeddings").is_ok());
219 }
220
221 #[test]
222 fn test_invalid_table_names_rejected() {
223 assert!(validate_table_name("").is_err());
224 assert!(validate_table_name("foo; DROP TABLE bar").is_err());
225 assert!(validate_table_name("foo; DROP TABLE bar; --").is_err());
226 assert!(validate_table_name("\"foo\"").is_err());
227 assert!(validate_table_name("foo bar").is_err());
228 assert!(validate_table_name("1foo").is_err());
229 assert!(validate_table_name("foo.bar.baz").is_err());
230 assert!(validate_table_name("foo'").is_err());
231 assert!(validate_table_name("foo)").is_err());
232 assert!(validate_table_name("schema..table").is_err());
233 }
234}