Skip to main content

dbrest_core/schema_cache/
db.rs

1//! Database introspection trait
2//!
3//! This module defines a trait for database introspection that can be mocked
4//! in tests. This allows unit testing schema cache logic without a real database.
5
6use async_trait::async_trait;
7
8use crate::error::Error;
9
10use super::relationship::Relationship;
11use super::routine::Routine;
12use super::table::Table;
13
14/// Row type returned from tables query
15#[derive(Debug, Clone)]
16pub struct TableRow {
17    pub table_schema: String,
18    pub table_name: String,
19    pub table_description: Option<String>,
20    pub is_view: bool,
21    pub insertable: bool,
22    pub updatable: bool,
23    pub deletable: bool,
24    pub readable: bool,
25    pub pk_cols: Vec<String>,
26    pub columns_json: String, // JSON array of columns
27}
28
29/// Row type returned from relationships query
30#[derive(Debug, Clone)]
31pub struct RelationshipRow {
32    pub table_schema: String,
33    pub table_name: String,
34    pub foreign_table_schema: String,
35    pub foreign_table_name: String,
36    pub is_self: bool,
37    pub constraint_name: String,
38    pub cols_and_fcols: Vec<(String, String)>,
39    pub one_to_one: bool,
40}
41
42/// Row type returned from routines query
43#[derive(Debug, Clone)]
44pub struct RoutineRow {
45    pub routine_schema: String,
46    pub routine_name: String,
47    pub description: Option<String>,
48    pub params_json: String,      // JSON array of params
49    pub return_type_json: String, // JSON object
50    pub volatility: String,
51    pub is_variadic: bool,
52    pub executable: bool,
53}
54
55/// Row type returned from computed fields query
56#[derive(Debug, Clone)]
57pub struct ComputedFieldRow {
58    pub table_schema: String,
59    pub table_name: String,
60    pub function_schema: String,
61    pub function_name: String,
62    pub return_type: String,
63    pub returns_set: bool,
64}
65
66/// Trait for database introspection
67///
68/// This trait abstracts database queries for schema introspection, allowing
69/// the schema cache to be tested without a real database connection.
70#[cfg_attr(test, mockall::automock)]
71#[async_trait]
72pub trait DbIntrospector: Send + Sync {
73    /// Query all tables and views in the specified schemas
74    async fn query_tables(&self, schemas: &[String]) -> Result<Vec<TableRow>, Error>;
75
76    /// Query all foreign key relationships
77    async fn query_relationships(&self) -> Result<Vec<RelationshipRow>, Error>;
78
79    /// Query all routines (functions/procedures) in the specified schemas
80    async fn query_routines(&self, schemas: &[String]) -> Result<Vec<RoutineRow>, Error>;
81
82    /// Query computed field functions in the specified schemas
83    async fn query_computed_fields(
84        &self,
85        schemas: &[String],
86    ) -> Result<Vec<ComputedFieldRow>, Error>;
87
88    /// Query available timezones
89    async fn query_timezones(&self) -> Result<Vec<String>, Error>;
90}
91
92/// Parse a JSON string into a vector of columns for a table
93pub fn parse_columns_json(json: &str) -> Result<Vec<ColumnJson>, serde_json::Error> {
94    serde_json::from_str(json)
95}
96
97/// Parse a JSON string into a vector of routine parameters
98pub fn parse_params_json(json: &str) -> Result<Vec<ParamJson>, serde_json::Error> {
99    serde_json::from_str(json)
100}
101
102/// Parse a JSON string into a return type
103pub fn parse_return_type_json(json: &str) -> Result<ReturnTypeJson, serde_json::Error> {
104    serde_json::from_str(json)
105}
106
107/// JSON structure for column data
108#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
109pub struct ColumnJson {
110    pub name: String,
111    pub description: Option<String>,
112    pub nullable: bool,
113    pub data_type: String,
114    pub nominal_type: String,
115    pub max_length: Option<i32>,
116    pub default: Option<String>,
117    #[serde(default)]
118    pub enum_values: Vec<String>,
119    #[serde(default)]
120    pub is_composite: bool,
121    pub composite_type_schema: Option<String>,
122    pub composite_type_name: Option<String>,
123}
124
125/// JSON structure for routine parameter data
126#[derive(Debug, Clone, serde::Deserialize)]
127pub struct ParamJson {
128    pub name: String,
129    pub pg_type: String,
130    pub type_max_length: String,
131    pub required: bool,
132    #[serde(default)]
133    pub is_variadic: bool,
134}
135
136/// JSON structure for return type data
137#[derive(Debug, Clone, serde::Deserialize)]
138pub struct ReturnTypeJson {
139    pub kind: String,      // "single" or "setof"
140    pub type_kind: String, // "scalar" or "composite"
141    pub type_schema: String,
142    pub type_name: String,
143    #[serde(default)]
144    pub is_alias: bool,
145}
146
147/// Convert TableRow into Table
148impl TableRow {
149    pub fn into_table(self) -> Result<Table, Error> {
150        use compact_str::CompactString;
151        use indexmap::IndexMap;
152        use smallvec::SmallVec;
153        use std::collections::HashMap;
154        use std::sync::Arc;
155
156        use super::table::Column;
157
158        let columns_data: Vec<ColumnJson> = parse_columns_json(&self.columns_json)
159            .map_err(|e| Error::Internal(format!("Failed to parse columns JSON: {}", e)))?;
160
161        let mut columns = IndexMap::with_capacity(columns_data.len());
162        for col in columns_data {
163            // Trace: check if location column is being detected as composite
164            if col.name == "location" {
165                tracing::trace!(
166                    "Loading 'location' column - is_composite: {}, data_type: {}, composite_type_schema: {:?}, composite_type_name: {:?}",
167                    col.is_composite,
168                    col.data_type,
169                    col.composite_type_schema,
170                    col.composite_type_name
171                );
172            }
173            let column = Column {
174                name: col.name.clone().into(),
175                description: col.description,
176                nullable: col.nullable,
177                data_type: col.data_type.into(),
178                nominal_type: col.nominal_type.into(),
179                max_length: col.max_length,
180                default: col.default,
181                enum_values: col.enum_values.into_iter().collect(),
182                is_composite: col.is_composite,
183                composite_type_schema: col.composite_type_schema.map(|s| s.into()),
184                composite_type_name: col.composite_type_name.map(|s| s.into()),
185            };
186            columns.insert(CompactString::from(col.name), column);
187        }
188
189        Ok(Table {
190            schema: self.table_schema.into(),
191            name: self.table_name.into(),
192            description: self.table_description,
193            is_view: self.is_view,
194            insertable: self.insertable,
195            updatable: self.updatable,
196            deletable: self.deletable,
197            readable: self.readable,
198            pk_cols: self
199                .pk_cols
200                .into_iter()
201                .map(|s| s.into())
202                .collect::<SmallVec<_>>(),
203            columns: Arc::new(columns),
204            computed_fields: HashMap::new(), // Will be populated during schema cache load
205        })
206    }
207}
208
209/// Convert RelationshipRow into Relationship
210impl RelationshipRow {
211    pub fn into_relationship(self) -> Relationship {
212        use super::relationship::Cardinality;
213        use crate::types::QualifiedIdentifier;
214
215        let cardinality = if self.one_to_one {
216            Cardinality::O2O {
217                constraint: self.constraint_name.into(),
218                columns: self
219                    .cols_and_fcols
220                    .into_iter()
221                    .map(|(a, b)| (a.into(), b.into()))
222                    .collect(),
223                is_parent: false,
224            }
225        } else {
226            Cardinality::M2O {
227                constraint: self.constraint_name.into(),
228                columns: self
229                    .cols_and_fcols
230                    .into_iter()
231                    .map(|(a, b)| (a.into(), b.into()))
232                    .collect(),
233            }
234        };
235
236        Relationship {
237            table: QualifiedIdentifier::new(&self.table_schema, &self.table_name),
238            foreign_table: QualifiedIdentifier::new(
239                &self.foreign_table_schema,
240                &self.foreign_table_name,
241            ),
242            is_self: self.is_self,
243            cardinality,
244            table_is_view: false, // Will be set later
245            foreign_table_is_view: false,
246        }
247    }
248}
249
250/// Convert RoutineRow into Routine
251impl RoutineRow {
252    pub fn into_routine(self) -> Result<Routine, Error> {
253        use super::routine::{PgType, ReturnType, RoutineParam, Volatility};
254        use crate::types::QualifiedIdentifier;
255
256        let params_data: Vec<ParamJson> = parse_params_json(&self.params_json)
257            .map_err(|e| Error::Internal(format!("Failed to parse params JSON: {}", e)))?;
258
259        let return_type_data: ReturnTypeJson = parse_return_type_json(&self.return_type_json)
260            .map_err(|e| Error::Internal(format!("Failed to parse return type JSON: {}", e)))?;
261
262        let params = params_data
263            .into_iter()
264            .map(|p| RoutineParam {
265                name: p.name.into(),
266                pg_type: p.pg_type.into(),
267                type_max_length: p.type_max_length.into(),
268                required: p.required,
269                is_variadic: p.is_variadic,
270            })
271            .collect();
272
273        let pg_type = match return_type_data.type_kind.as_str() {
274            "composite" => PgType::Composite(
275                QualifiedIdentifier::new(
276                    &return_type_data.type_schema,
277                    &return_type_data.type_name,
278                ),
279                return_type_data.is_alias,
280            ),
281            _ => PgType::Scalar(QualifiedIdentifier::new(
282                &return_type_data.type_schema,
283                &return_type_data.type_name,
284            )),
285        };
286
287        let return_type = match return_type_data.kind.as_str() {
288            "setof" => ReturnType::SetOf(pg_type),
289            _ => ReturnType::Single(pg_type),
290        };
291
292        let volatility = Volatility::parse(&self.volatility).unwrap_or(Volatility::Volatile);
293
294        Ok(Routine {
295            schema: self.routine_schema.into(),
296            name: self.routine_name.into(),
297            description: self.description,
298            params,
299            return_type,
300            volatility,
301            is_variadic: self.is_variadic,
302            executable: self.executable,
303        })
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_parse_columns_json() {
313        let json = r#"[
314            {"name": "id", "description": null, "nullable": false, "data_type": "integer", "nominal_type": "integer", "max_length": null, "default": "nextval('seq')", "enum_values": []},
315            {"name": "name", "description": "User name", "nullable": true, "data_type": "text", "nominal_type": "text", "max_length": null, "default": null, "enum_values": []}
316        ]"#;
317
318        let cols = parse_columns_json(json).unwrap();
319        assert_eq!(cols.len(), 2);
320        assert_eq!(cols[0].name, "id");
321        assert!(!cols[0].nullable);
322        assert_eq!(cols[1].name, "name");
323        assert!(cols[1].nullable);
324    }
325
326    #[test]
327    fn test_parse_params_json() {
328        let json = r#"[
329            {"name": "user_id", "pg_type": "integer", "type_max_length": "integer", "required": true, "is_variadic": false},
330            {"name": "limit", "pg_type": "integer", "type_max_length": "integer", "required": false, "is_variadic": false}
331        ]"#;
332
333        let params = parse_params_json(json).unwrap();
334        assert_eq!(params.len(), 2);
335        assert_eq!(params[0].name, "user_id");
336        assert!(params[0].required);
337        assert_eq!(params[1].name, "limit");
338        assert!(!params[1].required);
339    }
340
341    #[test]
342    fn test_parse_return_type_json_scalar() {
343        let json = r#"{"kind": "single", "type_kind": "scalar", "type_schema": "pg_catalog", "type_name": "integer", "is_alias": false}"#;
344
345        let rt = parse_return_type_json(json).unwrap();
346        assert_eq!(rt.kind, "single");
347        assert_eq!(rt.type_kind, "scalar");
348        assert_eq!(rt.type_name, "integer");
349    }
350
351    #[test]
352    fn test_parse_return_type_json_setof_composite() {
353        let json = r#"{"kind": "setof", "type_kind": "composite", "type_schema": "public", "type_name": "users", "is_alias": false}"#;
354
355        let rt = parse_return_type_json(json).unwrap();
356        assert_eq!(rt.kind, "setof");
357        assert_eq!(rt.type_kind, "composite");
358        assert_eq!(rt.type_name, "users");
359    }
360
361    #[test]
362    fn test_table_row_into_table() {
363        let row = TableRow {
364            table_schema: "public".to_string(),
365            table_name: "users".to_string(),
366            table_description: Some("User table".to_string()),
367            is_view: false,
368            insertable: true,
369            updatable: true,
370            deletable: true,
371            readable: true,
372            pk_cols: vec!["id".to_string()],
373            columns_json: r#"[{"name": "id", "description": null, "nullable": false, "data_type": "integer", "nominal_type": "integer", "max_length": null, "default": null, "enum_values": []}]"#.to_string(),
374        };
375
376        let table = row.into_table().unwrap();
377        assert_eq!(table.schema.as_str(), "public");
378        assert_eq!(table.name.as_str(), "users");
379        assert!(table.has_pk());
380        assert_eq!(table.column_count(), 1);
381    }
382
383    #[test]
384    fn test_relationship_row_into_relationship() {
385        let row = RelationshipRow {
386            table_schema: "public".to_string(),
387            table_name: "posts".to_string(),
388            foreign_table_schema: "public".to_string(),
389            foreign_table_name: "users".to_string(),
390            is_self: false,
391            constraint_name: "fk_posts_user".to_string(),
392            cols_and_fcols: vec![("user_id".to_string(), "id".to_string())],
393            one_to_one: false,
394        };
395
396        let rel = row.into_relationship();
397        assert_eq!(rel.table.name.as_str(), "posts");
398        assert_eq!(rel.foreign_table.name.as_str(), "users");
399        assert!(rel.is_to_one()); // M2O is to-one
400        assert_eq!(rel.constraint_name(), "fk_posts_user");
401    }
402
403    #[test]
404    fn test_routine_row_into_routine() {
405        let row = RoutineRow {
406            routine_schema: "api".to_string(),
407            routine_name: "get_user".to_string(),
408            description: Some("Get user by ID".to_string()),
409            params_json: r#"[{"name": "user_id", "pg_type": "integer", "type_max_length": "integer", "required": true, "is_variadic": false}]"#.to_string(),
410            return_type_json: r#"{"kind": "setof", "type_kind": "composite", "type_schema": "public", "type_name": "users", "is_alias": false}"#.to_string(),
411            volatility: "s".to_string(),
412            is_variadic: false,
413            executable: true,
414        };
415
416        let routine = row.into_routine().unwrap();
417        assert_eq!(routine.schema.as_str(), "api");
418        assert_eq!(routine.name.as_str(), "get_user");
419        assert!(routine.returns_set());
420        assert!(routine.returns_composite());
421        assert!(routine.is_stable());
422        assert_eq!(routine.param_count(), 1);
423    }
424}