Skip to main content

systemprompt_database/admin/
introspection.rs

1//! Schema introspection service.
2//!
3//! Part of the documented sqlx allowlist — every query here is built
4//! dynamically because the table name is supplied at runtime as a
5//! [`SafeIdentifier`].
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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}