1use std::collections::HashMap;
7
8use prax_query::introspection::{
9 ColumnInfo, DatabaseSchema, EnumInfo, ForeignKeyInfo, IndexColumn, IndexInfo,
10 ReferentialAction, SortOrder, TableInfo, ViewInfo, generate_prax_schema, normalize_type,
11 queries,
12};
13use prax_query::sql::DatabaseType;
14
15use crate::config::Config;
16use crate::error::{CliError, CliResult};
17
18#[derive(Debug, Clone)]
20pub struct IntrospectionOptions {
21 pub schema: Option<String>,
23 pub include_views: bool,
25 pub include_materialized_views: bool,
27 pub table_filter: Option<String>,
29 pub exclude_pattern: Option<String>,
31 pub include_comments: bool,
33 pub sample_size: usize,
35}
36
37impl Default for IntrospectionOptions {
38 fn default() -> Self {
39 Self {
40 schema: None,
41 include_views: false,
42 include_materialized_views: false,
43 table_filter: None,
44 exclude_pattern: None,
45 include_comments: true,
46 sample_size: 100,
47 }
48 }
49}
50
51#[allow(async_fn_in_trait)]
53pub trait Introspector {
54 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema>;
56}
57
58pub fn get_database_type(provider: &str) -> CliResult<DatabaseType> {
60 match provider.to_lowercase().as_str() {
61 "postgresql" | "postgres" | "pg" => Ok(DatabaseType::PostgreSQL),
62 "mysql" | "mariadb" => Ok(DatabaseType::MySQL),
63 "sqlite" | "sqlite3" => Ok(DatabaseType::SQLite),
64 "mssql" | "sqlserver" | "sql_server" => Ok(DatabaseType::MSSQL),
65 _ => Err(CliError::Config(format!(
66 "Unsupported database provider: {}",
67 provider
68 ))),
69 }
70}
71
72pub fn default_schema(db_type: DatabaseType) -> &'static str {
74 match db_type {
75 DatabaseType::PostgreSQL => "public",
76 DatabaseType::MySQL => "",
77 DatabaseType::SQLite => "",
78 DatabaseType::MSSQL => "dbo",
79 }
80}
81
82#[cfg(feature = "postgres")]
87pub mod postgres {
88 use super::*;
89 use tokio_postgres::{Client, NoTls, Row};
90
91 pub struct PostgresIntrospector {
93 connection_string: String,
94 }
95
96 impl PostgresIntrospector {
97 pub fn new(connection_string: String) -> Self {
99 Self { connection_string }
100 }
101
102 async fn connect(&self) -> CliResult<Client> {
104 let config = self
110 .connection_string
111 .parse::<tokio_postgres::Config>()
112 .map_err(|e| CliError::Config(format!("Invalid connection string: {}", e)))?;
113
114 let tls_disabled = matches!(
115 config.get_ssl_mode(),
116 tokio_postgres::config::SslMode::Disable
117 );
118
119 if tls_disabled && !config.get_hosts().iter().all(is_local_host) {
120 crate::output::warn(
121 "sslmode=disable with a non-local host: credentials and data will be \
122 sent in plaintext.",
123 );
124 }
125
126 let client = if tls_disabled {
130 let (client, connection) = tokio_postgres::connect(&self.connection_string, NoTls)
131 .await
132 .map_err(|e| CliError::Database(format!("Failed to connect: {}", e)))?;
133 tokio::spawn(async move {
134 if let Err(e) = connection.await {
135 eprintln!("Connection error: {}", e);
136 }
137 });
138 client
139 } else {
140 let (client, connection) = tokio_postgres::connect(
141 &self.connection_string,
142 prax_postgres::tls::make_tls_connector(),
143 )
144 .await
145 .map_err(|e| CliError::Database(format!("Failed to connect: {}", e)))?;
146 tokio::spawn(async move {
147 if let Err(e) = connection.await {
148 eprintln!("Connection error: {}", e);
149 }
150 });
151 client
152 };
153
154 Ok(client)
155 }
156 }
157
158 impl Introspector for PostgresIntrospector {
159 async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
160 let client = self.connect().await?;
161 let schema_name = options.schema.as_deref().unwrap_or("public");
162
163 let mut db_schema = DatabaseSchema {
164 name: "database".to_string(),
165 schema: Some(schema_name.to_string()),
166 ..Default::default()
167 };
168
169 let tables_sql = queries::tables_query(DatabaseType::PostgreSQL, Some(schema_name));
171 let table_rows = client
172 .query(&tables_sql, &[])
173 .await
174 .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
175
176 for row in table_rows {
177 let table_name: String = row.get(0);
178
179 if let Some(ref pattern) = options.table_filter
181 && !matches_pattern(&table_name, pattern)
182 {
183 continue;
184 }
185 if let Some(ref exclude) = options.exclude_pattern
186 && matches_pattern(&table_name, exclude)
187 {
188 continue;
189 }
190
191 let comment: Option<String> = row.try_get(1).ok();
192
193 db_schema.tables.push(TableInfo {
194 name: table_name,
195 schema: Some(schema_name.to_string()),
196 comment: if options.include_comments {
197 comment
198 } else {
199 None
200 },
201 ..Default::default()
202 });
203 }
204
205 let cols_sql = "SELECT \
212 c.column_name, \
213 c.data_type, \
214 c.udt_name, \
215 c.is_nullable = 'YES' as nullable, \
216 c.column_default, \
217 c.character_maximum_length, \
218 c.numeric_precision, \
219 c.numeric_scale, \
220 col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
221 CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment, \
222 c.table_name \
223 FROM information_schema.columns c \
224 WHERE c.table_schema = $1 \
225 ORDER BY c.table_name, c.ordinal_position";
226 let col_rows = client
227 .query(cols_sql, &[&schema_name])
228 .await
229 .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
230
231 let mut columns_by_table: HashMap<String, Vec<Row>> = HashMap::new();
232 for col_row in col_rows {
233 let table_name: String = col_row.get(10);
234 columns_by_table
235 .entry(table_name)
236 .or_default()
237 .push(col_row);
238 }
239
240 let pk_sql = "SELECT a.attname as column_name, c.relname as table_name \
241 FROM pg_index i \
242 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
243 JOIN pg_class c ON c.oid = i.indrelid \
244 JOIN pg_namespace n ON n.oid = c.relnamespace \
245 WHERE i.indisprimary AND n.nspname = $1 \
246 ORDER BY c.relname, array_position(i.indkey, a.attnum)";
247 let pk_rows = client
248 .query(pk_sql, &[&schema_name])
249 .await
250 .map_err(|e| CliError::Database(format!("Failed to query primary keys: {}", e)))?;
251
252 let mut pks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
253 for pk_row in pk_rows {
254 let table_name: String = pk_row.get(1);
255 pks_by_table.entry(table_name).or_default().push(pk_row);
256 }
257
258 let fk_sql = "SELECT \
259 tc.constraint_name, \
260 kcu.column_name, \
261 ccu.table_name as referenced_table, \
262 ccu.table_schema as referenced_schema, \
263 ccu.column_name as referenced_column, \
264 rc.delete_rule, \
265 rc.update_rule, \
266 tc.table_name \
267 FROM information_schema.table_constraints tc \
268 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
269 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
270 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
271 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1 \
272 ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position";
273 let fk_rows = client
274 .query(fk_sql, &[&schema_name])
275 .await
276 .map_err(|e| CliError::Database(format!("Failed to query foreign keys: {}", e)))?;
277
278 let mut fks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
279 for fk_row in fk_rows {
280 let table_name: String = fk_row.get(7);
281 fks_by_table.entry(table_name).or_default().push(fk_row);
282 }
283
284 let idx_sql = "SELECT \
285 i.relname as index_name, \
286 a.attname as column_name, \
287 ix.indisunique as is_unique, \
288 ix.indisprimary as is_primary, \
289 am.amname as index_type, \
290 pg_get_expr(ix.indpred, ix.indrelid) as filter, \
291 t.relname as table_name \
292 FROM pg_index ix \
293 JOIN pg_class t ON t.oid = ix.indrelid \
294 JOIN pg_class i ON i.oid = ix.indexrelid \
295 JOIN pg_namespace n ON n.oid = t.relnamespace \
296 JOIN pg_am am ON i.relam = am.oid \
297 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
298 WHERE n.nspname = $1 \
299 ORDER BY t.relname, i.relname, array_position(ix.indkey, a.attnum)";
300 let idx_rows = client
301 .query(idx_sql, &[&schema_name])
302 .await
303 .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
304
305 let mut indexes_by_table: HashMap<String, Vec<Row>> = HashMap::new();
306 for idx_row in idx_rows {
307 let table_name: String = idx_row.get(6);
308 indexes_by_table
309 .entry(table_name)
310 .or_default()
311 .push(idx_row);
312 }
313
314 for table in &mut db_schema.tables {
316 for col_row in columns_by_table.remove(&table.name).unwrap_or_default() {
317 let col_name: String = col_row.get(0);
318 let data_type: String = col_row.get(1);
319 let udt_name: String = col_row.get(2);
320 let nullable: bool = col_row.get(3);
321 let default: Option<String> = col_row.try_get(4).ok();
322 let max_length: Option<i32> = col_row.try_get(5).ok();
323 let precision: Option<i32> = col_row.try_get(6).ok();
324 let scale: Option<i32> = col_row.try_get(7).ok();
325 let comment: Option<String> = col_row.try_get(8).ok();
326 let auto_increment: bool = col_row.try_get(9).unwrap_or(false);
327
328 let normalized = normalize_type(
329 DatabaseType::PostgreSQL,
330 &udt_name,
331 max_length,
332 precision,
333 scale,
334 );
335
336 table.columns.push(ColumnInfo {
337 name: col_name,
338 db_type: data_type,
339 normalized_type: normalized,
340 nullable,
341 default,
342 auto_increment,
343 max_length,
344 precision,
345 scale,
346 comment: if options.include_comments {
347 comment
348 } else {
349 None
350 },
351 ..Default::default()
352 });
353 }
354
355 for pk_row in pks_by_table.remove(&table.name).unwrap_or_default() {
356 let col_name: String = pk_row.get(0);
357 table.primary_key.push(col_name.clone());
358
359 if let Some(col) = table.columns.iter_mut().find(|c| c.name == col_name) {
361 col.is_primary_key = true;
362 }
363 }
364
365 let mut fk_map: HashMap<String, ForeignKeyInfo> = HashMap::new();
366 for fk_row in fks_by_table.remove(&table.name).unwrap_or_default() {
367 let constraint_name: String = fk_row.get(0);
368 let column_name: String = fk_row.get(1);
369 let ref_table: String = fk_row.get(2);
370 let ref_schema: Option<String> = fk_row.try_get(3).ok();
371 let ref_column: String = fk_row.get(4);
372 let delete_rule: String = fk_row.get(5);
373 let update_rule: String = fk_row.get(6);
374
375 let fk =
376 fk_map
377 .entry(constraint_name.clone())
378 .or_insert_with(|| ForeignKeyInfo {
379 name: constraint_name,
380 columns: Vec::new(),
381 referenced_table: ref_table,
382 referenced_schema: ref_schema,
383 referenced_columns: Vec::new(),
384 on_delete: ReferentialAction::from_str(&delete_rule),
385 on_update: ReferentialAction::from_str(&update_rule),
386 });
387
388 fk.columns.push(column_name);
389 fk.referenced_columns.push(ref_column);
390 }
391
392 table.foreign_keys = fk_map.into_values().collect();
393
394 let mut idx_map: HashMap<String, IndexInfo> = HashMap::new();
395 for idx_row in indexes_by_table.remove(&table.name).unwrap_or_default() {
396 let idx_name: String = idx_row.get(0);
397 let col_name: String = idx_row.get(1);
398 let is_unique: bool = idx_row.get(2);
399 let is_primary: bool = idx_row.get(3);
400 let idx_type: Option<String> = idx_row.try_get(4).ok();
401 let filter: Option<String> = idx_row.try_get(5).ok();
402
403 let idx = idx_map
404 .entry(idx_name.clone())
405 .or_insert_with(|| IndexInfo {
406 name: idx_name,
407 columns: Vec::new(),
408 is_unique,
409 is_primary,
410 index_type: idx_type,
411 filter,
412 });
413
414 idx.columns.push(IndexColumn {
415 name: col_name,
416 order: SortOrder::Asc,
417 ..Default::default()
418 });
419 }
420
421 table.indexes = idx_map.into_values().collect();
422 }
423
424 let enums_sql = queries::enums_query(Some(schema_name));
426 let enum_rows = client
427 .query(&enums_sql, &[])
428 .await
429 .map_err(|e| CliError::Database(format!("Failed to query enums: {}", e)))?;
430
431 let mut enum_map: HashMap<String, EnumInfo> = HashMap::new();
432 for enum_row in enum_rows {
433 let enum_name: String = enum_row.get(0);
434 let enum_value: String = enum_row.get(1);
435
436 let enum_info = enum_map
437 .entry(enum_name.clone())
438 .or_insert_with(|| EnumInfo {
439 name: enum_name,
440 schema: Some(schema_name.to_string()),
441 values: Vec::new(),
442 });
443
444 enum_info.values.push(enum_value);
445 }
446
447 db_schema.enums = enum_map.into_values().collect();
448
449 if options.include_views || options.include_materialized_views {
451 let views_sql = queries::views_query(DatabaseType::PostgreSQL, Some(schema_name));
452 let view_rows = client
453 .query(&views_sql, &[])
454 .await
455 .map_err(|e| CliError::Database(format!("Failed to query views: {}", e)))?;
456
457 for view_row in view_rows {
458 let view_name: String = view_row.get(0);
459 let definition: Option<String> = view_row.try_get(1).ok();
460 let is_materialized: bool = view_row.get(2);
461
462 if is_materialized && !options.include_materialized_views {
463 continue;
464 }
465 if !is_materialized && !options.include_views {
466 continue;
467 }
468
469 db_schema.views.push(ViewInfo {
470 name: view_name,
471 schema: Some(schema_name.to_string()),
472 definition,
473 is_materialized,
474 columns: Vec::new(),
475 });
476 }
477 }
478
479 Ok(db_schema)
480 }
481 }
482
483 fn is_local_host(host: &tokio_postgres::config::Host) -> bool {
485 match host {
486 tokio_postgres::config::Host::Tcp(name) => {
487 name == "localhost" || name == "127.0.0.1" || name == "::1"
488 }
489 tokio_postgres::config::Host::Unix(_) => true,
490 }
491 }
492
493 fn matches_pattern(name: &str, pattern: &str) -> bool {
495 if pattern == "*" {
496 return true;
497 }
498
499 if pattern.starts_with('*') && pattern.ends_with('*') {
500 let middle = &pattern[1..pattern.len() - 1];
501 return name.contains(middle);
502 }
503
504 if let Some(suffix) = pattern.strip_prefix('*') {
505 return name.ends_with(suffix);
506 }
507
508 if let Some(prefix) = pattern.strip_suffix('*') {
509 return name.starts_with(prefix);
510 }
511
512 name == pattern
513 }
514}
515
516pub fn format_as_prax(schema: &DatabaseSchema, config: &Config) -> String {
522 let mut output = String::new();
523
524 output.push_str("// Generated by `prax db pull`\n");
525 output.push_str("// Edit this file to customize your schema\n\n");
526
527 output.push_str("datasource db {\n");
528 output.push_str(&format!(
529 " provider = \"{}\"\n",
530 config.database.provider
531 ));
532 output.push_str(" url = env(\"DATABASE_URL\")\n");
533 output.push_str("}\n\n");
534
535 output.push_str("generator client {\n");
536 output.push_str(" provider = \"prax-client-rust\"\n");
537 output.push_str(" output = \"./src/generated\"\n");
538 output.push_str("}\n\n");
539
540 output.push_str(&generate_prax_schema(schema));
542
543 output
544}
545
546pub fn format_as_json(schema: &DatabaseSchema) -> CliResult<String> {
548 serde_json::to_string_pretty(schema)
549 .map_err(|e| CliError::Config(format!("Failed to serialize schema: {}", e)))
550}
551
552pub fn format_as_sql(schema: &DatabaseSchema, db_type: DatabaseType) -> String {
554 let mut output = String::new();
555
556 output.push_str("-- Generated by `prax db pull`\n");
557 output.push_str(&format!("-- Database: {}\n\n", db_type_name(db_type)));
558
559 if db_type == DatabaseType::PostgreSQL {
561 for enum_info in &schema.enums {
562 output.push_str(&format!("CREATE TYPE {} AS ENUM (\n", enum_info.name));
563 let values: Vec<String> = enum_info
564 .values
565 .iter()
566 .map(|v| format!(" '{}'", v))
567 .collect();
568 output.push_str(&values.join(",\n"));
569 output.push_str("\n);\n\n");
570 }
571 }
572
573 for table in &schema.tables {
575 output.push_str(&format!(
576 "CREATE TABLE {} (\n",
577 quote_identifier(&table.name, db_type)
578 ));
579
580 let mut col_defs: Vec<String> = Vec::new();
581
582 for col in &table.columns {
583 let mut def = format!(
584 " {} {}",
585 quote_identifier(&col.name, db_type),
586 col.db_type
587 );
588
589 if !col.nullable {
590 def.push_str(" NOT NULL");
591 }
592
593 if let Some(ref default) = col.default {
594 def.push_str(&format!(" DEFAULT {}", default));
595 }
596
597 col_defs.push(def);
598 }
599
600 if !table.primary_key.is_empty() {
602 let pk_cols: Vec<String> = table
603 .primary_key
604 .iter()
605 .map(|c| quote_identifier(c, db_type))
606 .collect();
607 col_defs.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
608 }
609
610 output.push_str(&col_defs.join(",\n"));
611 output.push_str("\n);\n\n");
612
613 for idx in &table.indexes {
615 if idx.is_primary {
616 continue;
617 }
618
619 let unique = if idx.is_unique { "UNIQUE " } else { "" };
620 let cols: Vec<String> = idx
621 .columns
622 .iter()
623 .map(|c| quote_identifier(&c.name, db_type))
624 .collect();
625
626 output.push_str(&format!(
627 "CREATE {}INDEX {} ON {} ({});\n",
628 unique,
629 quote_identifier(&idx.name, db_type),
630 quote_identifier(&table.name, db_type),
631 cols.join(", ")
632 ));
633 }
634
635 output.push('\n');
636 }
637
638 output
639}
640
641fn db_type_name(db_type: DatabaseType) -> &'static str {
642 match db_type {
643 DatabaseType::PostgreSQL => "PostgreSQL",
644 DatabaseType::MySQL => "MySQL",
645 DatabaseType::SQLite => "SQLite",
646 DatabaseType::MSSQL => "SQL Server",
647 }
648}
649
650fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
651 match db_type {
652 DatabaseType::PostgreSQL => format!("\"{}\"", name),
653 DatabaseType::MySQL => format!("`{}`", name),
654 DatabaseType::SQLite => format!("\"{}\"", name),
655 DatabaseType::MSSQL => format!("[{}]", name),
656 }
657}