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_schema = 'public' AND table_name = $1 \
70             ORDER BY ordinal_position",
71        )
72        .bind(table_name.as_str())
73        .fetch_all(&*self.pool)
74        .await?;
75
76        if rows.is_empty() {
77            return Err(RepositoryError::not_found(format!(
78                "Table '{table_name}' not found"
79            )));
80        }
81
82        let pk_rows = sqlx::query(
83            r"
84            SELECT a.attname as column_name
85            FROM pg_index i
86            JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
87            WHERE i.indrelid = $1::regclass AND i.indisprimary
88            ",
89        )
90        .bind(table_name.as_str())
91        .fetch_all(&*self.pool)
92        .await?;
93
94        let pk_columns: Vec<String> = pk_rows
95            .iter()
96            .map(|row| row.get::<String, _>("column_name"))
97            .collect();
98
99        let columns = rows
100            .iter()
101            .map(|row| {
102                let name: String = row.get("column_name");
103                let data_type: String = row.get("data_type");
104                let nullable_str: String = row.get("is_nullable");
105                let nullable = nullable_str.to_uppercase() == "YES";
106                let default: Option<String> = row.get("column_default");
107                let primary_key = pk_columns.contains(&name);
108
109                ColumnInfo {
110                    name,
111                    data_type,
112                    nullable,
113                    primary_key,
114                    default,
115                }
116            })
117            .collect();
118
119        let row_count = self.count_rows(table_name).await?;
120
121        Ok((columns, row_count))
122    }
123
124    pub async fn list_table_indexes(
125        &self,
126        table_name: &SafeIdentifier,
127    ) -> DatabaseResult<Vec<IndexInfo>> {
128        let rows = sqlx::query(
129            r"
130            SELECT
131                i.relname as index_name,
132                ix.indisunique as is_unique,
133                array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
134            FROM pg_class t
135            JOIN pg_index ix ON t.oid = ix.indrelid
136            JOIN pg_class i ON i.oid = ix.indexrelid
137            JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
138            WHERE t.relname = $1 AND t.relkind = 'r'
139            GROUP BY i.relname, ix.indisunique
140            ORDER BY i.relname
141            ",
142        )
143        .bind(table_name.as_str())
144        .fetch_all(&*self.pool)
145        .await?;
146
147        let indexes = rows
148            .iter()
149            .map(|row| {
150                let name: String = row.get("index_name");
151                let unique: bool = row.get("is_unique");
152                let columns: Vec<String> = row.get("columns");
153                IndexInfo {
154                    name,
155                    columns,
156                    unique,
157                }
158            })
159            .collect();
160
161        Ok(indexes)
162    }
163
164    pub async fn count_rows(&self, table_name: &SafeIdentifier) -> DatabaseResult<i64> {
165        let quoted_table = table_name.quoted();
166        let count_query = format!("SELECT COUNT(*) as count FROM {quoted_table}");
167        let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(count_query))
168            .fetch_one(&*self.pool)
169            .await?;
170
171        Ok(row_count)
172    }
173
174    pub async fn get_database_info(&self) -> DatabaseResult<DatabaseInfo> {
175        let version: String = sqlx::query_scalar("SELECT version()")
176            .fetch_one(&*self.pool)
177            .await?;
178
179        let size: i64 = sqlx::query_scalar("SELECT pg_database_size(current_database())")
180            .fetch_one(&*self.pool)
181            .await?;
182
183        let size = u64::try_from(size).map_err(|_e| {
184            RepositoryError::internal(format!("pg_database_size returned negative value: {size}"))
185        })?;
186
187        let tables = self.list_tables().await?;
188
189        Ok(DatabaseInfo {
190            path: "PostgreSQL".to_owned(),
191            size,
192            version,
193            tables,
194        })
195    }
196}