Skip to main content

larust_db/sql/
introspect.rs

1//! Schema introspection - `sqlx::Any` has no portable "list tables"/
2//! "describe table" API of its own (confirmed by reading its source: the
3//! only "meta" operations it exposes are database-level create/drop, for
4//! `sqlx::migrate`, nothing table/column-level). Every function here
5//! hand-writes the per-backend catalog query - `sqlite_master`/`PRAGMA
6//! table_info` for SQLite, the standard SQL-92 `information_schema` views
7//! for MySQL/Postgres - and decodes the result through the exact same
8//! generic row-reading path (`sqlx::Row`) everything else in this crate's
9//! `sql` module uses.
10
11use larust_core::AppError;
12use larust_orm::Backend;
13use sqlx::any::AnyTypeInfoKind;
14use sqlx::Row;
15
16/// One column's shape - enough to render an edit input and bind a
17/// submitted value back to it correctly (see `codec::json_to_any_value`).
18/// A `Blob`-kind column is a known, stated v1 gap, not silently mishandled:
19/// the browse view renders its value as `<blob, N bytes>` (never the raw
20/// bytes), and a submitted value for one is stored as plain text rather
21/// than real binary - `dashboard::sql_views`'s edit/insert forms don't yet
22/// special-case it with a distinct (disabled, or upload-shaped) input, so
23/// editing one through the dashboard silently overwrites it with UTF-8
24/// text. No table in `demo` or the framework's own schema has one today.
25#[derive(Debug, Clone)]
26pub struct ColumnInfo {
27    pub name: String,
28    pub not_null: bool,
29    pub kind: AnyTypeInfoKind,
30}
31
32fn internal<E: std::error::Error + Send + Sync + 'static>(error: E) -> AppError {
33    AppError::Internal(Box::new(error))
34}
35
36/// Every real table in the connected database, alphabetical - delegates to
37/// `larust_orm::table_names()`, which hand-writes this same per-backend
38/// catalog query for its own [`larust_orm::migrate::fresh`]; kept as one
39/// query with two callers rather than duplicated here.
40pub async fn list_tables() -> Result<Vec<String>, AppError> {
41    larust_orm::table_names().await
42}
43
44/// `table`'s indexes, one row per index (shape varies by backend - see
45/// each arm below). Rendered through the same generic result-table
46/// machinery as the raw `/sql` page's output (`codec::row_to_json` handles
47/// any `AnyRow`, `PRAGMA`/`SHOW` output included), not a bespoke parsed
48/// struct - this is a read-only viewer, the raw columns are the point.
49/// Same "caller already validated `table`" contract as [`table_columns`].
50pub async fn list_indexes(table: &str) -> Result<Vec<serde_json::Value>, AppError> {
51    let pool = larust_orm::pool()?;
52    let rows = match larust_orm::backend() {
53        Backend::Sqlite => {
54            let sql = format!("PRAGMA index_list(\"{table}\")");
55            sqlx::query(&sql).fetch_all(pool).await.map_err(internal)?
56        }
57        Backend::MySql => {
58            // ANSI_QUOTES is set for the session (see `larust_orm::connect`'s
59            // `after_connect` hook), so a double-quoted identifier works
60            // here exactly like it does in ordinary SELECTs.
61            let sql = format!("SHOW INDEX FROM \"{table}\"");
62            sqlx::query(&sql).fetch_all(pool).await.map_err(internal)?
63        }
64        backend @ Backend::Postgres => {
65            let sql = format!(
66                "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = {}",
67                larust_orm::placeholder(backend, 1)
68            );
69            sqlx::query(&sql)
70                .bind(table)
71                .fetch_all(pool)
72                .await
73                .map_err(internal)?
74        }
75    };
76    Ok(rows.iter().map(crate::sql::codec::row_to_json).collect())
77}
78
79/// `table`'s foreign keys, one row per referencing column. Same shape/
80/// rendering rationale as [`list_indexes`].
81pub async fn list_foreign_keys(table: &str) -> Result<Vec<serde_json::Value>, AppError> {
82    let pool = larust_orm::pool()?;
83    let rows = match larust_orm::backend() {
84        Backend::Sqlite => {
85            let sql = format!("PRAGMA foreign_key_list(\"{table}\")");
86            sqlx::query(&sql).fetch_all(pool).await.map_err(internal)?
87        }
88        Backend::MySql => {
89            let sql = "SELECT column_name, referenced_table_name, referenced_column_name, \
90                        constraint_name FROM information_schema.key_column_usage \
91                        WHERE table_schema = DATABASE() AND table_name = ? \
92                        AND referenced_table_name IS NOT NULL";
93            sqlx::query(sql)
94                .bind(table)
95                .fetch_all(pool)
96                .await
97                .map_err(internal)?
98        }
99        Backend::Postgres => {
100            let sql = "SELECT kcu.column_name, ccu.table_name AS referenced_table_name, \
101                        ccu.column_name AS referenced_column_name, tc.constraint_name \
102                        FROM information_schema.table_constraints tc \
103                        JOIN information_schema.key_column_usage kcu \
104                          ON tc.constraint_name = kcu.constraint_name \
105                          AND tc.table_schema = kcu.table_schema \
106                        JOIN information_schema.constraint_column_usage ccu \
107                          ON tc.constraint_name = ccu.constraint_name \
108                          AND tc.table_schema = ccu.table_schema \
109                        WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1";
110            sqlx::query(sql)
111                .bind(table)
112                .fetch_all(pool)
113                .await
114                .map_err(internal)?
115        }
116    };
117    Ok(rows.iter().map(crate::sql::codec::row_to_json).collect())
118}
119
120/// `table`'s columns, in declaration order. Callers MUST have already
121/// validated `table` against [`list_tables`] - SQLite's `PRAGMA` can't
122/// take a bind parameter for the table name, so it's interpolated
123/// directly, safe only because it's guaranteed to be a real, existing
124/// table name by the time this runs, never raw user input.
125pub async fn table_columns(table: &str) -> Result<Vec<ColumnInfo>, AppError> {
126    let pool = larust_orm::pool()?;
127    match larust_orm::backend() {
128        Backend::Sqlite => {
129            let sql = format!("PRAGMA table_info(\"{table}\")");
130            let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(internal)?;
131            Ok(rows
132                .iter()
133                .map(|row| {
134                    let name: String = row.get("name");
135                    let type_text: String = row.get("type");
136                    let notnull: i64 = row.get("notnull");
137                    ColumnInfo {
138                        name,
139                        not_null: notnull != 0,
140                        kind: infer_kind(&type_text),
141                    }
142                })
143                .collect())
144        }
145        backend @ (Backend::MySql | Backend::Postgres) => {
146            let sql = format!(
147                "SELECT column_name, data_type, is_nullable FROM information_schema.columns \
148                 WHERE table_name = {} ORDER BY ordinal_position",
149                larust_orm::placeholder(backend, 1)
150            );
151            let rows = sqlx::query(&sql)
152                .bind(table)
153                .fetch_all(pool)
154                .await
155                .map_err(internal)?;
156            Ok(rows
157                .iter()
158                .map(|row| {
159                    let name: String = row.get("column_name");
160                    let type_text: String = row.get("data_type");
161                    let is_nullable: String = row.get("is_nullable");
162                    ColumnInfo {
163                        name,
164                        not_null: is_nullable != "YES",
165                        kind: infer_kind(&type_text),
166                    }
167                })
168                .collect())
169        }
170    }
171}
172
173/// `table`'s primary key column(s), in key order - composite-safe. Same
174/// "caller already validated `table`" contract as [`table_columns`].
175pub async fn primary_key_columns(table: &str) -> Result<Vec<String>, AppError> {
176    let pool = larust_orm::pool()?;
177    match larust_orm::backend() {
178        Backend::Sqlite => {
179            // PRAGMA table_info's own `pk` column is the 1-based ordinal
180            // within the primary key (0 = not part of it) - ordering by
181            // it directly reconstructs composite-key column order.
182            let sql = format!("PRAGMA table_info(\"{table}\")");
183            let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(internal)?;
184            let mut pk: Vec<(i64, String)> = rows
185                .iter()
186                .filter_map(|row| {
187                    let ordinal: i64 = row.get("pk");
188                    if ordinal == 0 {
189                        return None;
190                    }
191                    let name: String = row.get("name");
192                    Some((ordinal, name))
193                })
194                .collect();
195            pk.sort_by_key(|(ordinal, _)| *ordinal);
196            Ok(pk.into_iter().map(|(_, name)| name).collect())
197        }
198        backend @ (Backend::MySql | Backend::Postgres) => {
199            let sql = format!(
200                "SELECT kcu.column_name FROM information_schema.key_column_usage kcu \
201                 JOIN information_schema.table_constraints tc \
202                   ON kcu.constraint_name = tc.constraint_name \
203                   AND kcu.table_schema = tc.table_schema \
204                 WHERE tc.constraint_type = 'PRIMARY KEY' AND kcu.table_name = {} \
205                 ORDER BY kcu.ordinal_position",
206                larust_orm::placeholder(backend, 1)
207            );
208            let rows = sqlx::query(&sql)
209                .bind(table)
210                .fetch_all(pool)
211                .await
212                .map_err(internal)?;
213            Ok(rows
214                .iter()
215                .map(|row| row.get::<String, _>("column_name"))
216                .collect())
217        }
218    }
219}
220
221/// Maps a backend's own declared-type text (SQLite's free-form type
222/// affinity string, or `information_schema.columns.data_type`) onto the
223/// 9 kinds `sqlx::any` normalizes every value into. Deliberately
224/// pattern-based, not exhaustive - this schema (like most SQLite-first
225/// apps) has no table anywhere using a genuine `BOOLEAN`/`TIMESTAMP` SQL
226/// type (booleans and timestamps are plain `INTEGER`), so `Text` is the
227/// correct, safe fallback for anything not recognized rather than a
228/// guess that could silently misdecode.
229fn infer_kind(declared_type: &str) -> AnyTypeInfoKind {
230    let upper = declared_type.to_ascii_uppercase();
231    if upper.contains("BOOL") {
232        AnyTypeInfoKind::Bool
233    } else if upper.contains("BLOB") || upper.contains("BINARY") || upper.contains("BYTEA") {
234        AnyTypeInfoKind::Blob
235    } else if upper.contains("DOUBLE")
236        || upper.contains("REAL")
237        || upper.contains("FLOA")
238        || upper.contains("DEC")
239        || upper.contains("NUMERIC")
240    {
241        AnyTypeInfoKind::Double
242    } else if upper.contains("INT") {
243        AnyTypeInfoKind::BigInt
244    } else {
245        AnyTypeInfoKind::Text
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn infer_kind_recognizes_common_sqlite_and_information_schema_type_names() {
255        assert_eq!(infer_kind("INTEGER"), AnyTypeInfoKind::BigInt);
256        assert_eq!(infer_kind("integer"), AnyTypeInfoKind::BigInt);
257        assert_eq!(infer_kind("BIGINT"), AnyTypeInfoKind::BigInt);
258        assert_eq!(infer_kind("TEXT"), AnyTypeInfoKind::Text);
259        assert_eq!(infer_kind("character varying"), AnyTypeInfoKind::Text);
260        assert_eq!(infer_kind("VARCHAR(255)"), AnyTypeInfoKind::Text);
261        assert_eq!(infer_kind("REAL"), AnyTypeInfoKind::Double);
262        assert_eq!(infer_kind("DOUBLE PRECISION"), AnyTypeInfoKind::Double);
263        assert_eq!(infer_kind("NUMERIC(10,2)"), AnyTypeInfoKind::Double);
264        assert_eq!(infer_kind("BOOLEAN"), AnyTypeInfoKind::Bool);
265        assert_eq!(infer_kind("BLOB"), AnyTypeInfoKind::Blob);
266        assert_eq!(infer_kind("bytea"), AnyTypeInfoKind::Blob);
267        assert_eq!(infer_kind("some unknown type"), AnyTypeInfoKind::Text);
268    }
269}