use sqlx::PgPool;
use uuid::Uuid;
use crate::config::validate_schema_name;
use crate::error::{RemembrallError, Result};
use super::types::*;
pub struct GraphStore {
pool: PgPool,
schema: String,
}
impl GraphStore {
pub fn new(pool: PgPool, schema: String) -> Result<Self> {
validate_schema_name(&schema)
.map_err(RemembrallError::InvalidInput)?;
Ok(Self { pool, schema })
}
pub async fn init(&self) -> Result<()> {
sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", self.schema))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE TABLE IF NOT EXISTS {schema}.symbols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
symbol_type TEXT NOT NULL,
file_path TEXT NOT NULL,
start_line INTEGER,
end_line INTEGER,
language TEXT NOT NULL,
project TEXT NOT NULL,
signature TEXT,
file_mtime TIMESTAMPTZ NOT NULL,
layer TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
ALTER TABLE {schema}.symbols
ADD COLUMN IF NOT EXISTS parent_symbol_id UUID,
ADD COLUMN IF NOT EXISTS moniker TEXT
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE TABLE IF NOT EXISTS {schema}.relationships (
source_id UUID NOT NULL REFERENCES {schema}.symbols(id) ON DELETE CASCADE,
target_id UUID NOT NULL REFERENCES {schema}.symbols(id) ON DELETE CASCADE,
rel_type TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (source_id, target_id, rel_type)
)
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE INDEX IF NOT EXISTS idx_symbols_file ON {schema}.symbols (file_path);
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE INDEX IF NOT EXISTS idx_symbols_name ON {schema}.symbols (name, symbol_type);
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE INDEX IF NOT EXISTS idx_symbols_parent ON {schema}.symbols (parent_symbol_id);
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE INDEX IF NOT EXISTS idx_relationships_source ON {schema}.relationships (source_id);
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
sqlx::query(&format!(
r#"
CREATE INDEX IF NOT EXISTS idx_relationships_target ON {schema}.relationships (target_id);
"#,
schema = self.schema,
))
.execute(&self.pool)
.await?;
tracing::info!("Graph store initialized in schema '{}'", self.schema);
Ok(())
}
pub async fn upsert_symbol(&self, symbol: &Symbol) -> Result<Uuid> {
let symbol_type = symbol.symbol_type.to_string();
let (id,) = sqlx::query_as::<_, (Uuid,)>(&format!(
r#"
INSERT INTO {schema}.symbols
(id, name, symbol_type, file_path, start_line, end_line, language, project, signature, file_mtime, layer, parent_symbol_id, moniker)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
symbol_type = EXCLUDED.symbol_type,
start_line = EXCLUDED.start_line,
end_line = EXCLUDED.end_line,
signature = EXCLUDED.signature,
file_mtime = EXCLUDED.file_mtime,
layer = EXCLUDED.layer,
parent_symbol_id = EXCLUDED.parent_symbol_id,
moniker = EXCLUDED.moniker
RETURNING id
"#,
schema = self.schema,
))
.bind(symbol.id)
.bind(&symbol.name)
.bind(&symbol_type)
.bind(&symbol.file_path)
.bind(symbol.start_line)
.bind(symbol.end_line)
.bind(&symbol.language)
.bind(&symbol.project)
.bind(&symbol.signature)
.bind(symbol.file_mtime)
.bind(&symbol.layer)
.bind(symbol.parent_symbol_id)
.bind(&symbol.moniker)
.fetch_one(&self.pool)
.await?;
Ok(id)
}
pub async fn upsert_symbols_batch(&self, symbols: &[Symbol]) -> Result<()> {
if symbols.is_empty() {
return Ok(());
}
for chunk in symbols.chunks(500) {
let mut query_str = format!(
"INSERT INTO {schema}.symbols \
(id, name, symbol_type, file_path, start_line, end_line, language, project, signature, file_mtime, layer, parent_symbol_id, moniker) \
VALUES ",
schema = self.schema,
);
let cols_per_row: usize = 13;
for (i, _) in chunk.iter().enumerate() {
if i > 0 {
query_str.push_str(", ");
}
let base = i * cols_per_row + 1;
query_str.push_str(&format!(
"(${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${})",
base,
base + 1,
base + 2,
base + 3,
base + 4,
base + 5,
base + 6,
base + 7,
base + 8,
base + 9,
base + 10,
base + 11,
base + 12,
));
}
query_str.push_str(
" ON CONFLICT (id) DO UPDATE SET \
name = EXCLUDED.name, \
symbol_type = EXCLUDED.symbol_type, \
start_line = EXCLUDED.start_line, \
end_line = EXCLUDED.end_line, \
signature = EXCLUDED.signature, \
file_mtime = EXCLUDED.file_mtime, \
layer = EXCLUDED.layer, \
parent_symbol_id = EXCLUDED.parent_symbol_id, \
moniker = EXCLUDED.moniker",
);
let mut query = sqlx::query(&query_str);
for sym in chunk {
let symbol_type = sym.symbol_type.to_string();
query = query
.bind(sym.id)
.bind(&sym.name)
.bind(symbol_type)
.bind(&sym.file_path)
.bind(sym.start_line)
.bind(sym.end_line)
.bind(&sym.language)
.bind(&sym.project)
.bind(&sym.signature)
.bind(sym.file_mtime)
.bind(&sym.layer)
.bind(sym.parent_symbol_id)
.bind(&sym.moniker);
}
query.execute(&self.pool).await?;
}
Ok(())
}
pub async fn add_relationships_batch(&self, rels: &[Relationship]) -> Result<()> {
if rels.is_empty() {
return Ok(());
}
let known_ids: std::collections::HashSet<Uuid> = sqlx::query_as::<_, (Uuid,)>(
&format!("SELECT id FROM {schema}.symbols", schema = self.schema),
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|(id,)| id)
.collect();
for chunk in rels.chunks(1000) {
let mut seen = std::collections::HashSet::new();
let filtered: Vec<&Relationship> = chunk
.iter()
.filter(|r| r.source_id != r.target_id)
.filter(|r| known_ids.contains(&r.source_id) && known_ids.contains(&r.target_id))
.filter(|r| seen.insert((r.source_id, r.target_id, r.rel_type.to_string())))
.collect();
if filtered.is_empty() {
continue;
}
let mut query_str = format!(
"INSERT INTO {schema}.relationships (source_id, target_id, rel_type, confidence) VALUES ",
schema = self.schema,
);
for (i, _) in filtered.iter().enumerate() {
if i > 0 {
query_str.push_str(", ");
}
let base = i * 4 + 1;
query_str.push_str(&format!(
"(${}, ${}, ${}, ${})",
base,
base + 1,
base + 2,
base + 3,
));
}
query_str.push_str(
" ON CONFLICT (source_id, target_id, rel_type) DO UPDATE SET \
confidence = EXCLUDED.confidence",
);
let mut query = sqlx::query(&query_str);
for rel in &filtered {
query = query
.bind(rel.source_id)
.bind(rel.target_id)
.bind(rel.rel_type.to_string())
.bind(rel.confidence);
}
query.execute(&self.pool).await?;
}
Ok(())
}
pub async fn add_relationship(&self, rel: &Relationship) -> Result<()> {
let rel_type = rel.rel_type.to_string();
sqlx::query(&format!(
r#"
INSERT INTO {schema}.relationships (source_id, target_id, rel_type, confidence)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source_id, target_id, rel_type) DO UPDATE SET
confidence = EXCLUDED.confidence
"#,
schema = self.schema,
))
.bind(rel.source_id)
.bind(rel.target_id)
.bind(&rel_type)
.bind(rel.confidence)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn impact_analysis(
&self,
symbol_id: Uuid,
direction: Direction,
max_depth: i32,
) -> Result<Vec<ImpactResult>> {
if let Direction::Both = direction {
let mut upstream = Box::pin(self.impact_analysis(symbol_id, Direction::Upstream, max_depth)).await?;
let downstream = Box::pin(self.impact_analysis(symbol_id, Direction::Downstream, max_depth)).await?;
upstream.extend(downstream);
return Ok(upstream);
}
let (join_col, match_col) = match direction {
Direction::Upstream => ("target_id", "source_id"),
Direction::Downstream => ("source_id", "target_id"),
Direction::Both => unreachable!(),
};
let sql = format!(
r#"
WITH RECURSIVE impact AS (
-- Base case: direct relationships
SELECT
r.{match_col} AS symbol_id,
r.rel_type,
r.confidence,
1 AS depth,
ARRAY[r.{join_col}, r.{match_col}] AS path
FROM {schema}.relationships r
WHERE r.{join_col} = $1
UNION ALL
-- Recursive: follow the chain
SELECT
r.{match_col} AS symbol_id,
r.rel_type,
r.confidence * i.confidence AS confidence,
i.depth + 1 AS depth,
i.path || r.{match_col} AS path
FROM {schema}.relationships r
JOIN impact i ON r.{join_col} = i.symbol_id
WHERE i.depth < $2
AND NOT r.{match_col} = ANY(i.path) -- prevent cycles
)
SELECT
s.id, s.name, s.symbol_type, s.file_path, s.start_line, s.end_line,
s.language, s.project, s.signature, s.file_mtime, s.layer,
s.parent_symbol_id, s.moniker,
i.depth, i.path, i.rel_type, i.confidence
FROM impact i
JOIN {schema}.symbols s ON s.id = i.symbol_id
ORDER BY i.depth ASC, i.confidence DESC
"#,
schema = self.schema,
);
let rows = sqlx::query_as::<_, ImpactRow>(&sql)
.bind(symbol_id)
.bind(max_depth)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| r.into_impact_result()).collect())
}
pub async fn find_symbol(
&self,
name: &str,
symbol_type: Option<&SymbolType>,
project: Option<&str>,
) -> Result<Vec<Symbol>> {
let type_filter = symbol_type.map(|t| t.to_string());
let mut param_idx = 2u32;
let mut clauses = String::new();
if type_filter.is_some() {
clauses.push_str(&format!(" AND symbol_type = ${param_idx}"));
param_idx += 1;
}
if project.is_some() {
clauses.push_str(&format!(" AND project = ${param_idx}"));
}
let sql = format!(
r#"
SELECT id, name, symbol_type, file_path, start_line, end_line,
language, project, signature, file_mtime, layer,
parent_symbol_id, moniker
FROM {schema}.symbols
WHERE (
name ILIKE $1
OR regexp_replace(file_path, '^.*/', '') ILIKE ($1 || '.%')
OR regexp_replace(file_path, '^.*/', '') ILIKE $1
)
{clauses}
ORDER BY
CASE WHEN lower(name) = lower($1) THEN 0 ELSE 1 END,
name
LIMIT 50
"#,
schema = self.schema,
);
let mut query = sqlx::query_as::<_, SymbolRow>(&sql).bind(name);
if let Some(ref t) = type_filter {
query = query.bind(t);
}
if let Some(p) = project {
query = query.bind(p);
}
let rows = query.fetch_all(&self.pool).await?;
Ok(rows.into_iter().map(|r| r.into_symbol()).collect())
}
pub async fn generate_tour(&self, project: &str, limit: usize) -> Result<Vec<TourStop>> {
#[derive(sqlx::FromRow)]
struct FileRow {
id: uuid::Uuid,
file_path: String,
language: String,
}
let file_rows = sqlx::query_as::<_, FileRow>(&format!(
r#"
SELECT id, file_path, language
FROM {schema}.symbols
WHERE project = $1 AND symbol_type = 'file'
"#,
schema = self.schema,
))
.bind(project)
.fetch_all(&self.pool)
.await?;
if file_rows.is_empty() {
return Ok(vec![]);
}
let mut path_to_meta: std::collections::HashMap<String, (uuid::Uuid, String)> =
std::collections::HashMap::new();
for row in &file_rows {
path_to_meta.insert(row.file_path.clone(), (row.id, row.language.clone()));
}
#[derive(sqlx::FromRow)]
struct EdgeRow {
source_file: String,
target_file: String,
}
let edge_rows = sqlx::query_as::<_, EdgeRow>(&format!(
r#"
SELECT DISTINCT s.file_path AS source_file, t.file_path AS target_file
FROM {schema}.relationships r
JOIN {schema}.symbols s ON s.id = r.source_id
JOIN {schema}.symbols t ON t.id = r.target_id
WHERE r.rel_type = 'imports'
AND s.project = $1
AND t.project = $1
AND s.symbol_type = 'file'
AND t.symbol_type = 'file'
"#,
schema = self.schema,
))
.bind(project)
.fetch_all(&self.pool)
.await?;
let all_files: Vec<String> = file_rows.iter().map(|r| r.file_path.clone()).collect();
let mut imports_from: std::collections::HashMap<String, Vec<String>> =
all_files.iter().map(|f| (f.clone(), vec![])).collect();
let mut imported_by: std::collections::HashMap<String, Vec<String>> =
all_files.iter().map(|f| (f.clone(), vec![])).collect();
for edge in &edge_rows {
if imports_from.contains_key(&edge.source_file)
&& imported_by.contains_key(&edge.target_file)
{
imports_from
.entry(edge.source_file.clone())
.or_default()
.push(edge.target_file.clone());
imported_by
.entry(edge.target_file.clone())
.or_default()
.push(edge.source_file.clone());
}
}
let mut in_degree: std::collections::HashMap<String, usize> = all_files
.iter()
.map(|f| (f.clone(), imported_by[f].len()))
.collect();
#[derive(sqlx::FromRow)]
struct SymRow {
file_path: String,
name: String,
}
let sym_rows = sqlx::query_as::<_, SymRow>(&format!(
r#"
SELECT file_path, name
FROM {schema}.symbols
WHERE project = $1 AND symbol_type != 'file'
ORDER BY file_path, start_line
"#,
schema = self.schema,
))
.bind(project)
.fetch_all(&self.pool)
.await?;
let mut file_symbols: std::collections::HashMap<String, Vec<String>> =
all_files.iter().map(|f| (f.clone(), vec![])).collect();
for row in sym_rows {
file_symbols.entry(row.file_path).or_default().push(row.name);
}
let mut initial_queue: Vec<String> = all_files
.iter()
.filter(|f| in_degree[*f] == 0)
.cloned()
.collect();
initial_queue.sort();
let mut queue: std::collections::VecDeque<String> =
initial_queue.into_iter().collect();
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut ordered: Vec<String> = Vec::new();
while let Some(file) = queue.pop_front() {
if visited.contains(&file) {
continue;
}
visited.insert(file.clone());
ordered.push(file.clone());
let mut deps: Vec<String> = imports_from
.get(&file)
.cloned()
.unwrap_or_default();
deps.sort();
for dep in deps {
let deg = in_degree.entry(dep.clone()).or_insert(1);
if *deg > 0 {
*deg -= 1;
}
if *deg == 0 && !visited.contains(&dep) {
queue.push_back(dep);
}
}
}
let mut remaining: Vec<String> = all_files
.iter()
.filter(|f| !visited.contains(*f))
.cloned()
.collect();
remaining.sort();
ordered.extend(remaining);
let stops: Vec<TourStop> = ordered
.into_iter()
.take(limit)
.enumerate()
.map(|(idx, file)| {
let order = idx + 1;
let language = path_to_meta
.get(&file)
.map(|(_, lang)| lang.clone())
.unwrap_or_default();
let symbols = file_symbols.get(&file).cloned().unwrap_or_default();
let imp_from = imports_from.get(&file).cloned().unwrap_or_default();
let imp_by = imported_by.get(&file).cloned().unwrap_or_default();
let reason = if imp_by.is_empty() && imp_from.is_empty() {
"Standalone file - no import relationships recorded".to_string()
} else if imp_by.is_empty() {
"Entry point - no other files import this".to_string()
} else if imp_from.is_empty() {
format!(
"Core module - imported by {} file{}",
imp_by.len(),
if imp_by.len() == 1 { "" } else { "s" }
)
} else {
let dep_names: Vec<&str> = imp_from
.iter()
.take(3)
.map(|s| s.rfind('/').map(|i| &s[i + 1..]).unwrap_or(s.as_str()))
.collect();
format!("Depends on: {} (read those first)", dep_names.join(", "))
};
TourStop {
order,
file_path: file,
language,
symbols,
imports_from: imp_from,
imported_by: imp_by,
reason,
}
})
.collect();
Ok(stops)
}
pub async fn remove_file(&self, file_path: &str, project: &str) -> Result<u64> {
let result = sqlx::query(&format!(
"DELETE FROM {schema}.symbols WHERE file_path = $1 AND project = $2",
schema = self.schema,
))
.bind(file_path)
.bind(project)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn remove_project(&self, project: &str) -> Result<u64> {
let result = sqlx::query(&format!(
"DELETE FROM {schema}.symbols WHERE project = $1",
schema = self.schema,
))
.bind(project)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
#[derive(sqlx::FromRow)]
struct SymbolRow {
id: Uuid,
name: String,
symbol_type: String,
file_path: String,
start_line: Option<i32>,
end_line: Option<i32>,
language: String,
project: String,
signature: Option<String>,
file_mtime: chrono::DateTime<chrono::Utc>,
layer: Option<String>,
parent_symbol_id: Option<Uuid>,
moniker: Option<String>,
}
impl SymbolRow {
fn into_symbol(self) -> Symbol {
Symbol {
id: self.id,
name: self.name,
symbol_type: self.symbol_type.parse().unwrap_or(SymbolType::Function),
file_path: self.file_path,
start_line: self.start_line,
end_line: self.end_line,
language: self.language,
project: self.project,
signature: self.signature,
file_mtime: self.file_mtime,
layer: self.layer,
parent_symbol_id: self.parent_symbol_id,
moniker: self.moniker,
}
}
}
#[derive(sqlx::FromRow)]
struct ImpactRow {
id: Uuid,
name: String,
symbol_type: String,
file_path: String,
start_line: Option<i32>,
end_line: Option<i32>,
language: String,
project: String,
signature: Option<String>,
file_mtime: chrono::DateTime<chrono::Utc>,
layer: Option<String>,
parent_symbol_id: Option<Uuid>,
moniker: Option<String>,
depth: i32,
path: Vec<Uuid>,
rel_type: String,
confidence: f32,
}
impl ImpactRow {
fn into_impact_result(self) -> ImpactResult {
ImpactResult {
symbol: Symbol {
id: self.id,
name: self.name,
symbol_type: self.symbol_type.parse().unwrap_or(SymbolType::Function),
file_path: self.file_path,
start_line: self.start_line,
end_line: self.end_line,
language: self.language,
project: self.project,
signature: self.signature,
file_mtime: self.file_mtime,
layer: self.layer,
parent_symbol_id: self.parent_symbol_id,
moniker: self.moniker,
},
depth: self.depth,
path: self.path,
relationship: self.rel_type.parse().unwrap_or(RelationType::Calls),
confidence: self.confidence,
}
}
}