1use serde::{Deserialize, Serialize};
21
22use crate::sql::{DatabaseType, escape_literal};
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct DatabaseSchema {
31 pub name: String,
33 pub schema: Option<String>,
35 pub tables: Vec<TableInfo>,
37 pub views: Vec<ViewInfo>,
39 pub enums: Vec<EnumInfo>,
41 pub sequences: Vec<SequenceInfo>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct TableInfo {
48 pub name: String,
50 pub schema: Option<String>,
52 pub comment: Option<String>,
54 pub columns: Vec<ColumnInfo>,
56 pub primary_key: Vec<String>,
58 pub foreign_keys: Vec<ForeignKeyInfo>,
60 pub indexes: Vec<IndexInfo>,
62 pub unique_constraints: Vec<UniqueConstraint>,
64 pub check_constraints: Vec<CheckConstraint>,
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct ColumnInfo {
71 pub name: String,
73 pub db_type: String,
75 pub normalized_type: NormalizedType,
77 pub nullable: bool,
79 pub default: Option<String>,
81 pub auto_increment: bool,
83 pub is_primary_key: bool,
85 pub is_unique: bool,
87 pub comment: Option<String>,
89 pub max_length: Option<i32>,
91 pub precision: Option<i32>,
93 pub scale: Option<i32>,
95 pub enum_name: Option<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub enum NormalizedType {
102 Int,
104 BigInt,
105 SmallInt,
106 Float,
108 Double,
109 Decimal {
111 precision: Option<i32>,
112 scale: Option<i32>,
113 },
114 String,
116 Text,
117 Char {
118 length: Option<i32>,
119 },
120 VarChar {
121 length: Option<i32>,
122 },
123 Bytes,
125 Boolean,
127 DateTime,
129 Date,
130 Time,
131 Timestamp,
132 Json,
134 Uuid,
136 Array(Box<NormalizedType>),
138 Enum(String),
140 Unknown(String),
142}
143
144impl Default for NormalizedType {
145 fn default() -> Self {
146 Self::Unknown("unknown".to_string())
147 }
148}
149
150impl NormalizedType {
151 pub fn to_prax_type(&self) -> String {
153 match self {
154 Self::Int => "Int".to_string(),
155 Self::BigInt => "BigInt".to_string(),
156 Self::SmallInt => "Int".to_string(),
157 Self::Float => "Float".to_string(),
158 Self::Double => "Float".to_string(),
159 Self::Decimal { .. } => "Decimal".to_string(),
160 Self::String | Self::Text | Self::VarChar { .. } | Self::Char { .. } => {
161 "String".to_string()
162 }
163 Self::Bytes => "Bytes".to_string(),
164 Self::Boolean => "Boolean".to_string(),
165 Self::DateTime | Self::Timestamp => "DateTime".to_string(),
166 Self::Date => "DateTime".to_string(),
167 Self::Time => "DateTime".to_string(),
168 Self::Json => "Json".to_string(),
169 Self::Uuid => "String".to_string(), Self::Array(inner) => format!("{}[]", inner.to_prax_type()),
171 Self::Enum(name) => name.clone(),
172 Self::Unknown(t) => format!("Unsupported<{}>", t),
173 }
174 }
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ForeignKeyInfo {
180 pub name: String,
182 pub columns: Vec<String>,
184 pub referenced_table: String,
186 pub referenced_schema: Option<String>,
188 pub referenced_columns: Vec<String>,
190 pub on_delete: ReferentialAction,
192 pub on_update: ReferentialAction,
194}
195
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub enum ReferentialAction {
199 #[default]
200 NoAction,
201 Restrict,
202 Cascade,
203 SetNull,
204 SetDefault,
205}
206
207impl ReferentialAction {
208 pub fn to_prax(&self) -> &'static str {
210 match self {
211 Self::NoAction => "NoAction",
212 Self::Restrict => "Restrict",
213 Self::Cascade => "Cascade",
214 Self::SetNull => "SetNull",
215 Self::SetDefault => "SetDefault",
216 }
217 }
218
219 pub fn from_str(s: &str) -> Self {
221 match s.to_uppercase().as_str() {
222 "NO ACTION" | "NOACTION" => Self::NoAction,
223 "RESTRICT" => Self::Restrict,
224 "CASCADE" => Self::Cascade,
225 "SET NULL" | "SETNULL" => Self::SetNull,
226 "SET DEFAULT" | "SETDEFAULT" => Self::SetDefault,
227 _ => Self::NoAction,
228 }
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct IndexInfo {
235 pub name: String,
237 pub columns: Vec<IndexColumn>,
239 pub is_unique: bool,
241 pub is_primary: bool,
243 pub index_type: Option<String>,
245 pub filter: Option<String>,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct IndexColumn {
252 pub name: String,
254 pub order: SortOrder,
256 pub nulls: NullsOrder,
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262pub enum SortOrder {
263 #[default]
264 Asc,
265 Desc,
266}
267
268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
270pub enum NullsOrder {
271 #[default]
272 Last,
273 First,
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
278pub struct UniqueConstraint {
279 pub name: String,
281 pub columns: Vec<String>,
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287pub struct CheckConstraint {
288 pub name: String,
290 pub expression: String,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct ViewInfo {
297 pub name: String,
299 pub schema: Option<String>,
301 pub definition: Option<String>,
303 pub is_materialized: bool,
305 pub columns: Vec<ColumnInfo>,
307}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct EnumInfo {
312 pub name: String,
314 pub schema: Option<String>,
316 pub values: Vec<String>,
318}
319
320#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct SequenceInfo {
323 pub name: String,
325 pub schema: Option<String>,
327 pub start: i64,
329 pub increment: i64,
331 pub min_value: Option<i64>,
333 pub max_value: Option<i64>,
335 pub cycle: bool,
337}
338
339pub mod queries {
345 use super::*;
346
347 pub fn tables_query(db_type: DatabaseType, schema: Option<&str>) -> String {
349 match db_type {
350 DatabaseType::PostgreSQL => {
351 let schema_filter = escape_literal(schema.unwrap_or("public"));
352 format!(
353 "SELECT table_name, obj_description((quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass) as comment \
354 FROM information_schema.tables \
355 WHERE table_schema = '{}' AND table_type = 'BASE TABLE' \
356 ORDER BY table_name",
357 schema_filter
358 )
359 }
360 DatabaseType::MySQL => {
361 let schema_filter = schema
362 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
363 .unwrap_or_default();
364 format!(
365 "SELECT table_name, table_comment as comment \
366 FROM information_schema.tables \
367 WHERE table_type = 'BASE TABLE' {} \
368 ORDER BY table_name",
369 schema_filter
370 )
371 }
372 DatabaseType::SQLite => "SELECT name as table_name, NULL as comment \
373 FROM sqlite_master \
374 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
375 ORDER BY name"
376 .to_string(),
377 DatabaseType::MSSQL => {
378 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
379 format!(
380 "SELECT t.name as table_name, ep.value as comment \
381 FROM sys.tables t \
382 LEFT JOIN sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' \
383 JOIN sys.schemas s ON t.schema_id = s.schema_id \
384 WHERE s.name = '{}' \
385 ORDER BY t.name",
386 schema_filter
387 )
388 }
389 }
390 }
391
392 pub fn columns_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
394 let table = escape_literal(table);
395 match db_type {
396 DatabaseType::PostgreSQL => {
397 let schema_filter = escape_literal(schema.unwrap_or("public"));
398 format!(
399 "SELECT \
400 c.column_name, \
401 c.data_type, \
402 c.udt_name, \
403 c.is_nullable = 'YES' as nullable, \
404 c.column_default, \
405 c.character_maximum_length, \
406 c.numeric_precision, \
407 c.numeric_scale, \
408 col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
409 CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment \
410 FROM information_schema.columns c \
411 WHERE c.table_schema = '{}' AND c.table_name = '{}' \
412 ORDER BY c.ordinal_position",
413 schema_filter, table
414 )
415 }
416 DatabaseType::MySQL => {
417 format!(
418 "SELECT \
419 column_name, \
420 data_type, \
421 column_type as udt_name, \
422 is_nullable = 'YES' as nullable, \
423 column_default, \
424 character_maximum_length, \
425 numeric_precision, \
426 numeric_scale, \
427 column_comment as comment, \
428 extra LIKE '%auto_increment%' as auto_increment \
429 FROM information_schema.columns \
430 WHERE table_name = '{}' {} \
431 ORDER BY ordinal_position",
432 table,
433 schema
434 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
435 .unwrap_or_default()
436 )
437 }
438 DatabaseType::SQLite => {
439 format!("PRAGMA table_info('{}')", table)
440 }
441 DatabaseType::MSSQL => {
442 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
443 format!(
444 "SELECT \
445 c.name as column_name, \
446 t.name as data_type, \
447 t.name as udt_name, \
448 c.is_nullable as nullable, \
449 dc.definition as column_default, \
450 c.max_length as character_maximum_length, \
451 c.precision as numeric_precision, \
452 c.scale as numeric_scale, \
453 ep.value as comment, \
454 c.is_identity as auto_increment \
455 FROM sys.columns c \
456 JOIN sys.types t ON c.user_type_id = t.user_type_id \
457 JOIN sys.tables tb ON c.object_id = tb.object_id \
458 JOIN sys.schemas s ON tb.schema_id = s.schema_id \
459 LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id \
460 LEFT JOIN sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description' \
461 WHERE tb.name = '{}' AND s.name = '{}' \
462 ORDER BY c.column_id",
463 table, schema_filter
464 )
465 }
466 }
467 }
468
469 pub fn primary_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
471 let table = escape_literal(table);
472 match db_type {
473 DatabaseType::PostgreSQL => {
474 let schema_filter = escape_literal(schema.unwrap_or("public"));
475 format!(
476 "SELECT a.attname as column_name \
477 FROM pg_index i \
478 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
479 JOIN pg_class c ON c.oid = i.indrelid \
480 JOIN pg_namespace n ON n.oid = c.relnamespace \
481 WHERE i.indisprimary AND c.relname = '{}' AND n.nspname = '{}' \
482 ORDER BY array_position(i.indkey, a.attnum)",
483 table, schema_filter
484 )
485 }
486 DatabaseType::MySQL => {
487 format!(
488 "SELECT column_name \
489 FROM information_schema.key_column_usage \
490 WHERE constraint_name = 'PRIMARY' AND table_name = '{}' {} \
491 ORDER BY ordinal_position",
492 table,
493 schema
494 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
495 .unwrap_or_default()
496 )
497 }
498 DatabaseType::SQLite => {
499 format!("PRAGMA table_info('{}')", table) }
501 DatabaseType::MSSQL => {
502 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
503 format!(
504 "SELECT c.name as column_name \
505 FROM sys.indexes i \
506 JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
507 JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
508 JOIN sys.tables t ON i.object_id = t.object_id \
509 JOIN sys.schemas s ON t.schema_id = s.schema_id \
510 WHERE i.is_primary_key = 1 AND t.name = '{}' AND s.name = '{}' \
511 ORDER BY ic.key_ordinal",
512 table, schema_filter
513 )
514 }
515 }
516 }
517
518 pub fn foreign_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
520 let table = escape_literal(table);
521 match db_type {
522 DatabaseType::PostgreSQL => {
523 let schema_filter = escape_literal(schema.unwrap_or("public"));
524 format!(
525 "SELECT \
526 tc.constraint_name, \
527 kcu.column_name, \
528 ccu.table_name as referenced_table, \
529 ccu.table_schema as referenced_schema, \
530 ccu.column_name as referenced_column, \
531 rc.delete_rule, \
532 rc.update_rule \
533 FROM information_schema.table_constraints tc \
534 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
535 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
536 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
537 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = '{}' AND tc.table_schema = '{}' \
538 ORDER BY tc.constraint_name, kcu.ordinal_position",
539 table, schema_filter
540 )
541 }
542 DatabaseType::MySQL => {
543 format!(
544 "SELECT \
545 constraint_name, \
546 column_name, \
547 referenced_table_name as referenced_table, \
548 referenced_table_schema as referenced_schema, \
549 referenced_column_name as referenced_column, \
550 'NO ACTION' as delete_rule, \
551 'NO ACTION' as update_rule \
552 FROM information_schema.key_column_usage \
553 WHERE referenced_table_name IS NOT NULL AND table_name = '{}' {} \
554 ORDER BY constraint_name, ordinal_position",
555 table,
556 schema
557 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
558 .unwrap_or_default()
559 )
560 }
561 DatabaseType::SQLite => {
562 format!("PRAGMA foreign_key_list('{}')", table)
563 }
564 DatabaseType::MSSQL => {
565 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
566 format!(
567 "SELECT \
568 fk.name as constraint_name, \
569 c.name as column_name, \
570 rt.name as referenced_table, \
571 rs.name as referenced_schema, \
572 rc.name as referenced_column, \
573 fk.delete_referential_action_desc as delete_rule, \
574 fk.update_referential_action_desc as update_rule \
575 FROM sys.foreign_keys fk \
576 JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
577 JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
578 JOIN sys.tables t ON fk.parent_object_id = t.object_id \
579 JOIN sys.schemas s ON t.schema_id = s.schema_id \
580 JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id \
581 JOIN sys.schemas rs ON rt.schema_id = rs.schema_id \
582 JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
583 WHERE t.name = '{}' AND s.name = '{}' \
584 ORDER BY fk.name",
585 table, schema_filter
586 )
587 }
588 }
589 }
590
591 pub fn indexes_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
593 let table = escape_literal(table);
594 match db_type {
595 DatabaseType::PostgreSQL => {
596 let schema_filter = escape_literal(schema.unwrap_or("public"));
597 format!(
598 "SELECT \
599 i.relname as index_name, \
600 a.attname as column_name, \
601 ix.indisunique as is_unique, \
602 ix.indisprimary as is_primary, \
603 am.amname as index_type, \
604 pg_get_expr(ix.indpred, ix.indrelid) as filter \
605 FROM pg_index ix \
606 JOIN pg_class t ON t.oid = ix.indrelid \
607 JOIN pg_class i ON i.oid = ix.indexrelid \
608 JOIN pg_namespace n ON n.oid = t.relnamespace \
609 JOIN pg_am am ON i.relam = am.oid \
610 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
611 WHERE t.relname = '{}' AND n.nspname = '{}' \
612 ORDER BY i.relname, array_position(ix.indkey, a.attnum)",
613 table, schema_filter
614 )
615 }
616 DatabaseType::MySQL => {
617 format!(
618 "SELECT \
619 index_name, \
620 column_name, \
621 NOT non_unique as is_unique, \
622 index_name = 'PRIMARY' as is_primary, \
623 index_type, \
624 NULL as filter \
625 FROM information_schema.statistics \
626 WHERE table_name = '{}' {} \
627 ORDER BY index_name, seq_in_index",
628 table,
629 schema
630 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
631 .unwrap_or_default()
632 )
633 }
634 DatabaseType::SQLite => {
635 format!("PRAGMA index_list('{}')", table)
636 }
637 DatabaseType::MSSQL => {
638 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
639 format!(
640 "SELECT \
641 i.name as index_name, \
642 c.name as column_name, \
643 i.is_unique, \
644 i.is_primary_key as is_primary, \
645 i.type_desc as index_type, \
646 i.filter_definition as filter \
647 FROM sys.indexes i \
648 JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
649 JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
650 JOIN sys.tables t ON i.object_id = t.object_id \
651 JOIN sys.schemas s ON t.schema_id = s.schema_id \
652 WHERE t.name = '{}' AND s.name = '{}' AND i.name IS NOT NULL \
653 ORDER BY i.name, ic.key_ordinal",
654 table, schema_filter
655 )
656 }
657 }
658 }
659
660 pub fn enums_query(schema: Option<&str>) -> String {
662 let schema_filter = escape_literal(schema.unwrap_or("public"));
663 format!(
664 "SELECT t.typname as enum_name, e.enumlabel as enum_value \
665 FROM pg_type t \
666 JOIN pg_enum e ON t.oid = e.enumtypid \
667 JOIN pg_namespace n ON n.oid = t.typnamespace \
668 WHERE n.nspname = '{}' \
669 ORDER BY t.typname, e.enumsortorder",
670 schema_filter
671 )
672 }
673
674 pub fn views_query(db_type: DatabaseType, schema: Option<&str>) -> String {
676 match db_type {
677 DatabaseType::PostgreSQL => {
678 let schema_filter = escape_literal(schema.unwrap_or("public"));
679 format!(
680 "SELECT table_name as view_name, view_definition, false as is_materialized \
681 FROM information_schema.views \
682 WHERE table_schema = '{}' \
683 UNION ALL \
684 SELECT matviewname as view_name, definition as view_definition, true as is_materialized \
685 FROM pg_matviews \
686 WHERE schemaname = '{}' \
687 ORDER BY view_name",
688 schema_filter, schema_filter
689 )
690 }
691 DatabaseType::MySQL => {
692 format!(
693 "SELECT table_name as view_name, view_definition, false as is_materialized \
694 FROM information_schema.views \
695 WHERE table_schema = '{}' \
696 ORDER BY view_name",
697 escape_literal(schema.unwrap_or("information_schema"))
698 )
699 }
700 DatabaseType::SQLite => {
701 "SELECT name as view_name, sql as view_definition, 0 as is_materialized \
702 FROM sqlite_master \
703 WHERE type = 'view' \
704 ORDER BY name"
705 .to_string()
706 }
707 DatabaseType::MSSQL => {
708 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
709 format!(
710 "SELECT v.name as view_name, m.definition as view_definition, \
711 CASE WHEN i.object_id IS NOT NULL THEN 1 ELSE 0 END as is_materialized \
712 FROM sys.views v \
713 JOIN sys.schemas s ON v.schema_id = s.schema_id \
714 JOIN sys.sql_modules m ON v.object_id = m.object_id \
715 LEFT JOIN sys.indexes i ON v.object_id = i.object_id AND i.index_id = 1 \
716 WHERE s.name = '{}' \
717 ORDER BY v.name",
718 schema_filter
719 )
720 }
721 }
722 }
723}
724
725pub fn normalize_type(
731 db_type: DatabaseType,
732 type_name: &str,
733 max_length: Option<i32>,
734 precision: Option<i32>,
735 scale: Option<i32>,
736) -> NormalizedType {
737 let type_lower = type_name.to_lowercase();
738
739 match db_type {
740 DatabaseType::PostgreSQL => {
741 normalize_postgres_type(&type_lower, max_length, precision, scale)
742 }
743 DatabaseType::MySQL => normalize_mysql_type(&type_lower, max_length, precision, scale),
744 DatabaseType::SQLite => normalize_sqlite_type(&type_lower),
745 DatabaseType::MSSQL => normalize_mssql_type(&type_lower, max_length, precision, scale),
746 }
747}
748
749fn normalize_postgres_type(
750 type_name: &str,
751 _max_length: Option<i32>,
752 precision: Option<i32>,
753 scale: Option<i32>,
754) -> NormalizedType {
755 match type_name {
756 "int2" | "smallint" | "smallserial" => NormalizedType::SmallInt,
757 "int4" | "integer" | "int" | "serial" => NormalizedType::Int,
758 "int8" | "bigint" | "bigserial" => NormalizedType::BigInt,
759 "real" | "float4" => NormalizedType::Float,
760 "double precision" | "float8" => NormalizedType::Double,
761 "numeric" | "decimal" => NormalizedType::Decimal { precision, scale },
762 "bool" | "boolean" => NormalizedType::Boolean,
763 "text" => NormalizedType::Text,
764 "varchar" | "character varying" => NormalizedType::VarChar {
765 length: _max_length,
766 },
767 "char" | "character" | "bpchar" => NormalizedType::Char {
768 length: _max_length,
769 },
770 "bytea" => NormalizedType::Bytes,
771 "timestamp" | "timestamp without time zone" => NormalizedType::Timestamp,
772 "timestamptz" | "timestamp with time zone" => NormalizedType::DateTime,
773 "date" => NormalizedType::Date,
774 "time" | "time without time zone" | "timetz" | "time with time zone" => {
775 NormalizedType::Time
776 }
777 "json" | "jsonb" => NormalizedType::Json,
778 "uuid" => NormalizedType::Uuid,
779 t if t.ends_with("[]") => {
780 let inner = normalize_postgres_type(&t[..t.len() - 2], None, None, None);
781 NormalizedType::Array(Box::new(inner))
782 }
783 t => NormalizedType::Unknown(t.to_string()),
784 }
785}
786
787fn normalize_mysql_type(
788 type_name: &str,
789 max_length: Option<i32>,
790 precision: Option<i32>,
791 scale: Option<i32>,
792) -> NormalizedType {
793 match type_name {
794 "tinyint" | "smallint" => NormalizedType::SmallInt,
795 "int" | "integer" | "mediumint" => NormalizedType::Int,
796 "bigint" => NormalizedType::BigInt,
797 "float" => NormalizedType::Float,
798 "double" | "real" => NormalizedType::Double,
799 "decimal" | "numeric" => NormalizedType::Decimal { precision, scale },
800 "bit" | "bool" | "boolean" => NormalizedType::Boolean,
801 "text" | "mediumtext" | "longtext" => NormalizedType::Text,
802 "varchar" => NormalizedType::VarChar { length: max_length },
803 "char" => NormalizedType::Char { length: max_length },
804 "tinyblob" | "blob" | "mediumblob" | "longblob" | "binary" | "varbinary" => {
805 NormalizedType::Bytes
806 }
807 "datetime" | "timestamp" => NormalizedType::DateTime,
808 "date" => NormalizedType::Date,
809 "time" => NormalizedType::Time,
810 "json" => NormalizedType::Json,
811 t if t.starts_with("enum(") => {
812 NormalizedType::Enum(t.to_string())
814 }
815 t => NormalizedType::Unknown(t.to_string()),
816 }
817}
818
819fn normalize_sqlite_type(type_name: &str) -> NormalizedType {
820 match type_name {
822 "integer" | "int" => NormalizedType::Int,
823 "real" | "float" | "double" => NormalizedType::Double,
824 "text" | "varchar" | "char" | "clob" => NormalizedType::Text,
825 "blob" => NormalizedType::Bytes,
826 "boolean" | "bool" => NormalizedType::Boolean,
827 "datetime" | "timestamp" | "date" | "time" => NormalizedType::DateTime,
828 t => NormalizedType::Unknown(t.to_string()),
829 }
830}
831
832fn normalize_mssql_type(
833 type_name: &str,
834 max_length: Option<i32>,
835 precision: Option<i32>,
836 scale: Option<i32>,
837) -> NormalizedType {
838 match type_name {
839 "tinyint" | "smallint" => NormalizedType::SmallInt,
840 "int" => NormalizedType::Int,
841 "bigint" => NormalizedType::BigInt,
842 "real" | "float" => NormalizedType::Float,
843 "decimal" | "numeric" | "money" | "smallmoney" => {
844 NormalizedType::Decimal { precision, scale }
845 }
846 "bit" => NormalizedType::Boolean,
847 "text" | "ntext" => NormalizedType::Text,
848 "varchar" | "nvarchar" => NormalizedType::VarChar { length: max_length },
849 "char" | "nchar" => NormalizedType::Char { length: max_length },
850 "binary" | "varbinary" | "image" => NormalizedType::Bytes,
851 "datetime" | "datetime2" | "datetimeoffset" | "smalldatetime" => NormalizedType::DateTime,
852 "date" => NormalizedType::Date,
853 "time" => NormalizedType::Time,
854 "uniqueidentifier" => NormalizedType::Uuid,
855 t => NormalizedType::Unknown(t.to_string()),
856 }
857}
858
859pub fn generate_prax_schema(db: &DatabaseSchema) -> String {
865 let mut output = String::new();
866
867 output.push_str("// Generated by Prax introspection\n");
869 output.push_str(&format!("// Database: {}\n\n", db.name));
870
871 for enum_info in &db.enums {
873 output.push_str(&generate_enum(enum_info));
874 output.push('\n');
875 }
876
877 for table in &db.tables {
879 output.push_str(&generate_model(table, &db.tables));
880 output.push('\n');
881 }
882
883 for view in &db.views {
885 output.push_str(&generate_view(view));
886 output.push('\n');
887 }
888
889 output
890}
891
892fn generate_enum(enum_info: &EnumInfo) -> String {
893 let mut output = format!("enum {} {{\n", enum_info.name);
894 for value in &enum_info.values {
895 output.push_str(&format!(" {}\n", value));
896 }
897 output.push_str("}\n");
898 output
899}
900
901fn generate_model(table: &TableInfo, all_tables: &[TableInfo]) -> String {
902 let mut output = String::new();
903
904 if let Some(ref comment) = table.comment {
906 output.push_str(&format!("/// {}\n", comment));
907 }
908
909 output.push_str(&format!("model {} {{\n", pascal_case(&table.name)));
910
911 for col in &table.columns {
913 output.push_str(&generate_field(col, &table.primary_key));
914 }
915
916 for fk in &table.foreign_keys {
918 output.push_str(&generate_relation(fk, all_tables));
919 }
920
921 let attrs = generate_model_attributes(table);
923 if !attrs.is_empty() {
924 output.push('\n');
925 output.push_str(&attrs);
926 }
927
928 output.push_str("}\n");
929 output
930}
931
932fn generate_field(col: &ColumnInfo, primary_key: &[String]) -> String {
933 let mut attrs = Vec::new();
934
935 if primary_key.contains(&col.name) {
937 attrs.push("@id".to_string());
938 }
939
940 if col.auto_increment {
942 attrs.push("@auto".to_string());
943 }
944
945 if col.is_unique && !primary_key.contains(&col.name) {
947 attrs.push("@unique".to_string());
948 }
949
950 if let Some(ref default) = col.default
952 && !col.auto_increment
953 {
954 let default_val = simplify_default(default);
955 attrs.push(format!("@default({})", default_val));
956 }
957
958 let field_name = camel_case(&col.name);
960 if field_name != col.name {
961 attrs.push(format!("@map(\"{}\")", col.name));
962 }
963
964 let type_str = col.normalized_type.to_prax_type();
966 let optional = if col.nullable { "?" } else { "" };
967
968 let attrs_str = if attrs.is_empty() {
969 String::new()
970 } else {
971 format!(" {}", attrs.join(" "))
972 };
973
974 format!(" {} {}{}{}\n", field_name, type_str, optional, attrs_str)
975}
976
977fn generate_relation(fk: &ForeignKeyInfo, all_tables: &[TableInfo]) -> String {
978 let _ref_table = all_tables.iter().find(|t| t.name == fk.referenced_table);
980 let ref_name = pascal_case(&fk.referenced_table);
981
982 let field_name = if fk.columns.len() == 1 {
983 let col = &fk.columns[0];
985 if col.ends_with("_id") {
986 camel_case(&col[..col.len() - 3])
987 } else {
988 camel_case(&fk.referenced_table)
989 }
990 } else {
991 camel_case(&fk.referenced_table)
992 };
993
994 let mut attrs = [format!(
995 "@relation(fields: [{}], references: [{}]",
996 fk.columns
997 .iter()
998 .map(|c| camel_case(c))
999 .collect::<Vec<_>>()
1000 .join(", "),
1001 fk.referenced_columns
1002 .iter()
1003 .map(|c| camel_case(c))
1004 .collect::<Vec<_>>()
1005 .join(", ")
1006 )];
1007
1008 if fk.on_delete != ReferentialAction::NoAction {
1010 attrs[0].push_str(&format!(", onDelete: {}", fk.on_delete.to_prax()));
1011 }
1012 if fk.on_update != ReferentialAction::NoAction {
1013 attrs[0].push_str(&format!(", onUpdate: {}", fk.on_update.to_prax()));
1014 }
1015
1016 attrs[0].push(')');
1017
1018 format!(" {} {} {}\n", field_name, ref_name, attrs.join(" "))
1019}
1020
1021fn generate_model_attributes(table: &TableInfo) -> String {
1022 let mut output = String::new();
1023
1024 let model_name = pascal_case(&table.name);
1026 if model_name.to_lowercase() != table.name.to_lowercase() {
1027 output.push_str(&format!(" @@map(\"{}\")\n", table.name));
1028 }
1029
1030 if table.primary_key.len() > 1 {
1032 let fields: Vec<_> = table.primary_key.iter().map(|c| camel_case(c)).collect();
1033 output.push_str(&format!(" @@id([{}])\n", fields.join(", ")));
1034 }
1035
1036 for idx in &table.indexes {
1038 if !idx.is_primary {
1039 let cols: Vec<_> = idx.columns.iter().map(|c| camel_case(&c.name)).collect();
1040 if idx.is_unique {
1041 output.push_str(&format!(" @@unique([{}])\n", cols.join(", ")));
1042 } else {
1043 output.push_str(&format!(" @@index([{}])\n", cols.join(", ")));
1044 }
1045 }
1046 }
1047
1048 output
1049}
1050
1051fn generate_view(view: &ViewInfo) -> String {
1052 let mut output = String::new();
1053
1054 let keyword = if view.is_materialized {
1055 "materializedView"
1056 } else {
1057 "view"
1058 };
1059 output.push_str(&format!("{} {} {{\n", keyword, pascal_case(&view.name)));
1060
1061 for col in &view.columns {
1062 let type_str = col.normalized_type.to_prax_type();
1063 let optional = if col.nullable { "?" } else { "" };
1064 output.push_str(&format!(
1065 " {} {}{}\n",
1066 camel_case(&col.name),
1067 type_str,
1068 optional
1069 ));
1070 }
1071
1072 if let Some(ref def) = view.definition {
1073 output.push_str(&format!("\n @@sql(\"{}\")\n", def.replace('"', "\\\"")));
1074 }
1075
1076 output.push_str("}\n");
1077 output
1078}
1079
1080pub mod mongodb {
1086 use serde_json::Value as JsonValue;
1087
1088 use super::{ColumnInfo, NormalizedType, TableInfo};
1089
1090 #[derive(Debug, Clone, Default)]
1092 pub struct SchemaInferrer {
1093 pub fields: std::collections::HashMap<String, FieldSchema>,
1095 pub samples: usize,
1097 }
1098
1099 #[derive(Debug, Clone, Default)]
1101 pub struct FieldSchema {
1102 pub name: String,
1104 pub types: Vec<String>,
1106 pub required: bool,
1108 pub nested: Option<Box<SchemaInferrer>>,
1110 pub array_type: Option<String>,
1112 }
1113
1114 impl SchemaInferrer {
1115 pub fn new() -> Self {
1117 Self::default()
1118 }
1119
1120 pub fn add_document(&mut self, doc: &JsonValue) {
1122 self.samples += 1;
1123
1124 if let Some(obj) = doc.as_object() {
1125 for (key, value) in obj {
1126 self.infer_field(key, value);
1127 }
1128 }
1129 }
1130
1131 fn infer_field(&mut self, name: &str, value: &JsonValue) {
1132 let field = self
1133 .fields
1134 .entry(name.to_string())
1135 .or_insert_with(|| FieldSchema {
1136 name: name.to_string(),
1137 required: true,
1138 ..Default::default()
1139 });
1140
1141 let type_name = match value {
1142 JsonValue::Null => "null",
1143 JsonValue::Bool(_) => "boolean",
1144 JsonValue::Number(n) if n.is_i64() => "int",
1145 JsonValue::Number(n) if n.is_f64() => "double",
1146 JsonValue::Number(_) => "number",
1147 JsonValue::String(s) => {
1148 if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
1150 "objectId"
1151 } else if is_iso_datetime(s) {
1152 "date"
1153 } else {
1154 "string"
1155 }
1156 }
1157 JsonValue::Array(arr) => {
1158 if let Some(first) = arr.first() {
1159 let elem_type = match first {
1160 JsonValue::Object(_) => "object",
1161 JsonValue::String(_) => "string",
1162 JsonValue::Number(_) => "number",
1163 JsonValue::Bool(_) => "boolean",
1164 _ => "mixed",
1165 };
1166 field.array_type = Some(elem_type.to_string());
1167 }
1168 "array"
1169 }
1170 JsonValue::Object(_) => {
1171 let mut nested = field.nested.take().unwrap_or_default();
1173 nested.add_document(value);
1174 field.nested = Some(nested);
1175 "object"
1176 }
1177 };
1178
1179 if !field.types.contains(&type_name.to_string()) {
1180 field.types.push(type_name.to_string());
1181 }
1182 }
1183
1184 pub fn to_table_info(&self, collection_name: &str) -> TableInfo {
1186 let mut columns = Vec::new();
1187
1188 for (name, field) in &self.fields {
1189 let normalized = infer_normalized_type(field);
1190 columns.push(ColumnInfo {
1191 name: name.clone(),
1192 db_type: field.types.join("|"),
1193 normalized_type: normalized,
1194 nullable: !field.required || field.types.contains(&"null".to_string()),
1195 is_primary_key: name == "_id",
1196 ..Default::default()
1197 });
1198 }
1199
1200 TableInfo {
1201 name: collection_name.to_string(),
1202 columns,
1203 primary_key: vec!["_id".to_string()],
1204 ..Default::default()
1205 }
1206 }
1207 }
1208
1209 fn infer_normalized_type(field: &FieldSchema) -> NormalizedType {
1210 if field.types.contains(&"objectId".to_string()) {
1212 NormalizedType::String } else if field.types.contains(&"date".to_string()) {
1214 NormalizedType::DateTime
1215 } else if field.types.contains(&"boolean".to_string()) {
1216 NormalizedType::Boolean
1217 } else if field.types.contains(&"int".to_string()) {
1218 NormalizedType::Int
1219 } else if field.types.contains(&"double".to_string())
1220 || field.types.contains(&"number".to_string())
1221 {
1222 NormalizedType::Double
1223 } else if field.types.contains(&"array".to_string()) {
1224 let inner = match field.array_type.as_deref() {
1225 Some("string") => NormalizedType::String,
1226 Some("number") => NormalizedType::Double,
1227 Some("boolean") => NormalizedType::Boolean,
1228 _ => NormalizedType::Json,
1229 };
1230 NormalizedType::Array(Box::new(inner))
1231 } else if field.types.contains(&"object".to_string()) {
1232 NormalizedType::Json
1233 } else if field.types.contains(&"string".to_string()) {
1234 NormalizedType::String
1235 } else {
1236 NormalizedType::Unknown(field.types.join("|"))
1237 }
1238 }
1239
1240 pub fn list_indexes_command(collection: &str) -> JsonValue {
1242 serde_json::json!({
1243 "listIndexes": collection
1244 })
1245 }
1246
1247 pub fn list_collections_command() -> JsonValue {
1249 serde_json::json!({
1250 "listCollections": 1
1251 })
1252 }
1253
1254 fn is_iso_datetime(s: &str) -> bool {
1256 if s.len() < 10 {
1258 return false;
1259 }
1260
1261 let bytes = s.as_bytes();
1262 bytes.get(4) == Some(&b'-')
1264 && bytes.get(7) == Some(&b'-')
1265 && bytes[0..4].iter().all(|b| b.is_ascii_digit())
1266 && bytes[5..7].iter().all(|b| b.is_ascii_digit())
1267 && bytes[8..10].iter().all(|b| b.is_ascii_digit())
1268 }
1269}
1270
1271fn pascal_case(s: &str) -> String {
1276 s.split('_')
1277 .map(|part| {
1278 let mut chars = part.chars();
1279 match chars.next() {
1280 None => String::new(),
1281 Some(c) => c.to_uppercase().chain(chars).collect(),
1282 }
1283 })
1284 .collect()
1285}
1286
1287fn camel_case(s: &str) -> String {
1288 let pascal = pascal_case(s);
1289 let mut chars = pascal.chars();
1290 match chars.next() {
1291 None => String::new(),
1292 Some(c) => c.to_lowercase().chain(chars).collect(),
1293 }
1294}
1295
1296fn simplify_default(default: &str) -> String {
1297 let d = default.trim();
1299
1300 if d.eq_ignore_ascii_case("now()") || d.eq_ignore_ascii_case("current_timestamp") {
1301 return "now()".to_string();
1302 }
1303
1304 if d.starts_with("'") && d.ends_with("'") {
1305 return format!("\"{}\"", &d[1..d.len() - 1]);
1306 }
1307
1308 if d.eq_ignore_ascii_case("true") || d.eq_ignore_ascii_case("false") {
1309 return d.to_lowercase();
1310 }
1311
1312 if d.parse::<i64>().is_ok() || d.parse::<f64>().is_ok() {
1313 return d.to_string();
1314 }
1315
1316 format!("dbgenerated(\"{}\")", d.replace('"', "\\\""))
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322
1323 #[test]
1324 fn test_pascal_case() {
1325 assert_eq!(pascal_case("user_profile"), "UserProfile");
1326 assert_eq!(pascal_case("id"), "Id");
1327 assert_eq!(pascal_case("created_at"), "CreatedAt");
1328 }
1329
1330 #[test]
1331 fn test_camel_case() {
1332 assert_eq!(camel_case("user_profile"), "userProfile");
1333 assert_eq!(camel_case("ID"), "iD");
1334 assert_eq!(camel_case("created_at"), "createdAt");
1335 }
1336
1337 #[test]
1338 fn test_normalize_postgres_type() {
1339 assert_eq!(
1340 normalize_postgres_type("int4", None, None, None),
1341 NormalizedType::Int
1342 );
1343 assert_eq!(
1344 normalize_postgres_type("bigint", None, None, None),
1345 NormalizedType::BigInt
1346 );
1347 assert_eq!(
1348 normalize_postgres_type("text", None, None, None),
1349 NormalizedType::Text
1350 );
1351 assert_eq!(
1352 normalize_postgres_type("timestamptz", None, None, None),
1353 NormalizedType::DateTime
1354 );
1355 assert_eq!(
1356 normalize_postgres_type("jsonb", None, None, None),
1357 NormalizedType::Json
1358 );
1359 assert_eq!(
1360 normalize_postgres_type("uuid", None, None, None),
1361 NormalizedType::Uuid
1362 );
1363 }
1364
1365 #[test]
1366 fn test_normalize_mysql_type() {
1367 assert_eq!(
1368 normalize_mysql_type("int", None, None, None),
1369 NormalizedType::Int
1370 );
1371 assert_eq!(
1372 normalize_mysql_type("varchar", Some(255), None, None),
1373 NormalizedType::VarChar { length: Some(255) }
1374 );
1375 assert_eq!(
1376 normalize_mysql_type("datetime", None, None, None),
1377 NormalizedType::DateTime
1378 );
1379 }
1380
1381 #[test]
1382 fn test_referential_action() {
1383 assert_eq!(
1384 ReferentialAction::from_str("CASCADE"),
1385 ReferentialAction::Cascade
1386 );
1387 assert_eq!(
1388 ReferentialAction::from_str("SET NULL"),
1389 ReferentialAction::SetNull
1390 );
1391 assert_eq!(
1392 ReferentialAction::from_str("NO ACTION"),
1393 ReferentialAction::NoAction
1394 );
1395 }
1396
1397 #[test]
1398 fn test_generate_simple_model() {
1399 let table = TableInfo {
1400 name: "users".to_string(),
1401 columns: vec![
1402 ColumnInfo {
1403 name: "id".to_string(),
1404 normalized_type: NormalizedType::Int,
1405 auto_increment: true,
1406 ..Default::default()
1407 },
1408 ColumnInfo {
1409 name: "email".to_string(),
1410 normalized_type: NormalizedType::String,
1411 is_unique: true,
1412 ..Default::default()
1413 },
1414 ColumnInfo {
1415 name: "created_at".to_string(),
1416 normalized_type: NormalizedType::DateTime,
1417 nullable: true,
1418 default: Some("now()".to_string()),
1419 ..Default::default()
1420 },
1421 ],
1422 primary_key: vec!["id".to_string()],
1423 ..Default::default()
1424 };
1425
1426 let schema = generate_model(&table, &[]);
1427 assert!(schema.contains("model Users"));
1428 assert!(schema.contains("id Int @id @auto"));
1429 assert!(schema.contains("email String @unique"));
1430 assert!(schema.contains("createdAt DateTime?"));
1431 }
1432
1433 #[test]
1434 fn test_simplify_default() {
1435 assert_eq!(simplify_default("NOW()"), "now()");
1436 assert_eq!(simplify_default("CURRENT_TIMESTAMP"), "now()");
1437 assert_eq!(simplify_default("'hello'"), "\"hello\"");
1438 assert_eq!(simplify_default("42"), "42");
1439 assert_eq!(simplify_default("true"), "true");
1440 }
1441
1442 #[test]
1443 fn test_queries_tables() {
1444 let pg = queries::tables_query(DatabaseType::PostgreSQL, Some("public"));
1445 assert!(pg.contains("information_schema.tables"));
1446 assert!(pg.contains("public"));
1447
1448 let mysql = queries::tables_query(DatabaseType::MySQL, None);
1449 assert!(mysql.contains("information_schema.tables"));
1450
1451 let sqlite = queries::tables_query(DatabaseType::SQLite, None);
1452 assert!(sqlite.contains("sqlite_master"));
1453 }
1454
1455 #[test]
1456 fn test_escape_literal() {
1457 assert_eq!(escape_literal("public"), "public");
1458 assert_eq!(escape_literal("o'brien"), "o''brien");
1459 assert_eq!(
1460 escape_literal("'; DROP TABLE users; --"),
1461 "''; DROP TABLE users; --"
1462 );
1463 }
1464
1465 #[test]
1466 fn test_queries_escape_interpolated_names() {
1467 let sql = queries::columns_query(DatabaseType::SQLite, "we'ird", None);
1469 assert!(sql.contains("PRAGMA table_info('we''ird')"), "got: {sql}");
1470
1471 let sql = queries::tables_query(DatabaseType::MySQL, Some("my'schema"));
1473 assert!(
1474 sql.contains("AND table_schema = 'my''schema'"),
1475 "got: {sql}"
1476 );
1477 let sql = queries::tables_query(DatabaseType::PostgreSQL, Some("my'schema"));
1478 assert!(sql.contains("table_schema = 'my''schema'"), "got: {sql}");
1479 }
1480
1481 mod mongodb_tests {
1482 use super::super::mongodb::*;
1483
1484 #[test]
1485 fn test_schema_inferrer() {
1486 let mut inferrer = SchemaInferrer::new();
1487
1488 inferrer.add_document(&serde_json::json!({
1489 "_id": "507f1f77bcf86cd799439011",
1490 "name": "Alice",
1491 "age": 30,
1492 "active": true
1493 }));
1494
1495 inferrer.add_document(&serde_json::json!({
1496 "_id": "507f1f77bcf86cd799439012",
1497 "name": "Bob",
1498 "age": 25,
1499 "active": false,
1500 "email": "bob@example.com"
1501 }));
1502
1503 let table = inferrer.to_table_info("users");
1504 assert_eq!(table.name, "users");
1505 assert!(table.columns.iter().any(|c| c.name == "_id"));
1506 assert!(table.columns.iter().any(|c| c.name == "name"));
1507 assert!(table.columns.iter().any(|c| c.name == "age"));
1508 }
1509 }
1510}