systemprompt_database/admin/
introspection.rs1use std::sync::Arc;
11
12use sqlx::Row;
13use sqlx::postgres::PgPool;
14
15use crate::admin::identifier::SafeIdentifier;
16use crate::error::{DatabaseResult, RepositoryError};
17use crate::models::{ColumnInfo, DatabaseInfo, IndexInfo, TableInfo};
18
19#[derive(Debug)]
20pub struct DatabaseAdminService {
21 pool: Arc<PgPool>,
22}
23
24impl DatabaseAdminService {
25 pub const fn new(pool: Arc<PgPool>) -> Self {
26 Self { pool }
27 }
28
29 pub async fn list_tables(&self) -> DatabaseResult<Vec<TableInfo>> {
30 let rows = sqlx::query(
31 r"
32 SELECT
33 t.table_name as name,
34 COALESCE(s.n_live_tup, 0) as row_count,
35 COALESCE(pg_total_relation_size(quote_ident(t.table_name)::regclass), 0) as size_bytes
36 FROM information_schema.tables t
37 LEFT JOIN pg_stat_user_tables s ON t.table_name = s.relname
38 WHERE t.table_schema = 'public'
39 ORDER BY t.table_name
40 ",
41 )
42 .fetch_all(&*self.pool)
43 .await?;
44
45 let tables = rows
46 .iter()
47 .map(|row| {
48 let name: String = row.get("name");
49 let row_count: i64 = row.get("row_count");
50 let size_bytes: i64 = row.get("size_bytes");
51 TableInfo {
52 name,
53 row_count,
54 size_bytes,
55 columns: vec![],
56 }
57 })
58 .collect();
59
60 Ok(tables)
61 }
62
63 pub async fn describe_table(
64 &self,
65 table_name: &SafeIdentifier,
66 ) -> DatabaseResult<(Vec<ColumnInfo>, i64)> {
67 let rows = sqlx::query(
68 "SELECT column_name, data_type, is_nullable, column_default FROM \
69 information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position",
70 )
71 .bind(table_name.as_str())
72 .fetch_all(&*self.pool)
73 .await?;
74
75 if rows.is_empty() {
76 return Err(RepositoryError::not_found(format!(
77 "Table '{table_name}' not found"
78 )));
79 }
80
81 let pk_rows = sqlx::query(
82 r"
83 SELECT a.attname as column_name
84 FROM pg_index i
85 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
86 WHERE i.indrelid = $1::regclass AND i.indisprimary
87 ",
88 )
89 .bind(table_name.as_str())
90 .fetch_all(&*self.pool)
91 .await?;
92
93 let pk_columns: Vec<String> = pk_rows
94 .iter()
95 .map(|row| row.get::<String, _>("column_name"))
96 .collect();
97
98 let columns = rows
99 .iter()
100 .map(|row| {
101 let name: String = row.get("column_name");
102 let data_type: String = row.get("data_type");
103 let nullable_str: String = row.get("is_nullable");
104 let nullable = nullable_str.to_uppercase() == "YES";
105 let default: Option<String> = row.get("column_default");
106 let primary_key = pk_columns.contains(&name);
107
108 ColumnInfo {
109 name,
110 data_type,
111 nullable,
112 primary_key,
113 default,
114 }
115 })
116 .collect();
117
118 let row_count = self.count_rows(table_name).await?;
119
120 Ok((columns, row_count))
121 }
122
123 pub async fn list_table_indexes(
124 &self,
125 table_name: &SafeIdentifier,
126 ) -> DatabaseResult<Vec<IndexInfo>> {
127 let rows = sqlx::query(
128 r"
129 SELECT
130 i.relname as index_name,
131 ix.indisunique as is_unique,
132 array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
133 FROM pg_class t
134 JOIN pg_index ix ON t.oid = ix.indrelid
135 JOIN pg_class i ON i.oid = ix.indexrelid
136 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
137 WHERE t.relname = $1 AND t.relkind = 'r'
138 GROUP BY i.relname, ix.indisunique
139 ORDER BY i.relname
140 ",
141 )
142 .bind(table_name.as_str())
143 .fetch_all(&*self.pool)
144 .await?;
145
146 let indexes = rows
147 .iter()
148 .map(|row| {
149 let name: String = row.get("index_name");
150 let unique: bool = row.get("is_unique");
151 let columns: Vec<String> = row.get("columns");
152 IndexInfo {
153 name,
154 columns,
155 unique,
156 }
157 })
158 .collect();
159
160 Ok(indexes)
161 }
162
163 pub async fn count_rows(&self, table_name: &SafeIdentifier) -> DatabaseResult<i64> {
164 let quoted_table = quote_identifier(table_name.as_str());
165 let count_query = format!("SELECT COUNT(*) as count FROM {quoted_table}");
166 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(count_query))
167 .fetch_one(&*self.pool)
168 .await?;
169
170 Ok(row_count)
171 }
172
173 pub async fn get_database_info(&self) -> DatabaseResult<DatabaseInfo> {
174 let version: String = sqlx::query_scalar("SELECT version()")
175 .fetch_one(&*self.pool)
176 .await?;
177
178 let size: i64 = sqlx::query_scalar("SELECT pg_database_size(current_database())")
179 .fetch_one(&*self.pool)
180 .await?;
181
182 let size = u64::try_from(size).map_err(|_e| {
183 RepositoryError::internal(format!("pg_database_size returned negative value: {size}"))
184 })?;
185
186 let tables = self.list_tables().await?;
187
188 Ok(DatabaseInfo {
189 path: "PostgreSQL".to_owned(),
190 size,
191 version,
192 tables,
193 })
194 }
195
196 pub fn list_expected_tables() -> Vec<&'static str> {
197 vec![
198 "users",
199 "user_sessions",
200 "user_contexts",
201 "agent_tasks",
202 "agent_skills",
203 "task_messages",
204 "task_artifacts",
205 "task_execution_steps",
206 "artifact_parts",
207 "message_parts",
208 "ai_requests",
209 "ai_request_messages",
210 "ai_request_tool_calls",
211 "mcp_tool_executions",
212 "logs",
213 "analytics_events",
214 "oauth_clients",
215 "oauth_auth_codes",
216 "oauth_refresh_tokens",
217 "scheduled_jobs",
218 "services",
219 "markdown_content",
220 "markdown_categories",
221 "files",
222 "content_files",
223 ]
224 }
225}
226
227fn quote_identifier(identifier: &str) -> String {
228 let escaped = identifier.replace('"', "\"\"");
229 format!("\"{escaped}\"")
230}