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
1409 && let Some(col) = table
1410 .columns
1411 .iter_mut()
1412 .find(|c| c.name == table.primary_key[0])
1413 && col.db_type.to_ascii_lowercase().contains("int")
1414 {
1415 col.auto_increment = true;
1416 }
1417
1418 let fk_rows = SqliteIntrospector::rows(
1421 engine,
1422 &queries::foreign_keys_query(DatabaseType::SQLite, &table.name, None),
1423 )
1424 .await?;
1425 let mut fk_map: std::collections::BTreeMap<i64, ForeignKeyInfo> =
1426 std::collections::BTreeMap::new();
1427 for row in &fk_rows {
1428 let id = row.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
1429 let fk = fk_map.entry(id).or_insert_with(|| ForeignKeyInfo {
1430 name: String::new(),
1434 columns: Vec::new(),
1435 referenced_table: json_str(row, "table").unwrap_or_default(),
1436 referenced_schema: None,
1437 referenced_columns: Vec::new(),
1438 on_delete: ReferentialAction::from_str(
1439 &json_str(row, "on_delete").unwrap_or_default(),
1440 ),
1441 on_update: ReferentialAction::from_str(
1442 &json_str(row, "on_update").unwrap_or_default(),
1443 ),
1444 });
1445 if let Some(from) = json_str(row, "from") {
1446 fk.columns.push(from);
1447 }
1448 if let Some(to) = json_str(row, "to") {
1449 fk.referenced_columns.push(to);
1450 }
1451 }
1452 table.foreign_keys = fk_map
1453 .into_values()
1454 .map(|mut fk| {
1455 if fk.name.is_empty() {
1456 fk.name = format!("fk_{}_{}", table.name, fk.columns.join("_"));
1457 }
1458 fk
1459 })
1460 .collect();
1461
1462 let idx_rows = SqliteIntrospector::rows(
1465 engine,
1466 &queries::indexes_query(DatabaseType::SQLite, &table.name, None),
1467 )
1468 .await?;
1469 for row in &idx_rows {
1470 let Some(idx_name) = json_str(row, "name") else {
1471 continue;
1472 };
1473 let origin = json_str(row, "origin").unwrap_or_default();
1474 let is_primary = origin == "pk";
1475 let is_unique = json_i32(row, "unique").unwrap_or(0) != 0;
1476
1477 let info_rows = SqliteIntrospector::rows(
1479 engine,
1480 &format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")),
1481 )
1482 .await?;
1483 let columns: Vec<IndexColumn> = info_rows
1484 .iter()
1485 .filter_map(|r| json_str(r, "name"))
1486 .map(|name| IndexColumn {
1487 name,
1488 order: SortOrder::Asc,
1489 ..Default::default()
1490 })
1491 .collect();
1492
1493 table.indexes.push(IndexInfo {
1494 name: idx_name,
1495 columns,
1496 is_unique,
1497 is_primary,
1498 index_type: None,
1499 filter: None,
1500 });
1501 }
1502
1503 Ok(())
1504 }
1505}
1506
1507#[cfg(feature = "mssql")]
1512pub mod mssql {
1513 use super::*;
1514 use prax_mssql::MssqlPool;
1515 use prax_mssql::Row;
1516
1517 pub struct MssqlIntrospector {
1521 connection_string: String,
1522 }
1523
1524 impl MssqlIntrospector {
1525 pub fn new(connection_string: String) -> Self {
1527 Self { connection_string }
1528 }
1529 }
1530
1531 fn row_str(row: &Row, col: &str) -> Option<String> {
1533 row.try_get::<&str, _>(col)
1534 .ok()
1535 .flatten()
1536 .map(str::to_string)
1537 }
1538
1539 fn row_bool(row: &Row, col: &str) -> bool {
1541 if let Ok(Some(b)) = row.try_get::<bool, _>(col) {
1542 return b;
1543 }
1544 row_i64(row, col).is_some_and(|i| i != 0)
1546 }
1547
1548 fn row_i64(row: &Row, col: &str) -> Option<i64> {
1550 if let Ok(Some(v)) = row.try_get::<i32, _>(col) {
1551 return Some(v as i64);
1552 }
1553 if let Ok(Some(v)) = row.try_get::<i64, _>(col) {
1554 return Some(v);
1555 }
1556 if let Ok(Some(v)) = row.try_get::<i16, _>(col) {
1557 return Some(v as i64);
1558 }
1559 if let Ok(Some(v)) = row.try_get::<u8, _>(col) {
1560 return Some(v as i64);
1561 }
1562 None
1563 }
1564
1565 fn row_i32(row: &Row, col: &str) -> Option<i32> {
1566 row_i64(row, col).and_then(|v| i32::try_from(v).ok())
1567 }
1568
1569 impl Introspector for MssqlIntrospector {
1570 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
1571 let pool = MssqlPool::builder()
1572 .connection_string(self.connection_string.clone())
1573 .connection_timeout(std::time::Duration::from_secs(
1574 super::INTROSPECT_CONNECT_TIMEOUT_SECS,
1575 ))
1576 .build()
1577 .await
1578 .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
1579 let mut conn = pool.get().await.map_err(|e| {
1580 CliError::Unreachable(format!("Failed to acquire connection: {}", e))
1581 })?;
1582
1583 let schema_name = options.schema.clone().unwrap_or_else(|| "dbo".to_string());
1584 let schema_ref = Some(schema_name.as_str());
1585
1586 let mut db_schema = DatabaseSchema {
1587 name: "database".to_string(),
1588 schema: Some(schema_name.clone()),
1589 ..Default::default()
1590 };
1591
1592 let table_rows = conn
1593 .query(&queries::tables_query(DatabaseType::MSSQL, schema_ref), &[])
1594 .await
1595 .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
1596 for row in &table_rows {
1597 let Some(table_name) = row_str(row, "table_name") else {
1598 continue;
1599 };
1600 if let Some(ref pattern) = options.table_filter
1601 && !matches_pattern(&table_name, pattern)
1602 {
1603 continue;
1604 }
1605 if let Some(ref exclude) = options.exclude_pattern
1606 && matches_pattern(&table_name, exclude)
1607 {
1608 continue;
1609 }
1610 db_schema.tables.push(TableInfo {
1611 name: table_name,
1612 schema: Some(schema_name.clone()),
1613 comment: if options.include_comments {
1614 row_str(row, "comment")
1615 } else {
1616 None
1617 },
1618 ..Default::default()
1619 });
1620 }
1621
1622 for i in 0..db_schema.tables.len() {
1623 let table_name = db_schema.tables[i].name.clone();
1624
1625 let col_rows = conn
1627 .query(
1628 &queries::columns_query(DatabaseType::MSSQL, &table_name, schema_ref),
1629 &[],
1630 )
1631 .await
1632 .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
1633 for row in &col_rows {
1634 let Some(name) = row_str(row, "column_name") else {
1635 continue;
1636 };
1637 let data_type = row_str(row, "data_type").unwrap_or_default();
1638 let max_length = row_i32(row, "character_maximum_length");
1645 let precision = row_i32(row, "numeric_precision");
1646 let scale = row_i32(row, "numeric_scale");
1647 let normalized = normalize_type(
1648 DatabaseType::MSSQL,
1649 &data_type,
1650 max_length,
1651 precision,
1652 scale,
1653 );
1654 db_schema.tables[i].columns.push(ColumnInfo {
1655 name,
1656 db_type: data_type,
1657 normalized_type: normalized,
1658 nullable: row_bool(row, "nullable"),
1659 default: row_str(row, "column_default"),
1660 auto_increment: row_bool(row, "auto_increment"),
1661 max_length,
1662 precision,
1663 scale,
1664 comment: if options.include_comments {
1665 row_str(row, "comment")
1666 } else {
1667 None
1668 },
1669 ..Default::default()
1670 });
1671 }
1672
1673 let pk_rows = conn
1675 .query(
1676 &queries::primary_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1677 &[],
1678 )
1679 .await
1680 .map_err(|e| {
1681 CliError::Database(format!("Failed to query primary keys: {}", e))
1682 })?;
1683 for row in &pk_rows {
1684 if let Some(col) = row_str(row, "column_name") {
1685 db_schema.tables[i].primary_key.push(col.clone());
1686 if let Some(c) = db_schema.tables[i]
1687 .columns
1688 .iter_mut()
1689 .find(|c| c.name == col)
1690 {
1691 c.is_primary_key = true;
1692 }
1693 }
1694 }
1695
1696 let fk_rows = conn
1698 .query(
1699 &queries::foreign_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1700 &[],
1701 )
1702 .await
1703 .map_err(|e| {
1704 CliError::Database(format!("Failed to query foreign keys: {}", e))
1705 })?;
1706 let mut fk_map: std::collections::HashMap<String, ForeignKeyInfo> =
1707 std::collections::HashMap::new();
1708 let mut fk_order: Vec<String> = Vec::new();
1709 for row in &fk_rows {
1710 let Some(cname) = row_str(row, "constraint_name") else {
1711 continue;
1712 };
1713 let fk = fk_map.entry(cname.clone()).or_insert_with(|| {
1714 fk_order.push(cname.clone());
1715 ForeignKeyInfo {
1716 name: cname.clone(),
1717 columns: Vec::new(),
1718 referenced_table: row_str(row, "referenced_table").unwrap_or_default(),
1719 referenced_schema: row_str(row, "referenced_schema"),
1720 referenced_columns: Vec::new(),
1721 on_delete: ReferentialAction::from_str(
1722 &row_str(row, "delete_rule").unwrap_or_default(),
1723 ),
1724 on_update: ReferentialAction::from_str(
1725 &row_str(row, "update_rule").unwrap_or_default(),
1726 ),
1727 }
1728 });
1729 if let Some(col) = row_str(row, "column_name") {
1730 fk.columns.push(col);
1731 }
1732 if let Some(rc) = row_str(row, "referenced_column") {
1733 fk.referenced_columns.push(rc);
1734 }
1735 }
1736 db_schema.tables[i].foreign_keys = fk_order
1737 .into_iter()
1738 .filter_map(|n| fk_map.remove(&n))
1739 .collect();
1740
1741 let idx_rows = conn
1743 .query(
1744 &queries::indexes_query(DatabaseType::MSSQL, &table_name, schema_ref),
1745 &[],
1746 )
1747 .await
1748 .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
1749 let mut idx_map: std::collections::HashMap<String, IndexInfo> =
1750 std::collections::HashMap::new();
1751 let mut idx_order: Vec<String> = Vec::new();
1752 for row in &idx_rows {
1753 let Some(iname) = row_str(row, "index_name") else {
1754 continue;
1755 };
1756 let idx = idx_map.entry(iname.clone()).or_insert_with(|| {
1757 idx_order.push(iname.clone());
1758 IndexInfo {
1759 name: iname.clone(),
1760 columns: Vec::new(),
1761 is_unique: row_bool(row, "is_unique"),
1762 is_primary: row_bool(row, "is_primary"),
1763 index_type: row_str(row, "index_type"),
1764 filter: row_str(row, "filter"),
1765 }
1766 });
1767 if let Some(col) = row_str(row, "column_name") {
1768 idx.columns.push(IndexColumn {
1769 name: col,
1770 order: SortOrder::Asc,
1771 ..Default::default()
1772 });
1773 }
1774 }
1775 db_schema.tables[i].indexes = idx_order
1776 .into_iter()
1777 .filter_map(|n| idx_map.remove(&n))
1778 .collect();
1779 }
1780
1781 Ok(db_schema)
1782 }
1783 }
1784}
1785
1786pub fn format_as_prax(schema: &DatabaseSchema, config: &Config) -> String {
1792 let mut output = String::new();
1793
1794 output.push_str("// Generated by `prax db pull`\n");
1795 output.push_str("// Edit this file to customize your schema\n\n");
1796
1797 output.push_str("datasource db {\n");
1798 output.push_str(&format!(
1799 " provider = \"{}\"\n",
1800 config.database.provider
1801 ));
1802 output.push_str(" url = env(\"DATABASE_URL\")\n");
1803 output.push_str("}\n\n");
1804
1805 output.push_str("generator client {\n");
1806 output.push_str(" provider = \"prax-client-rust\"\n");
1807 output.push_str(" output = \"./src/generated\"\n");
1808 output.push_str("}\n\n");
1809
1810 output.push_str(&generate_prax_schema(schema));
1812
1813 output
1814}
1815
1816pub fn format_as_json(schema: &DatabaseSchema) -> CliResult<String> {
1818 serde_json::to_string_pretty(schema)
1819 .map_err(|e| CliError::Config(format!("Failed to serialize schema: {}", e)))
1820}
1821
1822pub fn format_as_sql(schema: &DatabaseSchema, db_type: DatabaseType) -> String {
1824 let mut output = String::new();
1825
1826 output.push_str("-- Generated by `prax db pull`\n");
1827 output.push_str(&format!("-- Database: {}\n\n", db_type_name(db_type)));
1828
1829 if db_type == DatabaseType::PostgreSQL {
1831 for enum_info in &schema.enums {
1832 output.push_str(&format!("CREATE TYPE {} AS ENUM (\n", enum_info.name));
1833 let values: Vec<String> = enum_info
1834 .values
1835 .iter()
1836 .map(|v| format!(" '{}'", v))
1837 .collect();
1838 output.push_str(&values.join(",\n"));
1839 output.push_str("\n);\n\n");
1840 }
1841 }
1842
1843 for table in &schema.tables {
1845 output.push_str(&format!(
1846 "CREATE TABLE {} (\n",
1847 quote_identifier(&table.name, db_type)
1848 ));
1849
1850 let mut col_defs: Vec<String> = Vec::new();
1851
1852 for col in &table.columns {
1853 let mut def = format!(
1854 " {} {}",
1855 quote_identifier(&col.name, db_type),
1856 col.db_type
1857 );
1858
1859 if !col.nullable {
1860 def.push_str(" NOT NULL");
1861 }
1862
1863 if let Some(ref default) = col.default {
1864 def.push_str(&format!(" DEFAULT {}", default));
1865 }
1866
1867 col_defs.push(def);
1868 }
1869
1870 if !table.primary_key.is_empty() {
1872 let pk_cols: Vec<String> = table
1873 .primary_key
1874 .iter()
1875 .map(|c| quote_identifier(c, db_type))
1876 .collect();
1877 col_defs.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
1878 }
1879
1880 output.push_str(&col_defs.join(",\n"));
1881 output.push_str("\n);\n\n");
1882
1883 for idx in &table.indexes {
1885 if idx.is_primary {
1886 continue;
1887 }
1888
1889 let unique = if idx.is_unique { "UNIQUE " } else { "" };
1890 let cols: Vec<String> = idx
1891 .columns
1892 .iter()
1893 .map(|c| quote_identifier(&c.name, db_type))
1894 .collect();
1895
1896 output.push_str(&format!(
1897 "CREATE {}INDEX {} ON {} ({});\n",
1898 unique,
1899 quote_identifier(&idx.name, db_type),
1900 quote_identifier(&table.name, db_type),
1901 cols.join(", ")
1902 ));
1903 }
1904
1905 output.push('\n');
1906 }
1907
1908 output
1909}
1910
1911fn db_type_name(db_type: DatabaseType) -> &'static str {
1912 match db_type {
1913 DatabaseType::PostgreSQL => "PostgreSQL",
1914 DatabaseType::MySQL => "MySQL",
1915 DatabaseType::SQLite => "SQLite",
1916 DatabaseType::MSSQL => "SQL Server",
1917 }
1918}
1919
1920fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
1921 match db_type {
1922 DatabaseType::PostgreSQL => format!("\"{}\"", name),
1923 DatabaseType::MySQL => format!("`{}`", name),
1924 DatabaseType::SQLite => format!("\"{}\"", name),
1925 DatabaseType::MSSQL => format!("[{}]", name),
1926 }
1927}