1use prax_query::introspection::{
7 ColumnInfo, DatabaseSchema, ForeignKeyInfo, IndexColumn, IndexInfo, ReferentialAction,
8 SortOrder, TableInfo, generate_prax_schema, normalize_type, queries,
9};
10#[cfg(any(feature = "postgres", feature = "mysql"))]
16use prax_query::introspection::EnumInfo;
17#[cfg(feature = "mysql")]
18use prax_query::introspection::NormalizedType;
19#[cfg(feature = "postgres")]
20use prax_query::introspection::ViewInfo;
21use prax_query::sql::DatabaseType;
22
23use crate::config::Config;
24use crate::error::{CliError, CliResult};
25
26#[derive(Debug, Clone)]
28pub struct IntrospectionOptions {
29 pub schema: Option<String>,
31 pub include_views: bool,
33 pub include_materialized_views: bool,
35 pub table_filter: Option<String>,
37 pub exclude_pattern: Option<String>,
39 pub include_comments: bool,
41 pub sample_size: usize,
43}
44
45impl Default for IntrospectionOptions {
46 fn default() -> Self {
47 Self {
48 schema: None,
49 include_views: false,
50 include_materialized_views: false,
51 table_filter: None,
52 exclude_pattern: None,
53 include_comments: true,
54 sample_size: 100,
55 }
56 }
57}
58
59#[allow(async_fn_in_trait)]
61pub trait Introspector {
62 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema>;
64}
65
66pub fn get_database_type(provider: &str) -> CliResult<DatabaseType> {
68 match provider.to_lowercase().as_str() {
69 "postgresql" | "postgres" | "pg" => Ok(DatabaseType::PostgreSQL),
70 "mysql" | "mariadb" => Ok(DatabaseType::MySQL),
71 "sqlite" | "sqlite3" => Ok(DatabaseType::SQLite),
72 "mssql" | "sqlserver" | "sql_server" => Ok(DatabaseType::MSSQL),
73 _ => Err(CliError::Config(format!(
74 "Unsupported database provider: {}",
75 provider
76 ))),
77 }
78}
79
80pub fn default_schema(db_type: DatabaseType) -> &'static str {
82 match db_type {
83 DatabaseType::PostgreSQL => "public",
84 DatabaseType::MySQL => "",
85 DatabaseType::SQLite => "",
86 DatabaseType::MSSQL => "dbo",
87 }
88}
89
90pub async fn introspect_database(
97 provider: &str,
98 database_url: &str,
99 options: &IntrospectionOptions,
100) -> CliResult<DatabaseSchema> {
101 let db_type = get_database_type(provider)?;
102 match db_type {
103 DatabaseType::PostgreSQL => {
104 #[cfg(feature = "postgres")]
105 {
106 postgres::PostgresIntrospector::new(database_url.to_string())
107 .introspect(options)
108 .await
109 }
110 #[cfg(not(feature = "postgres"))]
111 {
112 let _ = (database_url, options);
113 Err(CliError::FeatureUnavailable(
114 "PostgreSQL introspection requires the `postgres` feature: rebuild with \
115 --features postgres."
116 .to_string(),
117 ))
118 }
119 }
120 DatabaseType::MySQL => {
121 #[cfg(feature = "mysql")]
122 {
123 mysql::MysqlIntrospector::new(database_url.to_string())
124 .introspect(options)
125 .await
126 }
127 #[cfg(not(feature = "mysql"))]
128 {
129 let _ = (database_url, options);
130 Err(CliError::FeatureUnavailable(
131 "MySQL introspection requires the `mysql` feature: rebuild with \
132 --features mysql."
133 .to_string(),
134 ))
135 }
136 }
137 DatabaseType::SQLite => {
138 #[cfg(feature = "sqlite")]
139 {
140 sqlite::SqliteIntrospector::new(database_url.to_string())
141 .introspect(options)
142 .await
143 }
144 #[cfg(not(feature = "sqlite"))]
145 {
146 let _ = (database_url, options);
147 Err(CliError::FeatureUnavailable(
148 "SQLite introspection requires the `sqlite` feature: rebuild with \
149 --features sqlite."
150 .to_string(),
151 ))
152 }
153 }
154 DatabaseType::MSSQL => {
155 #[cfg(feature = "mssql")]
156 {
157 mssql::MssqlIntrospector::new(database_url.to_string())
158 .introspect(options)
159 .await
160 }
161 #[cfg(not(feature = "mssql"))]
162 {
163 let _ = (database_url, options);
164 Err(CliError::FeatureUnavailable(
165 "MSSQL introspection requires the `mssql` feature: rebuild with \
166 --features mssql."
167 .to_string(),
168 ))
169 }
170 }
171 }
172}
173
174#[cfg(feature = "postgres")]
179pub mod postgres {
180 use std::collections::HashMap;
181
182 use super::*;
183 use tokio_postgres::{Client, NoTls, Row};
184
185 pub struct PostgresIntrospector {
187 connection_string: String,
188 }
189
190 impl PostgresIntrospector {
191 pub fn new(connection_string: String) -> Self {
193 Self { connection_string }
194 }
195
196 async fn connect(&self) -> CliResult<Client> {
198 let config = self
204 .connection_string
205 .parse::<tokio_postgres::Config>()
206 .map_err(|e| CliError::Config(format!("Invalid connection string: {}", e)))?;
207
208 let tls_disabled = matches!(
209 config.get_ssl_mode(),
210 tokio_postgres::config::SslMode::Disable
211 );
212
213 if tls_disabled && !config.get_hosts().iter().all(is_local_host) {
214 crate::output::warn(
215 "sslmode=disable with a non-local host: credentials and data will be \
216 sent in plaintext.",
217 );
218 }
219
220 let connect_timeout =
227 std::time::Duration::from_secs(super::INTROSPECT_CONNECT_TIMEOUT_SECS);
228 let client = if tls_disabled {
229 let (client, connection) = tokio::time::timeout(
230 connect_timeout,
231 tokio_postgres::connect(&self.connection_string, NoTls),
232 )
233 .await
234 .map_err(|_| {
235 CliError::Unreachable("Failed to connect: connection timed out".to_string())
236 })?
237 .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
238 tokio::spawn(async move {
239 if let Err(e) = connection.await {
240 eprintln!("Connection error: {}", e);
241 }
242 });
243 client
244 } else {
245 let (client, connection) = tokio::time::timeout(
246 connect_timeout,
247 tokio_postgres::connect(
248 &self.connection_string,
249 prax_postgres::tls::make_tls_connector(),
250 ),
251 )
252 .await
253 .map_err(|_| {
254 CliError::Unreachable("Failed to connect: connection timed out".to_string())
255 })?
256 .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
257 tokio::spawn(async move {
258 if let Err(e) = connection.await {
259 eprintln!("Connection error: {}", e);
260 }
261 });
262 client
263 };
264
265 Ok(client)
266 }
267 }
268
269 impl Introspector for PostgresIntrospector {
270 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
271 let client = self.connect().await?;
272 let schema_name = options.schema.as_deref().unwrap_or("public");
273
274 let mut db_schema = DatabaseSchema {
275 name: "database".to_string(),
276 schema: Some(schema_name.to_string()),
277 ..Default::default()
278 };
279
280 let tables_sql = queries::tables_query(DatabaseType::PostgreSQL, Some(schema_name));
282 let table_rows = client
283 .query(&tables_sql, &[])
284 .await
285 .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
286
287 for row in table_rows {
288 let table_name: String = row.get(0);
289
290 if let Some(ref pattern) = options.table_filter
292 && !matches_pattern(&table_name, pattern)
293 {
294 continue;
295 }
296 if let Some(ref exclude) = options.exclude_pattern
297 && matches_pattern(&table_name, exclude)
298 {
299 continue;
300 }
301
302 let comment: Option<String> = row.try_get(1).ok();
303
304 db_schema.tables.push(TableInfo {
305 name: table_name,
306 schema: Some(schema_name.to_string()),
307 comment: if options.include_comments {
308 comment
309 } else {
310 None
311 },
312 ..Default::default()
313 });
314 }
315
316 let cols_sql = "SELECT \
323 c.column_name, \
324 c.data_type, \
325 c.udt_name, \
326 c.is_nullable = 'YES' as nullable, \
327 c.column_default, \
328 c.character_maximum_length, \
329 c.numeric_precision, \
330 c.numeric_scale, \
331 col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
332 CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment, \
333 c.table_name \
334 FROM information_schema.columns c \
335 WHERE c.table_schema = $1 \
336 ORDER BY c.table_name, c.ordinal_position";
337 let col_rows = client
338 .query(cols_sql, &[&schema_name])
339 .await
340 .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
341
342 let mut columns_by_table: HashMap<String, Vec<Row>> = HashMap::new();
343 for col_row in col_rows {
344 let table_name: String = col_row.get(10);
345 columns_by_table
346 .entry(table_name)
347 .or_default()
348 .push(col_row);
349 }
350
351 let pk_sql = "SELECT a.attname as column_name, c.relname as table_name \
352 FROM pg_index i \
353 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
354 JOIN pg_class c ON c.oid = i.indrelid \
355 JOIN pg_namespace n ON n.oid = c.relnamespace \
356 WHERE i.indisprimary AND n.nspname = $1 \
357 ORDER BY c.relname, array_position(i.indkey, a.attnum)";
358 let pk_rows = client
359 .query(pk_sql, &[&schema_name])
360 .await
361 .map_err(|e| CliError::Database(format!("Failed to query primary keys: {}", e)))?;
362
363 let mut pks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
364 for pk_row in pk_rows {
365 let table_name: String = pk_row.get(1);
366 pks_by_table.entry(table_name).or_default().push(pk_row);
367 }
368
369 let fk_sql = "SELECT \
370 tc.constraint_name, \
371 kcu.column_name, \
372 ccu.table_name as referenced_table, \
373 ccu.table_schema as referenced_schema, \
374 ccu.column_name as referenced_column, \
375 rc.delete_rule, \
376 rc.update_rule, \
377 tc.table_name \
378 FROM information_schema.table_constraints tc \
379 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
380 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
381 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
382 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1 \
383 ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position";
384 let fk_rows = client
385 .query(fk_sql, &[&schema_name])
386 .await
387 .map_err(|e| CliError::Database(format!("Failed to query foreign keys: {}", e)))?;
388
389 let mut fks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
390 for fk_row in fk_rows {
391 let table_name: String = fk_row.get(7);
392 fks_by_table.entry(table_name).or_default().push(fk_row);
393 }
394
395 let idx_sql = "SELECT \
396 i.relname as index_name, \
397 a.attname as column_name, \
398 ix.indisunique as is_unique, \
399 ix.indisprimary as is_primary, \
400 am.amname as index_type, \
401 pg_get_expr(ix.indpred, ix.indrelid) as filter, \
402 t.relname as table_name \
403 FROM pg_index ix \
404 JOIN pg_class t ON t.oid = ix.indrelid \
405 JOIN pg_class i ON i.oid = ix.indexrelid \
406 JOIN pg_namespace n ON n.oid = t.relnamespace \
407 JOIN pg_am am ON i.relam = am.oid \
408 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
409 WHERE n.nspname = $1 \
410 ORDER BY t.relname, i.relname, array_position(ix.indkey, a.attnum)";
411 let idx_rows = client
412 .query(idx_sql, &[&schema_name])
413 .await
414 .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
415
416 let mut indexes_by_table: HashMap<String, Vec<Row>> = HashMap::new();
417 for idx_row in idx_rows {
418 let table_name: String = idx_row.get(6);
419 indexes_by_table
420 .entry(table_name)
421 .or_default()
422 .push(idx_row);
423 }
424
425 for table in &mut db_schema.tables {
427 for col_row in columns_by_table.remove(&table.name).unwrap_or_default() {
428 let col_name: String = col_row.get(0);
429 let data_type: String = col_row.get(1);
430 let udt_name: String = col_row.get(2);
431 let nullable: bool = col_row.get(3);
432 let default: Option<String> = col_row.try_get(4).ok();
433 let max_length: Option<i32> = col_row.try_get(5).ok();
434 let precision: Option<i32> = col_row.try_get(6).ok();
435 let scale: Option<i32> = col_row.try_get(7).ok();
436 let comment: Option<String> = col_row.try_get(8).ok();
437 let auto_increment: bool = col_row.try_get(9).unwrap_or(false);
438
439 let normalized = normalize_type(
440 DatabaseType::PostgreSQL,
441 &udt_name,
442 max_length,
443 precision,
444 scale,
445 );
446
447 table.columns.push(ColumnInfo {
448 name: col_name,
449 db_type: data_type,
450 normalized_type: normalized,
451 nullable,
452 default,
453 auto_increment,
454 max_length,
455 precision,
456 scale,
457 comment: if options.include_comments {
458 comment
459 } else {
460 None
461 },
462 ..Default::default()
463 });
464 }
465
466 for pk_row in pks_by_table.remove(&table.name).unwrap_or_default() {
467 let col_name: String = pk_row.get(0);
468 table.primary_key.push(col_name.clone());
469
470 if let Some(col) = table.columns.iter_mut().find(|c| c.name == col_name) {
472 col.is_primary_key = true;
473 }
474 }
475
476 let mut fk_map: HashMap<String, ForeignKeyInfo> = HashMap::new();
477 for fk_row in fks_by_table.remove(&table.name).unwrap_or_default() {
478 let constraint_name: String = fk_row.get(0);
479 let column_name: String = fk_row.get(1);
480 let ref_table: String = fk_row.get(2);
481 let ref_schema: Option<String> = fk_row.try_get(3).ok();
482 let ref_column: String = fk_row.get(4);
483 let delete_rule: String = fk_row.get(5);
484 let update_rule: String = fk_row.get(6);
485
486 let fk =
487 fk_map
488 .entry(constraint_name.clone())
489 .or_insert_with(|| ForeignKeyInfo {
490 name: constraint_name,
491 columns: Vec::new(),
492 referenced_table: ref_table,
493 referenced_schema: ref_schema,
494 referenced_columns: Vec::new(),
495 on_delete: ReferentialAction::from_str(&delete_rule),
496 on_update: ReferentialAction::from_str(&update_rule),
497 });
498
499 fk.columns.push(column_name);
500 fk.referenced_columns.push(ref_column);
501 }
502
503 table.foreign_keys = fk_map.into_values().collect();
504
505 let mut idx_map: HashMap<String, IndexInfo> = HashMap::new();
506 for idx_row in indexes_by_table.remove(&table.name).unwrap_or_default() {
507 let idx_name: String = idx_row.get(0);
508 let col_name: String = idx_row.get(1);
509 let is_unique: bool = idx_row.get(2);
510 let is_primary: bool = idx_row.get(3);
511 let idx_type: Option<String> = idx_row.try_get(4).ok();
512 let filter: Option<String> = idx_row.try_get(5).ok();
513
514 let idx = idx_map
515 .entry(idx_name.clone())
516 .or_insert_with(|| IndexInfo {
517 name: idx_name,
518 columns: Vec::new(),
519 is_unique,
520 is_primary,
521 index_type: idx_type,
522 filter,
523 });
524
525 idx.columns.push(IndexColumn {
526 name: col_name,
527 order: SortOrder::Asc,
528 ..Default::default()
529 });
530 }
531
532 table.indexes = idx_map.into_values().collect();
533 }
534
535 let enums_sql = queries::enums_query(Some(schema_name));
537 let enum_rows = client
538 .query(&enums_sql, &[])
539 .await
540 .map_err(|e| CliError::Database(format!("Failed to query enums: {}", e)))?;
541
542 let mut enum_map: HashMap<String, EnumInfo> = HashMap::new();
543 for enum_row in enum_rows {
544 let enum_name: String = enum_row.get(0);
545 let enum_value: String = enum_row.get(1);
546
547 let enum_info = enum_map
548 .entry(enum_name.clone())
549 .or_insert_with(|| EnumInfo {
550 name: enum_name,
551 schema: Some(schema_name.to_string()),
552 values: Vec::new(),
553 });
554
555 enum_info.values.push(enum_value);
556 }
557
558 db_schema.enums = enum_map.into_values().collect();
559
560 if options.include_views || options.include_materialized_views {
562 let views_sql = queries::views_query(DatabaseType::PostgreSQL, Some(schema_name));
563 let view_rows = client
564 .query(&views_sql, &[])
565 .await
566 .map_err(|e| CliError::Database(format!("Failed to query views: {}", e)))?;
567
568 for view_row in view_rows {
569 let view_name: String = view_row.get(0);
570 let definition: Option<String> = view_row.try_get(1).ok();
571 let is_materialized: bool = view_row.get(2);
572
573 if is_materialized && !options.include_materialized_views {
574 continue;
575 }
576 if !is_materialized && !options.include_views {
577 continue;
578 }
579
580 db_schema.views.push(ViewInfo {
581 name: view_name,
582 schema: Some(schema_name.to_string()),
583 definition,
584 is_materialized,
585 columns: Vec::new(),
586 });
587 }
588 }
589
590 Ok(db_schema)
591 }
592 }
593
594 fn is_local_host(host: &tokio_postgres::config::Host) -> bool {
596 match host {
597 tokio_postgres::config::Host::Tcp(name) => {
598 name == "localhost" || name == "127.0.0.1" || name == "::1"
599 }
600 tokio_postgres::config::Host::Unix(_) => true,
601 }
602 }
603}
604
605#[cfg(any(
614 feature = "postgres",
615 feature = "mysql",
616 feature = "sqlite",
617 feature = "mssql"
618))]
619pub(crate) const INTROSPECT_CONNECT_TIMEOUT_SECS: u64 = 5;
620
621#[cfg(any(feature = "mysql", feature = "sqlite"))]
626trait JsonRowSource {
627 async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>>;
629}
630
631#[cfg(feature = "mysql")]
632impl JsonRowSource for prax_mysql::MysqlRawEngine {
633 async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>> {
634 let rows = self
635 .raw_sql_query(sql, &[])
636 .await
637 .map_err(|e| CliError::Database(format!("Introspection query failed: {}", e)))?;
638 Ok(rows.into_iter().map(|r| r.into_json()).collect())
639 }
640}
641
642#[cfg(feature = "sqlite")]
643impl JsonRowSource for prax_sqlite::SqliteRawEngine {
644 async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>> {
645 let rows = self
646 .raw_sql_query(sql, &[])
647 .await
648 .map_err(|e| CliError::Database(format!("Introspection query failed: {}", e)))?;
649 Ok(rows.into_iter().map(|r| r.into_json()).collect())
650 }
651}
652
653#[cfg(any(
661 feature = "postgres",
662 feature = "mysql",
663 feature = "sqlite",
664 feature = "mssql"
665))]
666fn matches_pattern(name: &str, pattern: &str) -> bool {
667 if pattern == "*" {
668 return true;
669 }
670
671 if pattern.starts_with('*') && pattern.ends_with('*') {
672 let middle = &pattern[1..pattern.len() - 1];
673 return name.contains(middle);
674 }
675
676 if let Some(suffix) = pattern.strip_prefix('*') {
677 return name.ends_with(suffix);
678 }
679
680 if let Some(prefix) = pattern.strip_suffix('*') {
681 return name.starts_with(prefix);
682 }
683
684 name == pattern
685}
686
687#[cfg(any(feature = "mysql", feature = "sqlite"))]
694fn json_get<'a>(row: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
695 if let Some(v) = row.get(key) {
696 return Some(v);
697 }
698 row.as_object()?
699 .iter()
700 .find(|(k, _)| k.eq_ignore_ascii_case(key))
701 .map(|(_, v)| v)
702}
703
704#[cfg(any(feature = "mysql", feature = "sqlite"))]
706fn json_str(row: &serde_json::Value, key: &str) -> Option<String> {
707 json_get(row, key)
708 .and_then(|v| v.as_str())
709 .map(str::to_string)
710}
711
712#[cfg(feature = "mysql")]
716fn json_bool(row: &serde_json::Value, key: &str) -> bool {
717 match json_get(row, key) {
718 Some(serde_json::Value::Bool(b)) => *b,
719 Some(serde_json::Value::Number(n)) => n.as_i64().is_some_and(|i| i != 0),
720 Some(serde_json::Value::String(s)) => {
721 matches!(s.as_str(), "1" | "YES" | "yes" | "true" | "TRUE")
722 }
723 _ => false,
724 }
725}
726
727#[cfg(any(feature = "mysql", feature = "sqlite"))]
729fn json_i32(row: &serde_json::Value, key: &str) -> Option<i32> {
730 match json_get(row, key) {
731 Some(serde_json::Value::Number(n)) => n.as_i64().and_then(|i| i32::try_from(i).ok()),
732 Some(serde_json::Value::String(s)) => s.parse::<i32>().ok(),
733 _ => None,
734 }
735}
736
737#[cfg(feature = "mysql")]
742pub mod mysql {
743 use std::collections::HashSet;
744
745 use super::*;
746 use prax_mysql::{MysqlPool, MysqlRawEngine};
747
748 pub struct MysqlIntrospector {
750 connection_string: String,
751 }
752
753 impl MysqlIntrospector {
754 pub fn new(connection_string: String) -> Self {
756 Self { connection_string }
757 }
758
759 async fn engine(&self) -> CliResult<MysqlRawEngine> {
760 let pool = MysqlPool::builder()
761 .url(self.connection_string.clone())
762 .connection_timeout(std::time::Duration::from_secs(
763 super::INTROSPECT_CONNECT_TIMEOUT_SECS,
764 ))
765 .build()
766 .await
767 .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
768 Ok(MysqlRawEngine::new(pool))
769 }
770
771 async fn rows(engine: &MysqlRawEngine, sql: &str) -> CliResult<Vec<serde_json::Value>> {
774 super::JsonRowSource::json_rows(engine, sql).await
775 }
776 }
777
778 impl Introspector for MysqlIntrospector {
779 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
780 let engine = self.engine().await?;
781 let schema = options.schema.clone();
784 let schema_ref = schema.as_deref();
785
786 let mut db_schema = DatabaseSchema {
787 name: "database".to_string(),
788 schema: schema.clone(),
789 ..Default::default()
790 };
791
792 let table_rows = Self::rows(
793 &engine,
794 &queries::tables_query(DatabaseType::MySQL, schema_ref),
795 )
796 .await?;
797 for row in &table_rows {
798 let Some(table_name) = json_str(row, "table_name") else {
799 continue;
800 };
801 if let Some(ref pattern) = options.table_filter
802 && !matches_pattern(&table_name, pattern)
803 {
804 continue;
805 }
806 if let Some(ref exclude) = options.exclude_pattern
807 && matches_pattern(&table_name, exclude)
808 {
809 continue;
810 }
811
812 db_schema.tables.push(TableInfo {
813 name: table_name,
814 schema: schema.clone(),
815 comment: if options.include_comments {
816 json_str(row, "comment").filter(|c| !c.is_empty())
817 } else {
818 None
819 },
820 ..Default::default()
821 });
822 }
823
824 let mut used_enum_names: HashSet<String> = HashSet::new();
832 for table in &mut db_schema.tables {
833 let table_enums =
834 populate_table(&engine, table, schema_ref, options, &mut used_enum_names)
835 .await?;
836 db_schema.enums.extend(table_enums);
837 }
838
839 Ok(db_schema)
840 }
841 }
842
843 fn reserve_unique_enum_name(
854 base_name: String,
855 used_pascal_names: &mut HashSet<String>,
856 ) -> String {
857 use prax_query::introspection::{disambiguate, pascal_case};
858 disambiguate(&base_name, pascal_case, used_pascal_names)
859 }
860
861 fn synthesize_enum_name(
868 table_name: &str,
869 column_name: &str,
870 used_enum_names: &mut HashSet<String>,
871 ) -> String {
872 use prax_query::introspection::sanitize_identifier;
873 reserve_unique_enum_name(
874 format!(
875 "{}_{}",
876 sanitize_identifier(table_name),
877 sanitize_identifier(column_name)
878 ),
879 used_enum_names,
880 )
881 }
882
883 async fn populate_table(
889 engine: &MysqlRawEngine,
890 table: &mut TableInfo,
891 schema: Option<&str>,
892 options: &IntrospectionOptions,
893 used_enum_names: &mut HashSet<String>,
894 ) -> CliResult<Vec<EnumInfo>> {
895 let col_rows = MysqlIntrospector::rows(
897 engine,
898 &queries::columns_query(DatabaseType::MySQL, &table.name, schema),
899 )
900 .await?;
901 let mut enums = Vec::new();
902 for row in &col_rows {
903 let Some(name) = json_str(row, "column_name") else {
904 continue;
905 };
906 let data_type = json_str(row, "data_type").unwrap_or_default();
907 let max_length = json_i32(row, "character_maximum_length");
908 let precision = json_i32(row, "numeric_precision");
909 let scale = json_i32(row, "numeric_scale");
910 let normalized = if data_type.eq_ignore_ascii_case("enum") {
911 let column_type = json_str(row, "udt_name").unwrap_or_default();
915 let values = prax_query::introspection::parse_mysql_enum_values(&column_type);
916 if values.is_empty() {
917 NormalizedType::Unknown(column_type)
925 } else {
926 let enum_name = synthesize_enum_name(&table.name, &name, used_enum_names);
927 enums.push(EnumInfo {
928 name: enum_name.clone(),
929 schema: schema.map(str::to_string),
930 values,
931 });
932 NormalizedType::Enum(enum_name)
933 }
934 } else {
935 normalize_type(
936 DatabaseType::MySQL,
937 &data_type,
938 max_length,
939 precision,
940 scale,
941 )
942 };
943
944 table.columns.push(ColumnInfo {
945 name,
946 db_type: data_type,
947 normalized_type: normalized,
948 nullable: json_bool(row, "nullable"),
949 default: json_str(row, "column_default"),
950 auto_increment: json_bool(row, "auto_increment"),
951 max_length,
952 precision,
953 scale,
954 comment: if options.include_comments {
955 json_str(row, "comment").filter(|c| !c.is_empty())
956 } else {
957 None
958 },
959 ..Default::default()
960 });
961 }
962
963 let pk_rows = MysqlIntrospector::rows(
965 engine,
966 &queries::primary_keys_query(DatabaseType::MySQL, &table.name, schema),
967 )
968 .await?;
969 for row in &pk_rows {
970 if let Some(col) = json_str(row, "column_name") {
971 table.primary_key.push(col.clone());
972 if let Some(c) = table.columns.iter_mut().find(|c| c.name == col) {
973 c.is_primary_key = true;
974 }
975 }
976 }
977
978 let fk_rows = MysqlIntrospector::rows(
980 engine,
981 &queries::foreign_keys_query(DatabaseType::MySQL, &table.name, schema),
982 )
983 .await?;
984 let mut fk_map: std::collections::HashMap<String, ForeignKeyInfo> =
985 std::collections::HashMap::new();
986 let mut fk_order: Vec<String> = Vec::new();
987 for row in &fk_rows {
988 let Some(cname) = json_str(row, "constraint_name") else {
989 continue;
990 };
991 let fk = fk_map.entry(cname.clone()).or_insert_with(|| {
992 fk_order.push(cname.clone());
993 ForeignKeyInfo {
994 name: cname.clone(),
995 columns: Vec::new(),
996 referenced_table: json_str(row, "referenced_table").unwrap_or_default(),
997 referenced_schema: json_str(row, "referenced_schema"),
998 referenced_columns: Vec::new(),
999 on_delete: ReferentialAction::from_str(
1000 &json_str(row, "delete_rule").unwrap_or_default(),
1001 ),
1002 on_update: ReferentialAction::from_str(
1003 &json_str(row, "update_rule").unwrap_or_default(),
1004 ),
1005 }
1006 });
1007 if let Some(col) = json_str(row, "column_name") {
1008 fk.columns.push(col);
1009 }
1010 if let Some(rc) = json_str(row, "referenced_column") {
1011 fk.referenced_columns.push(rc);
1012 }
1013 }
1014 table.foreign_keys = fk_order
1015 .into_iter()
1016 .filter_map(|n| fk_map.remove(&n))
1017 .collect();
1018
1019 let idx_rows = MysqlIntrospector::rows(
1021 engine,
1022 &queries::indexes_query(DatabaseType::MySQL, &table.name, schema),
1023 )
1024 .await?;
1025 let mut idx_map: std::collections::HashMap<String, IndexInfo> =
1026 std::collections::HashMap::new();
1027 let mut idx_order: Vec<String> = Vec::new();
1028 for row in &idx_rows {
1029 let Some(iname) = json_str(row, "index_name") else {
1030 continue;
1031 };
1032 let idx = idx_map.entry(iname.clone()).or_insert_with(|| {
1033 idx_order.push(iname.clone());
1034 IndexInfo {
1035 name: iname.clone(),
1036 columns: Vec::new(),
1037 is_unique: json_bool(row, "is_unique"),
1038 is_primary: json_bool(row, "is_primary"),
1039 index_type: json_str(row, "index_type"),
1040 filter: json_str(row, "filter"),
1041 }
1042 });
1043 if let Some(col) = json_str(row, "column_name") {
1044 idx.columns.push(IndexColumn {
1045 name: col,
1046 order: SortOrder::Asc,
1047 ..Default::default()
1048 });
1049 }
1050 }
1051 table.indexes = idx_order
1071 .into_iter()
1072 .filter_map(|n| idx_map.remove(&n))
1073 .filter(|idx| !is_fk_backing_index(idx, &table.foreign_keys))
1074 .collect();
1075
1076 Ok(enums)
1077 }
1078
1079 fn is_fk_backing_index(idx: &IndexInfo, foreign_keys: &[ForeignKeyInfo]) -> bool {
1099 if idx.is_unique || idx.is_primary {
1100 return false;
1101 }
1102 foreign_keys.iter().any(|fk| {
1103 fk.name == idx.name
1104 && fk
1105 .columns
1106 .iter()
1107 .map(String::as_str)
1108 .eq(idx.columns.iter().map(|c| c.name.as_str()))
1109 })
1110 }
1111
1112 #[cfg(test)]
1113 mod tests {
1114 use super::*;
1115
1116 fn fk(name: &str, columns: &[&str]) -> ForeignKeyInfo {
1117 ForeignKeyInfo {
1118 name: name.to_string(),
1119 columns: columns.iter().map(|c| c.to_string()).collect(),
1120 ..Default::default()
1121 }
1122 }
1123
1124 fn index(name: &str, columns: &[&str], is_unique: bool, is_primary: bool) -> IndexInfo {
1125 IndexInfo {
1126 name: name.to_string(),
1127 columns: columns
1128 .iter()
1129 .map(|c| IndexColumn {
1130 name: c.to_string(),
1131 ..Default::default()
1132 })
1133 .collect(),
1134 is_unique,
1135 is_primary,
1136 ..Default::default()
1137 }
1138 }
1139
1140 #[test]
1141 fn drops_non_unique_index_matching_fk_columns() {
1142 let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1145 assert!(is_fk_backing_index(
1146 &index("fk_posts_author_id", &["author_id"], false, false),
1147 &fks
1148 ));
1149 }
1150
1151 #[test]
1152 fn keeps_unique_index_even_if_it_matches_fk_columns() {
1153 let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1154 assert!(!is_fk_backing_index(
1155 &index("fk_posts_author_id", &["author_id"], true, false),
1156 &fks
1157 ));
1158 }
1159
1160 #[test]
1161 fn keeps_index_whose_columns_dont_match_any_fk() {
1162 let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1163 assert!(!is_fk_backing_index(
1164 &index("title_idx", &["title"], false, false),
1165 &fks
1166 ));
1167 }
1168
1169 #[test]
1170 fn keeps_user_declared_index_covering_fk_columns() {
1171 let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1176 assert!(!is_fk_backing_index(
1177 &index("posts_author_id_idx", &["author_id"], false, false),
1178 &fks
1179 ));
1180 }
1181
1182 #[test]
1183 fn drops_non_unique_index_matching_composite_fk_columns_in_order() {
1184 let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1185 assert!(is_fk_backing_index(
1186 &index("fk_membership_ids", &["tenant_id", "user_id"], false, false),
1187 &fks
1188 ));
1189 }
1190
1191 #[test]
1192 fn keeps_index_whose_composite_columns_match_a_different_order() {
1193 let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1194 assert!(!is_fk_backing_index(
1195 &index("fk_membership_ids", &["user_id", "tenant_id"], false, false),
1196 &fks
1197 ));
1198 }
1199
1200 #[test]
1201 fn keeps_composite_user_index_covering_fk_columns() {
1202 let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1203 assert!(!is_fk_backing_index(
1204 &index(
1205 "membership_tenant_user_idx",
1206 &["tenant_id", "user_id"],
1207 false,
1208 false
1209 ),
1210 &fks
1211 ));
1212 }
1213
1214 #[test]
1215 fn reserve_unique_enum_name_disambiguates_on_collision() {
1216 let mut used = HashSet::new();
1217 assert_eq!(
1218 reserve_unique_enum_name("order_item_status".to_string(), &mut used),
1219 "order_item_status"
1220 );
1221 assert_eq!(
1222 reserve_unique_enum_name("order_item_status".to_string(), &mut used),
1223 "order_item_status_2"
1224 );
1225 assert_eq!(
1226 reserve_unique_enum_name("order_status".to_string(), &mut used),
1227 "order_status"
1228 );
1229 }
1230
1231 #[test]
1232 fn reserve_unique_enum_name_catches_collision_that_only_appears_after_pascal_case() {
1233 let mut used = HashSet::new();
1237 assert_eq!(
1238 reserve_unique_enum_name("FooBar".to_string(), &mut used),
1239 "FooBar"
1240 );
1241 assert_eq!(
1242 reserve_unique_enum_name("foo_bar".to_string(), &mut used),
1243 "foo_bar_2"
1244 );
1245 }
1246
1247 #[test]
1248 fn reserve_unique_enum_name_catches_collision_within_the_same_table() {
1249 let mut used = HashSet::new();
1254 assert_eq!(
1255 reserve_unique_enum_name("a_b".to_string(), &mut used),
1256 "a_b"
1257 );
1258 assert_eq!(
1259 reserve_unique_enum_name("a__b".to_string(), &mut used),
1260 "a__b_2"
1261 );
1262 }
1263
1264 #[test]
1265 fn synthesize_enum_name_sanitizes_illegal_characters() {
1266 let mut used = HashSet::new();
1267 assert_eq!(
1268 synthesize_enum_name("1099-forms", "my col", &mut used),
1269 "V1099_forms_my_col"
1270 );
1271 }
1272 }
1273}
1274
1275#[cfg(feature = "sqlite")]
1280pub mod sqlite {
1281 use super::*;
1282 use prax_sqlite::{SqlitePool, SqliteRawEngine};
1283
1284 pub struct SqliteIntrospector {
1290 connection_string: String,
1291 }
1292
1293 impl SqliteIntrospector {
1294 pub fn new(connection_string: String) -> Self {
1296 Self { connection_string }
1297 }
1298
1299 async fn engine(&self) -> CliResult<SqliteRawEngine> {
1300 let pool = SqlitePool::builder()
1301 .url(self.connection_string.clone())
1302 .connection_timeout(std::time::Duration::from_secs(
1303 super::INTROSPECT_CONNECT_TIMEOUT_SECS,
1304 ))
1305 .build()
1306 .await
1307 .map_err(|e| CliError::Unreachable(format!("Failed to open database: {}", e)))?;
1308 Ok(SqliteRawEngine::new(pool))
1309 }
1310
1311 async fn rows(engine: &SqliteRawEngine, sql: &str) -> CliResult<Vec<serde_json::Value>> {
1312 super::JsonRowSource::json_rows(engine, sql).await
1313 }
1314 }
1315
1316 impl Introspector for SqliteIntrospector {
1317 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
1318 let engine = self.engine().await?;
1319
1320 let mut db_schema = DatabaseSchema {
1321 name: "database".to_string(),
1322 schema: None,
1323 ..Default::default()
1324 };
1325
1326 let table_rows =
1328 Self::rows(&engine, &queries::tables_query(DatabaseType::SQLite, None)).await?;
1329 for row in &table_rows {
1330 let Some(table_name) = json_str(row, "table_name") else {
1331 continue;
1332 };
1333 if let Some(ref pattern) = options.table_filter
1334 && !matches_pattern(&table_name, pattern)
1335 {
1336 continue;
1337 }
1338 if let Some(ref exclude) = options.exclude_pattern
1339 && matches_pattern(&table_name, exclude)
1340 {
1341 continue;
1342 }
1343 db_schema.tables.push(TableInfo {
1344 name: table_name,
1345 ..Default::default()
1346 });
1347 }
1348
1349 for table in &mut db_schema.tables {
1350 populate_table(&engine, table).await?;
1351 }
1352
1353 Ok(db_schema)
1354 }
1355 }
1356
1357 async fn populate_table(engine: &SqliteRawEngine, table: &mut TableInfo) -> CliResult<()> {
1358 let col_rows = SqliteIntrospector::rows(
1361 engine,
1362 &queries::columns_query(DatabaseType::SQLite, &table.name, None),
1363 )
1364 .await?;
1365 let mut pk_positions: Vec<(i32, String)> = Vec::new();
1366 for row in &col_rows {
1367 let Some(name) = json_str(row, "name") else {
1368 continue;
1369 };
1370 let decl_type = json_str(row, "type").unwrap_or_default();
1371 let base_type: String = decl_type
1374 .split([' ', '('])
1375 .next()
1376 .unwrap_or("")
1377 .to_ascii_lowercase();
1378 let normalized = normalize_type(DatabaseType::SQLite, &base_type, None, None, None);
1379 let not_null = json_i32(row, "notnull").unwrap_or(0) != 0;
1380 let pk_pos = json_i32(row, "pk").unwrap_or(0);
1381 if pk_pos > 0 {
1382 pk_positions.push((pk_pos, name.clone()));
1383 }
1384
1385 table.columns.push(ColumnInfo {
1386 name,
1387 db_type: decl_type,
1388 normalized_type: normalized,
1389 nullable: !not_null,
1394 default: json_str(row, "dflt_value"),
1395 auto_increment: false,
1398 is_primary_key: pk_pos > 0,
1399 ..Default::default()
1400 });
1401 }
1402
1403 pk_positions.sort_by_key(|(pos, _)| *pos);
1405 table.primary_key = pk_positions.into_iter().map(|(_, name)| name).collect();
1406
1407 if table.primary_key.len() == 1
1412 && let Some(col) = table
1413 .columns
1414 .iter_mut()
1415 .find(|c| c.name == table.primary_key[0])
1416 && col.db_type.to_ascii_lowercase().contains("int")
1417 {
1418 col.auto_increment = true;
1419 col.nullable = false;
1420 }
1421
1422 let fk_rows = SqliteIntrospector::rows(
1425 engine,
1426 &queries::foreign_keys_query(DatabaseType::SQLite, &table.name, None),
1427 )
1428 .await?;
1429 let mut fk_map: std::collections::BTreeMap<i64, ForeignKeyInfo> =
1430 std::collections::BTreeMap::new();
1431 for row in &fk_rows {
1432 let id = row.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
1433 let fk = fk_map.entry(id).or_insert_with(|| ForeignKeyInfo {
1434 name: String::new(),
1438 columns: Vec::new(),
1439 referenced_table: json_str(row, "table").unwrap_or_default(),
1440 referenced_schema: None,
1441 referenced_columns: Vec::new(),
1442 on_delete: ReferentialAction::from_str(
1443 &json_str(row, "on_delete").unwrap_or_default(),
1444 ),
1445 on_update: ReferentialAction::from_str(
1446 &json_str(row, "on_update").unwrap_or_default(),
1447 ),
1448 });
1449 if let Some(from) = json_str(row, "from") {
1450 fk.columns.push(from);
1451 }
1452 if let Some(to) = json_str(row, "to") {
1453 fk.referenced_columns.push(to);
1454 }
1455 }
1456 table.foreign_keys = fk_map
1457 .into_values()
1458 .map(|mut fk| {
1459 if fk.name.is_empty() {
1460 fk.name = format!("fk_{}_{}", table.name, fk.columns.join("_"));
1461 }
1462 fk
1463 })
1464 .collect();
1465
1466 let idx_rows = SqliteIntrospector::rows(
1469 engine,
1470 &queries::indexes_query(DatabaseType::SQLite, &table.name, None),
1471 )
1472 .await?;
1473 for row in &idx_rows {
1474 let Some(idx_name) = json_str(row, "name") else {
1475 continue;
1476 };
1477 let origin = json_str(row, "origin").unwrap_or_default();
1478 let is_primary = origin == "pk";
1479 let is_unique = json_i32(row, "unique").unwrap_or(0) != 0;
1480
1481 let info_rows = SqliteIntrospector::rows(
1483 engine,
1484 &format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")),
1485 )
1486 .await?;
1487 let columns: Vec<IndexColumn> = info_rows
1488 .iter()
1489 .filter_map(|r| json_str(r, "name"))
1490 .map(|name| IndexColumn {
1491 name,
1492 order: SortOrder::Asc,
1493 ..Default::default()
1494 })
1495 .collect();
1496
1497 table.indexes.push(IndexInfo {
1498 name: idx_name,
1499 columns,
1500 is_unique,
1501 is_primary,
1502 index_type: None,
1503 filter: None,
1504 });
1505 }
1506
1507 Ok(())
1508 }
1509}
1510
1511#[cfg(feature = "mssql")]
1516pub mod mssql {
1517 use super::*;
1518 use prax_mssql::MssqlPool;
1519 use prax_mssql::Row;
1520
1521 pub struct MssqlIntrospector {
1525 connection_string: String,
1526 }
1527
1528 impl MssqlIntrospector {
1529 pub fn new(connection_string: String) -> Self {
1531 Self { connection_string }
1532 }
1533 }
1534
1535 fn row_str(row: &Row, col: &str) -> Option<String> {
1537 row.try_get::<&str, _>(col)
1538 .ok()
1539 .flatten()
1540 .map(str::to_string)
1541 }
1542
1543 fn row_bool(row: &Row, col: &str) -> bool {
1545 if let Ok(Some(b)) = row.try_get::<bool, _>(col) {
1546 return b;
1547 }
1548 row_i64(row, col).is_some_and(|i| i != 0)
1550 }
1551
1552 fn row_i64(row: &Row, col: &str) -> Option<i64> {
1554 if let Ok(Some(v)) = row.try_get::<i32, _>(col) {
1555 return Some(v as i64);
1556 }
1557 if let Ok(Some(v)) = row.try_get::<i64, _>(col) {
1558 return Some(v);
1559 }
1560 if let Ok(Some(v)) = row.try_get::<i16, _>(col) {
1561 return Some(v as i64);
1562 }
1563 if let Ok(Some(v)) = row.try_get::<u8, _>(col) {
1564 return Some(v as i64);
1565 }
1566 None
1567 }
1568
1569 fn row_i32(row: &Row, col: &str) -> Option<i32> {
1570 row_i64(row, col).and_then(|v| i32::try_from(v).ok())
1571 }
1572
1573 impl Introspector for MssqlIntrospector {
1574 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
1575 let pool = MssqlPool::builder()
1576 .connection_string(self.connection_string.clone())
1577 .connection_timeout(std::time::Duration::from_secs(
1578 super::INTROSPECT_CONNECT_TIMEOUT_SECS,
1579 ))
1580 .build()
1581 .await
1582 .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
1583 let mut conn = pool.get().await.map_err(|e| {
1584 CliError::Unreachable(format!("Failed to acquire connection: {}", e))
1585 })?;
1586
1587 let schema_name = options.schema.clone().unwrap_or_else(|| "dbo".to_string());
1588 let schema_ref = Some(schema_name.as_str());
1589
1590 let mut db_schema = DatabaseSchema {
1591 name: "database".to_string(),
1592 schema: Some(schema_name.clone()),
1593 ..Default::default()
1594 };
1595
1596 let table_rows = conn
1597 .query(&queries::tables_query(DatabaseType::MSSQL, schema_ref), &[])
1598 .await
1599 .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
1600 for row in &table_rows {
1601 let Some(table_name) = row_str(row, "table_name") else {
1602 continue;
1603 };
1604 if let Some(ref pattern) = options.table_filter
1605 && !matches_pattern(&table_name, pattern)
1606 {
1607 continue;
1608 }
1609 if let Some(ref exclude) = options.exclude_pattern
1610 && matches_pattern(&table_name, exclude)
1611 {
1612 continue;
1613 }
1614 db_schema.tables.push(TableInfo {
1615 name: table_name,
1616 schema: Some(schema_name.clone()),
1617 comment: if options.include_comments {
1618 row_str(row, "comment")
1619 } else {
1620 None
1621 },
1622 ..Default::default()
1623 });
1624 }
1625
1626 for i in 0..db_schema.tables.len() {
1627 let table_name = db_schema.tables[i].name.clone();
1628
1629 let col_rows = conn
1631 .query(
1632 &queries::columns_query(DatabaseType::MSSQL, &table_name, schema_ref),
1633 &[],
1634 )
1635 .await
1636 .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
1637 for row in &col_rows {
1638 let Some(name) = row_str(row, "column_name") else {
1639 continue;
1640 };
1641 let data_type = row_str(row, "data_type").unwrap_or_default();
1642 let max_length = row_i32(row, "character_maximum_length");
1649 let precision = row_i32(row, "numeric_precision");
1650 let scale = row_i32(row, "numeric_scale");
1651 let normalized = normalize_type(
1652 DatabaseType::MSSQL,
1653 &data_type,
1654 max_length,
1655 precision,
1656 scale,
1657 );
1658 db_schema.tables[i].columns.push(ColumnInfo {
1659 name,
1660 db_type: data_type,
1661 normalized_type: normalized,
1662 nullable: row_bool(row, "nullable"),
1663 default: row_str(row, "column_default"),
1664 auto_increment: row_bool(row, "auto_increment"),
1665 max_length,
1666 precision,
1667 scale,
1668 comment: if options.include_comments {
1669 row_str(row, "comment")
1670 } else {
1671 None
1672 },
1673 ..Default::default()
1674 });
1675 }
1676
1677 let pk_rows = conn
1679 .query(
1680 &queries::primary_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1681 &[],
1682 )
1683 .await
1684 .map_err(|e| {
1685 CliError::Database(format!("Failed to query primary keys: {}", e))
1686 })?;
1687 for row in &pk_rows {
1688 if let Some(col) = row_str(row, "column_name") {
1689 db_schema.tables[i].primary_key.push(col.clone());
1690 if let Some(c) = db_schema.tables[i]
1691 .columns
1692 .iter_mut()
1693 .find(|c| c.name == col)
1694 {
1695 c.is_primary_key = true;
1696 }
1697 }
1698 }
1699
1700 let fk_rows = conn
1702 .query(
1703 &queries::foreign_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1704 &[],
1705 )
1706 .await
1707 .map_err(|e| {
1708 CliError::Database(format!("Failed to query foreign keys: {}", e))
1709 })?;
1710 let mut fk_map: std::collections::HashMap<String, ForeignKeyInfo> =
1711 std::collections::HashMap::new();
1712 let mut fk_order: Vec<String> = Vec::new();
1713 for row in &fk_rows {
1714 let Some(cname) = row_str(row, "constraint_name") else {
1715 continue;
1716 };
1717 let fk = fk_map.entry(cname.clone()).or_insert_with(|| {
1718 fk_order.push(cname.clone());
1719 ForeignKeyInfo {
1720 name: cname.clone(),
1721 columns: Vec::new(),
1722 referenced_table: row_str(row, "referenced_table").unwrap_or_default(),
1723 referenced_schema: row_str(row, "referenced_schema"),
1724 referenced_columns: Vec::new(),
1725 on_delete: ReferentialAction::from_str(
1726 &row_str(row, "delete_rule").unwrap_or_default(),
1727 ),
1728 on_update: ReferentialAction::from_str(
1729 &row_str(row, "update_rule").unwrap_or_default(),
1730 ),
1731 }
1732 });
1733 if let Some(col) = row_str(row, "column_name") {
1734 fk.columns.push(col);
1735 }
1736 if let Some(rc) = row_str(row, "referenced_column") {
1737 fk.referenced_columns.push(rc);
1738 }
1739 }
1740 db_schema.tables[i].foreign_keys = fk_order
1741 .into_iter()
1742 .filter_map(|n| fk_map.remove(&n))
1743 .collect();
1744
1745 let idx_rows = conn
1747 .query(
1748 &queries::indexes_query(DatabaseType::MSSQL, &table_name, schema_ref),
1749 &[],
1750 )
1751 .await
1752 .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
1753 let mut idx_map: std::collections::HashMap<String, IndexInfo> =
1754 std::collections::HashMap::new();
1755 let mut idx_order: Vec<String> = Vec::new();
1756 for row in &idx_rows {
1757 let Some(iname) = row_str(row, "index_name") else {
1758 continue;
1759 };
1760 let idx = idx_map.entry(iname.clone()).or_insert_with(|| {
1761 idx_order.push(iname.clone());
1762 IndexInfo {
1763 name: iname.clone(),
1764 columns: Vec::new(),
1765 is_unique: row_bool(row, "is_unique"),
1766 is_primary: row_bool(row, "is_primary"),
1767 index_type: row_str(row, "index_type"),
1768 filter: row_str(row, "filter"),
1769 }
1770 });
1771 if let Some(col) = row_str(row, "column_name") {
1772 idx.columns.push(IndexColumn {
1773 name: col,
1774 order: SortOrder::Asc,
1775 ..Default::default()
1776 });
1777 }
1778 }
1779 db_schema.tables[i].indexes = idx_order
1780 .into_iter()
1781 .filter_map(|n| idx_map.remove(&n))
1782 .collect();
1783 }
1784
1785 Ok(db_schema)
1786 }
1787 }
1788}
1789
1790pub fn format_as_prax(schema: &DatabaseSchema, config: &Config) -> String {
1796 let mut output = String::new();
1797
1798 output.push_str("// Generated by `prax db pull`\n");
1799 output.push_str("// Edit this file to customize your schema\n\n");
1800
1801 output.push_str("datasource db {\n");
1802 output.push_str(&format!(
1803 " provider = \"{}\"\n",
1804 config.database.provider
1805 ));
1806 output.push_str(" url = env(\"DATABASE_URL\")\n");
1807 output.push_str("}\n\n");
1808
1809 output.push_str("generator client {\n");
1810 output.push_str(" provider = \"prax-client-rust\"\n");
1811 output.push_str(" output = \"./src/generated\"\n");
1812 output.push_str("}\n\n");
1813
1814 output.push_str(&generate_prax_schema(schema));
1816
1817 output
1818}
1819
1820pub fn format_as_json(schema: &DatabaseSchema) -> CliResult<String> {
1822 serde_json::to_string_pretty(schema)
1823 .map_err(|e| CliError::Config(format!("Failed to serialize schema: {}", e)))
1824}
1825
1826pub fn format_as_sql(schema: &DatabaseSchema, db_type: DatabaseType) -> String {
1828 let mut output = String::new();
1829
1830 output.push_str("-- Generated by `prax db pull`\n");
1831 output.push_str(&format!("-- Database: {}\n\n", db_type_name(db_type)));
1832
1833 if db_type == DatabaseType::PostgreSQL {
1835 for enum_info in &schema.enums {
1836 output.push_str(&format!("CREATE TYPE {} AS ENUM (\n", enum_info.name));
1837 let values: Vec<String> = enum_info
1838 .values
1839 .iter()
1840 .map(|v| format!(" '{}'", v))
1841 .collect();
1842 output.push_str(&values.join(",\n"));
1843 output.push_str("\n);\n\n");
1844 }
1845 }
1846
1847 for table in &schema.tables {
1849 output.push_str(&format!(
1850 "CREATE TABLE {} (\n",
1851 quote_identifier(&table.name, db_type)
1852 ));
1853
1854 let mut col_defs: Vec<String> = Vec::new();
1855
1856 for col in &table.columns {
1857 let mut def = format!(
1858 " {} {}",
1859 quote_identifier(&col.name, db_type),
1860 col.db_type
1861 );
1862
1863 if !col.nullable {
1864 def.push_str(" NOT NULL");
1865 }
1866
1867 if let Some(ref default) = col.default {
1868 def.push_str(&format!(" DEFAULT {}", default));
1869 }
1870
1871 col_defs.push(def);
1872 }
1873
1874 if !table.primary_key.is_empty() {
1876 let pk_cols: Vec<String> = table
1877 .primary_key
1878 .iter()
1879 .map(|c| quote_identifier(c, db_type))
1880 .collect();
1881 col_defs.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
1882 }
1883
1884 output.push_str(&col_defs.join(",\n"));
1885 output.push_str("\n);\n\n");
1886
1887 for idx in &table.indexes {
1889 if idx.is_primary {
1890 continue;
1891 }
1892
1893 let unique = if idx.is_unique { "UNIQUE " } else { "" };
1894 let cols: Vec<String> = idx
1895 .columns
1896 .iter()
1897 .map(|c| quote_identifier(&c.name, db_type))
1898 .collect();
1899
1900 output.push_str(&format!(
1901 "CREATE {}INDEX {} ON {} ({});\n",
1902 unique,
1903 quote_identifier(&idx.name, db_type),
1904 quote_identifier(&table.name, db_type),
1905 cols.join(", ")
1906 ));
1907 }
1908
1909 output.push('\n');
1910 }
1911
1912 output
1913}
1914
1915fn db_type_name(db_type: DatabaseType) -> &'static str {
1916 match db_type {
1917 DatabaseType::PostgreSQL => "PostgreSQL",
1918 DatabaseType::MySQL => "MySQL",
1919 DatabaseType::SQLite => "SQLite",
1920 DatabaseType::MSSQL => "SQL Server",
1921 }
1922}
1923
1924fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
1925 match db_type {
1926 DatabaseType::PostgreSQL => format!("\"{}\"", name),
1927 DatabaseType::MySQL => format!("`{}`", name),
1928 DatabaseType::SQLite => format!("\"{}\"", name),
1929 DatabaseType::MSSQL => format!("[{}]", name),
1930 }
1931}