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) => pascal_case(name),
175 Self::Unknown(t) => format!("Unsupported<{}>", t),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct ForeignKeyInfo {
183 pub name: String,
185 pub columns: Vec<String>,
187 pub referenced_table: String,
189 pub referenced_schema: Option<String>,
191 pub referenced_columns: Vec<String>,
193 pub on_delete: ReferentialAction,
195 pub on_update: ReferentialAction,
197}
198
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub enum ReferentialAction {
202 #[default]
203 NoAction,
204 Restrict,
205 Cascade,
206 SetNull,
207 SetDefault,
208}
209
210impl ReferentialAction {
211 pub fn to_prax(&self) -> &'static str {
213 match self {
214 Self::NoAction => "NoAction",
215 Self::Restrict => "Restrict",
216 Self::Cascade => "Cascade",
217 Self::SetNull => "SetNull",
218 Self::SetDefault => "SetDefault",
219 }
220 }
221
222 pub fn from_str(s: &str) -> Self {
224 match s.to_uppercase().as_str() {
225 "NO ACTION" | "NOACTION" => Self::NoAction,
226 "RESTRICT" => Self::Restrict,
227 "CASCADE" => Self::Cascade,
228 "SET NULL" | "SETNULL" => Self::SetNull,
229 "SET DEFAULT" | "SETDEFAULT" => Self::SetDefault,
230 _ => Self::NoAction,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct IndexInfo {
238 pub name: String,
240 pub columns: Vec<IndexColumn>,
242 pub is_unique: bool,
244 pub is_primary: bool,
246 pub index_type: Option<String>,
248 pub filter: Option<String>,
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct IndexColumn {
255 pub name: String,
257 pub order: SortOrder,
259 pub nulls: NullsOrder,
261}
262
263#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
265pub enum SortOrder {
266 #[default]
267 Asc,
268 Desc,
269}
270
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273pub enum NullsOrder {
274 #[default]
275 Last,
276 First,
277}
278
279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct UniqueConstraint {
282 pub name: String,
284 pub columns: Vec<String>,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct CheckConstraint {
291 pub name: String,
293 pub expression: String,
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct ViewInfo {
300 pub name: String,
302 pub schema: Option<String>,
304 pub definition: Option<String>,
306 pub is_materialized: bool,
308 pub columns: Vec<ColumnInfo>,
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct EnumInfo {
315 pub name: String,
317 pub schema: Option<String>,
319 pub values: Vec<String>,
321}
322
323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325pub struct SequenceInfo {
326 pub name: String,
328 pub schema: Option<String>,
330 pub start: i64,
332 pub increment: i64,
334 pub min_value: Option<i64>,
336 pub max_value: Option<i64>,
338 pub cycle: bool,
340}
341
342pub mod queries {
348 use super::*;
349
350 pub fn tables_query(db_type: DatabaseType, schema: Option<&str>) -> String {
352 match db_type {
353 DatabaseType::PostgreSQL => {
354 let schema_filter = escape_literal(schema.unwrap_or("public"));
355 format!(
356 "SELECT table_name, obj_description((quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass) as comment \
357 FROM information_schema.tables \
358 WHERE table_schema = '{}' AND table_type = 'BASE TABLE' \
359 ORDER BY table_name",
360 schema_filter
361 )
362 }
363 DatabaseType::MySQL => {
364 let schema_filter = schema
365 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
366 .unwrap_or_default();
367 format!(
368 "SELECT table_name, table_comment as comment \
369 FROM information_schema.tables \
370 WHERE table_type = 'BASE TABLE' {} \
371 ORDER BY table_name",
372 schema_filter
373 )
374 }
375 DatabaseType::SQLite => "SELECT name as table_name, NULL as comment \
376 FROM sqlite_master \
377 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
378 ORDER BY name"
379 .to_string(),
380 DatabaseType::MSSQL => {
381 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
382 format!(
383 "SELECT t.name as table_name, CAST(ep.value AS NVARCHAR(MAX)) as comment \
384 FROM sys.tables t \
385 LEFT JOIN sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' \
386 JOIN sys.schemas s ON t.schema_id = s.schema_id \
387 WHERE s.name = '{}' \
388 ORDER BY t.name",
389 schema_filter
390 )
391 }
392 }
393 }
394
395 pub fn columns_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
397 let table = escape_literal(table);
398 match db_type {
399 DatabaseType::PostgreSQL => {
400 let schema_filter = escape_literal(schema.unwrap_or("public"));
401 format!(
402 "SELECT \
403 c.column_name, \
404 c.data_type, \
405 c.udt_name, \
406 c.is_nullable = 'YES' as nullable, \
407 c.column_default, \
408 c.character_maximum_length, \
409 c.numeric_precision, \
410 c.numeric_scale, \
411 col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
412 CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment \
413 FROM information_schema.columns c \
414 WHERE c.table_schema = '{}' AND c.table_name = '{}' \
415 ORDER BY c.ordinal_position",
416 schema_filter, table
417 )
418 }
419 DatabaseType::MySQL => {
420 format!(
421 "SELECT \
422 column_name, \
423 data_type, \
424 column_type as udt_name, \
425 is_nullable = 'YES' as nullable, \
426 column_default, \
427 character_maximum_length, \
428 numeric_precision, \
429 numeric_scale, \
430 column_comment as comment, \
431 extra LIKE '%auto_increment%' as auto_increment \
432 FROM information_schema.columns \
433 WHERE table_name = '{}' {} \
434 ORDER BY ordinal_position",
435 table,
436 schema
437 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
438 .unwrap_or_default()
439 )
440 }
441 DatabaseType::SQLite => {
442 format!("PRAGMA table_info('{}')", table)
443 }
444 DatabaseType::MSSQL => {
445 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
446 format!(
447 "SELECT \
448 c.name as column_name, \
449 t.name as data_type, \
450 t.name as udt_name, \
451 c.is_nullable as nullable, \
452 dc.definition as column_default, \
453 c.max_length as character_maximum_length, \
454 c.precision as numeric_precision, \
455 c.scale as numeric_scale, \
456 CAST(ep.value AS NVARCHAR(MAX)) as comment, \
457 c.is_identity as auto_increment \
458 FROM sys.columns c \
459 JOIN sys.types t ON c.user_type_id = t.user_type_id \
460 JOIN sys.tables tb ON c.object_id = tb.object_id \
461 JOIN sys.schemas s ON tb.schema_id = s.schema_id \
462 LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id \
463 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' \
464 WHERE tb.name = '{}' AND s.name = '{}' \
465 ORDER BY c.column_id",
466 table, schema_filter
467 )
468 }
469 }
470 }
471
472 pub fn primary_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
474 let table = escape_literal(table);
475 match db_type {
476 DatabaseType::PostgreSQL => {
477 let schema_filter = escape_literal(schema.unwrap_or("public"));
478 format!(
479 "SELECT a.attname as column_name \
480 FROM pg_index i \
481 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
482 JOIN pg_class c ON c.oid = i.indrelid \
483 JOIN pg_namespace n ON n.oid = c.relnamespace \
484 WHERE i.indisprimary AND c.relname = '{}' AND n.nspname = '{}' \
485 ORDER BY array_position(i.indkey, a.attnum)",
486 table, schema_filter
487 )
488 }
489 DatabaseType::MySQL => {
490 format!(
491 "SELECT column_name \
492 FROM information_schema.key_column_usage \
493 WHERE constraint_name = 'PRIMARY' AND table_name = '{}' {} \
494 ORDER BY ordinal_position",
495 table,
496 schema
497 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
498 .unwrap_or_default()
499 )
500 }
501 DatabaseType::SQLite => {
502 format!("PRAGMA table_info('{}')", table) }
504 DatabaseType::MSSQL => {
505 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
506 format!(
507 "SELECT c.name as column_name \
508 FROM sys.indexes i \
509 JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
510 JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
511 JOIN sys.tables t ON i.object_id = t.object_id \
512 JOIN sys.schemas s ON t.schema_id = s.schema_id \
513 WHERE i.is_primary_key = 1 AND t.name = '{}' AND s.name = '{}' \
514 ORDER BY ic.key_ordinal",
515 table, schema_filter
516 )
517 }
518 }
519 }
520
521 pub fn foreign_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
523 let table = escape_literal(table);
524 match db_type {
525 DatabaseType::PostgreSQL => {
526 let schema_filter = escape_literal(schema.unwrap_or("public"));
527 format!(
528 "SELECT \
529 tc.constraint_name, \
530 kcu.column_name, \
531 ccu.table_name as referenced_table, \
532 ccu.table_schema as referenced_schema, \
533 ccu.column_name as referenced_column, \
534 rc.delete_rule, \
535 rc.update_rule \
536 FROM information_schema.table_constraints tc \
537 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
538 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
539 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
540 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = '{}' AND tc.table_schema = '{}' \
541 ORDER BY tc.constraint_name, kcu.ordinal_position",
542 table, schema_filter
543 )
544 }
545 DatabaseType::MySQL => {
546 format!(
547 "SELECT \
548 constraint_name, \
549 column_name, \
550 referenced_table_name as referenced_table, \
551 referenced_table_schema as referenced_schema, \
552 referenced_column_name as referenced_column, \
553 'NO ACTION' as delete_rule, \
554 'NO ACTION' as update_rule \
555 FROM information_schema.key_column_usage \
556 WHERE referenced_table_name IS NOT NULL AND table_name = '{}' {} \
557 ORDER BY constraint_name, ordinal_position",
558 table,
559 schema
560 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
561 .unwrap_or_default()
562 )
563 }
564 DatabaseType::SQLite => {
565 format!("PRAGMA foreign_key_list('{}')", table)
566 }
567 DatabaseType::MSSQL => {
568 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
569 format!(
570 "SELECT \
571 fk.name as constraint_name, \
572 c.name as column_name, \
573 rt.name as referenced_table, \
574 rs.name as referenced_schema, \
575 rc.name as referenced_column, \
576 fk.delete_referential_action_desc as delete_rule, \
577 fk.update_referential_action_desc as update_rule \
578 FROM sys.foreign_keys fk \
579 JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
580 JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
581 JOIN sys.tables t ON fk.parent_object_id = t.object_id \
582 JOIN sys.schemas s ON t.schema_id = s.schema_id \
583 JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id \
584 JOIN sys.schemas rs ON rt.schema_id = rs.schema_id \
585 JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
586 WHERE t.name = '{}' AND s.name = '{}' \
587 ORDER BY fk.name",
588 table, schema_filter
589 )
590 }
591 }
592 }
593
594 pub fn indexes_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
596 let table = escape_literal(table);
597 match db_type {
598 DatabaseType::PostgreSQL => {
599 let schema_filter = escape_literal(schema.unwrap_or("public"));
600 format!(
601 "SELECT \
602 i.relname as index_name, \
603 a.attname as column_name, \
604 ix.indisunique as is_unique, \
605 ix.indisprimary as is_primary, \
606 am.amname as index_type, \
607 pg_get_expr(ix.indpred, ix.indrelid) as filter \
608 FROM pg_index ix \
609 JOIN pg_class t ON t.oid = ix.indrelid \
610 JOIN pg_class i ON i.oid = ix.indexrelid \
611 JOIN pg_namespace n ON n.oid = t.relnamespace \
612 JOIN pg_am am ON i.relam = am.oid \
613 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
614 WHERE t.relname = '{}' AND n.nspname = '{}' \
615 ORDER BY i.relname, array_position(ix.indkey, a.attnum)",
616 table, schema_filter
617 )
618 }
619 DatabaseType::MySQL => {
620 format!(
621 "SELECT \
622 index_name, \
623 column_name, \
624 NOT non_unique as is_unique, \
625 index_name = 'PRIMARY' as is_primary, \
626 index_type, \
627 NULL as filter \
628 FROM information_schema.statistics \
629 WHERE table_name = '{}' {} \
630 ORDER BY index_name, seq_in_index",
631 table,
632 schema
633 .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
634 .unwrap_or_default()
635 )
636 }
637 DatabaseType::SQLite => {
638 format!("PRAGMA index_list('{}')", table)
639 }
640 DatabaseType::MSSQL => {
641 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
642 format!(
643 "SELECT \
644 i.name as index_name, \
645 c.name as column_name, \
646 i.is_unique, \
647 i.is_primary_key as is_primary, \
648 i.type_desc as index_type, \
649 i.filter_definition as filter \
650 FROM sys.indexes i \
651 JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
652 JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
653 JOIN sys.tables t ON i.object_id = t.object_id \
654 JOIN sys.schemas s ON t.schema_id = s.schema_id \
655 WHERE t.name = '{}' AND s.name = '{}' AND i.name IS NOT NULL \
656 ORDER BY i.name, ic.key_ordinal",
657 table, schema_filter
658 )
659 }
660 }
661 }
662
663 pub fn enums_query(schema: Option<&str>) -> String {
665 let schema_filter = escape_literal(schema.unwrap_or("public"));
666 format!(
667 "SELECT t.typname as enum_name, e.enumlabel as enum_value \
668 FROM pg_type t \
669 JOIN pg_enum e ON t.oid = e.enumtypid \
670 JOIN pg_namespace n ON n.oid = t.typnamespace \
671 WHERE n.nspname = '{}' \
672 ORDER BY t.typname, e.enumsortorder",
673 schema_filter
674 )
675 }
676
677 pub fn views_query(db_type: DatabaseType, schema: Option<&str>) -> String {
679 match db_type {
680 DatabaseType::PostgreSQL => {
681 let schema_filter = escape_literal(schema.unwrap_or("public"));
682 format!(
683 "SELECT table_name as view_name, view_definition, false as is_materialized \
684 FROM information_schema.views \
685 WHERE table_schema = '{}' \
686 UNION ALL \
687 SELECT matviewname as view_name, definition as view_definition, true as is_materialized \
688 FROM pg_matviews \
689 WHERE schemaname = '{}' \
690 ORDER BY view_name",
691 schema_filter, schema_filter
692 )
693 }
694 DatabaseType::MySQL => {
695 format!(
696 "SELECT table_name as view_name, view_definition, false as is_materialized \
697 FROM information_schema.views \
698 WHERE table_schema = '{}' \
699 ORDER BY view_name",
700 escape_literal(schema.unwrap_or("information_schema"))
701 )
702 }
703 DatabaseType::SQLite => {
704 "SELECT name as view_name, sql as view_definition, 0 as is_materialized \
705 FROM sqlite_master \
706 WHERE type = 'view' \
707 ORDER BY name"
708 .to_string()
709 }
710 DatabaseType::MSSQL => {
711 let schema_filter = escape_literal(schema.unwrap_or("dbo"));
712 format!(
713 "SELECT v.name as view_name, m.definition as view_definition, \
714 CASE WHEN i.object_id IS NOT NULL THEN 1 ELSE 0 END as is_materialized \
715 FROM sys.views v \
716 JOIN sys.schemas s ON v.schema_id = s.schema_id \
717 JOIN sys.sql_modules m ON v.object_id = m.object_id \
718 LEFT JOIN sys.indexes i ON v.object_id = i.object_id AND i.index_id = 1 \
719 WHERE s.name = '{}' \
720 ORDER BY v.name",
721 schema_filter
722 )
723 }
724 }
725 }
726}
727
728pub fn normalize_type(
745 db_type: DatabaseType,
746 type_name: &str,
747 max_length: Option<i32>,
748 precision: Option<i32>,
749 scale: Option<i32>,
750) -> NormalizedType {
751 let type_lower = type_name.to_lowercase();
752
753 match db_type {
754 DatabaseType::PostgreSQL => {
755 normalize_postgres_type(&type_lower, max_length, precision, scale)
756 }
757 DatabaseType::MySQL => normalize_mysql_type(&type_lower, max_length, precision, scale),
758 DatabaseType::SQLite => normalize_sqlite_type(&type_lower),
759 DatabaseType::MSSQL => normalize_mssql_type(&type_lower, max_length, precision, scale),
760 }
761}
762
763fn normalize_postgres_type(
764 type_name: &str,
765 _max_length: Option<i32>,
766 precision: Option<i32>,
767 scale: Option<i32>,
768) -> NormalizedType {
769 match type_name {
770 "int2" | "smallint" | "smallserial" => NormalizedType::SmallInt,
771 "int4" | "integer" | "int" | "serial" => NormalizedType::Int,
772 "int8" | "bigint" | "bigserial" => NormalizedType::BigInt,
773 "real" | "float4" => NormalizedType::Float,
774 "double precision" | "float8" => NormalizedType::Double,
775 "numeric" | "decimal" => NormalizedType::Decimal { precision, scale },
776 "bool" | "boolean" => NormalizedType::Boolean,
777 "text" => NormalizedType::Text,
778 "varchar" | "character varying" => NormalizedType::VarChar {
779 length: _max_length,
780 },
781 "char" | "character" | "bpchar" => NormalizedType::Char {
782 length: _max_length,
783 },
784 "bytea" => NormalizedType::Bytes,
785 "timestamp" | "timestamp without time zone" => NormalizedType::Timestamp,
786 "timestamptz" | "timestamp with time zone" => NormalizedType::DateTime,
787 "date" => NormalizedType::Date,
788 "time" | "time without time zone" | "timetz" | "time with time zone" => {
789 NormalizedType::Time
790 }
791 "json" | "jsonb" => NormalizedType::Json,
792 "uuid" => NormalizedType::Uuid,
793 t if t.ends_with("[]") => {
794 let inner = normalize_postgres_type(&t[..t.len() - 2], None, None, None);
795 NormalizedType::Array(Box::new(inner))
796 }
797 t => NormalizedType::Unknown(t.to_string()),
798 }
799}
800
801fn normalize_mysql_type(
802 type_name: &str,
803 max_length: Option<i32>,
804 precision: Option<i32>,
805 scale: Option<i32>,
806) -> NormalizedType {
807 match type_name {
808 "tinyint" | "smallint" => NormalizedType::SmallInt,
809 "int" | "integer" | "mediumint" => NormalizedType::Int,
810 "bigint" => NormalizedType::BigInt,
811 "float" => NormalizedType::Float,
812 "double" | "real" => NormalizedType::Double,
813 "decimal" | "numeric" => NormalizedType::Decimal { precision, scale },
814 "bit" | "bool" | "boolean" => NormalizedType::Boolean,
815 "text" | "mediumtext" | "longtext" => NormalizedType::Text,
816 "varchar" => NormalizedType::VarChar { length: max_length },
817 "char" => NormalizedType::Char { length: max_length },
818 "tinyblob" | "blob" | "mediumblob" | "longblob" | "binary" | "varbinary" => {
819 NormalizedType::Bytes
820 }
821 "datetime" | "timestamp" => NormalizedType::DateTime,
822 "date" => NormalizedType::Date,
823 "time" => NormalizedType::Time,
824 "json" => NormalizedType::Json,
825 t => NormalizedType::Unknown(t.to_string()),
835 }
836}
837
838pub fn parse_mysql_enum_values(column_type: &str) -> Vec<String> {
842 let trimmed = column_type.trim();
843 let inner = match trimmed.get(..5) {
850 Some(prefix) if prefix.eq_ignore_ascii_case("enum(") => {
851 trimmed[5..].strip_suffix(')').unwrap_or("")
852 }
853 _ => "",
854 };
855
856 let mut values = Vec::new();
857 let mut chars = inner.chars().peekable();
858 while let Some(c) = chars.next() {
859 if c != '\'' {
860 continue;
861 }
862 let mut value = String::new();
863 while let Some(next) = chars.next() {
864 if next == '\'' {
865 if chars.peek() == Some(&'\'') {
866 value.push('\'');
867 chars.next();
868 continue;
869 }
870 break;
871 }
872 value.push(next);
873 }
874 values.push(value);
875 }
876 values
877}
878
879fn normalize_sqlite_type(type_name: &str) -> NormalizedType {
880 match type_name {
882 "integer" | "int" => NormalizedType::Int,
883 "real" | "float" | "double" => NormalizedType::Double,
884 "text" | "varchar" | "char" | "clob" => NormalizedType::Text,
885 "blob" => NormalizedType::Bytes,
886 "boolean" | "bool" => NormalizedType::Boolean,
887 "datetime" | "timestamp" | "date" | "time" => NormalizedType::DateTime,
888 t => NormalizedType::Unknown(t.to_string()),
889 }
890}
891
892fn normalize_mssql_type(
893 type_name: &str,
894 max_length: Option<i32>,
895 precision: Option<i32>,
896 scale: Option<i32>,
897) -> NormalizedType {
898 match type_name {
899 "tinyint" | "smallint" => NormalizedType::SmallInt,
900 "int" => NormalizedType::Int,
901 "bigint" => NormalizedType::BigInt,
902 "real" | "float" => NormalizedType::Float,
903 "decimal" | "numeric" | "money" | "smallmoney" => {
904 NormalizedType::Decimal { precision, scale }
905 }
906 "bit" => NormalizedType::Boolean,
907 "text" | "ntext" => NormalizedType::Text,
908 "varchar" | "nvarchar" => NormalizedType::VarChar { length: max_length },
909 "char" | "nchar" => NormalizedType::Char { length: max_length },
910 "binary" | "varbinary" | "image" => NormalizedType::Bytes,
911 "datetime" | "datetime2" | "datetimeoffset" | "smalldatetime" => NormalizedType::DateTime,
912 "date" => NormalizedType::Date,
913 "time" => NormalizedType::Time,
914 "uniqueidentifier" => NormalizedType::Uuid,
915 t => NormalizedType::Unknown(t.to_string()),
916 }
917}
918
919pub fn generate_prax_schema(db: &DatabaseSchema) -> String {
925 let mut output = String::new();
926
927 output.push_str("// Generated by Prax introspection\n");
929 output.push_str(&format!("// Database: {}\n\n", db.name));
930
931 for enum_info in &db.enums {
933 output.push_str(&generate_enum(enum_info));
934 output.push('\n');
935 }
936
937 for table in &db.tables {
939 output.push_str(&generate_model(table, &db.tables));
940 output.push('\n');
941 }
942
943 for view in &db.views {
945 output.push_str(&generate_view(view));
946 output.push('\n');
947 }
948
949 output
950}
951
952fn generate_enum(enum_info: &EnumInfo) -> String {
953 let mut output = format!("enum {} {{\n", pascal_case(&enum_info.name));
958 let sanitized = sanitize_variants(&enum_info.values);
959 for (raw, value) in enum_info.values.iter().zip(sanitized) {
960 if value == *raw {
967 output.push_str(&format!(" {}\n", value));
968 } else {
969 output.push_str(&format!(
970 " {} @map(\"{}\")\n",
971 value,
972 escape_map_value(raw)
973 ));
974 }
975 }
976 output.push_str(&format!(
984 " @@map(\"{}\")\n",
985 escape_map_value(&enum_info.name)
986 ));
987 output.push_str("}\n");
988 output
989}
990
991pub fn sanitize_variants(values: &[String]) -> Vec<String> {
1001 let mut seen = std::collections::HashSet::with_capacity(values.len());
1002 values
1003 .iter()
1004 .map(|raw| disambiguate(&sanitize_identifier(raw), |c| c.to_string(), &mut seen))
1005 .collect()
1006}
1007
1008pub fn disambiguate(
1016 base: &str,
1017 mut key: impl FnMut(&str) -> String,
1018 used_keys: &mut std::collections::HashSet<String>,
1019) -> String {
1020 let mut suffix = 2;
1021 let mut candidate = base.to_string();
1022 loop {
1023 if used_keys.insert(key(&candidate)) {
1024 return candidate;
1025 }
1026 candidate = format!("{}_{}", base, suffix);
1027 suffix += 1;
1028 }
1029}
1030
1031pub fn sanitize_identifier(raw: &str) -> String {
1042 let mapped: String = raw
1043 .chars()
1044 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1045 .collect();
1046 match mapped.chars().next() {
1047 Some(c) if c.is_ascii_alphabetic() => mapped,
1048 Some(_) => format!("V{}", mapped),
1049 None => "V".to_string(),
1050 }
1051}
1052
1053fn escape_map_value(raw: &str) -> String {
1061 raw.replace('\\', "\\\\").replace('"', "\\\"")
1062}
1063
1064fn generate_model(table: &TableInfo, all_tables: &[TableInfo]) -> String {
1065 let mut output = String::new();
1066
1067 if let Some(ref comment) = table.comment {
1069 output.push_str(&format!("/// {}\n", comment));
1070 }
1071
1072 output.push_str(&format!("model {} {{\n", pascal_case(&table.name)));
1073
1074 for col in &table.columns {
1076 output.push_str(&generate_field(col, &table.primary_key));
1077 }
1078
1079 for fk in &table.foreign_keys {
1081 output.push_str(&generate_relation(fk, all_tables));
1082 }
1083
1084 let attrs = generate_model_attributes(table);
1086 if !attrs.is_empty() {
1087 output.push('\n');
1088 output.push_str(&attrs);
1089 }
1090
1091 output.push_str("}\n");
1092 output
1093}
1094
1095fn generate_field(col: &ColumnInfo, primary_key: &[String]) -> String {
1096 let mut attrs = Vec::new();
1097
1098 if primary_key.contains(&col.name) {
1100 attrs.push("@id".to_string());
1101 }
1102
1103 if col.auto_increment {
1105 attrs.push("@auto".to_string());
1106 }
1107
1108 if col.is_unique && !primary_key.contains(&col.name) {
1110 attrs.push("@unique".to_string());
1111 }
1112
1113 if let Some(ref default) = col.default
1115 && !col.auto_increment
1116 {
1117 let default_val = simplify_default(default);
1118 attrs.push(format!("@default({})", default_val));
1119 }
1120
1121 let field_name = camel_case(&col.name);
1123 if field_name != col.name {
1124 attrs.push(format!("@map(\"{}\")", escape_map_value(&col.name)));
1125 }
1126
1127 let type_str = col.normalized_type.to_prax_type();
1129 let optional = if col.nullable { "?" } else { "" };
1130
1131 let attrs_str = if attrs.is_empty() {
1132 String::new()
1133 } else {
1134 format!(" {}", attrs.join(" "))
1135 };
1136
1137 format!(" {} {}{}{}\n", field_name, type_str, optional, attrs_str)
1138}
1139
1140fn generate_relation(fk: &ForeignKeyInfo, all_tables: &[TableInfo]) -> String {
1141 let _ref_table = all_tables.iter().find(|t| t.name == fk.referenced_table);
1143 let ref_name = pascal_case(&fk.referenced_table);
1144
1145 let field_name = if fk.columns.len() == 1 {
1146 let col = &fk.columns[0];
1148 if col.ends_with("_id") {
1149 camel_case(&col[..col.len() - 3])
1150 } else {
1151 camel_case(&fk.referenced_table)
1152 }
1153 } else {
1154 camel_case(&fk.referenced_table)
1155 };
1156
1157 let mut attrs = [format!(
1158 "@relation(fields: [{}], references: [{}]",
1159 fk.columns
1160 .iter()
1161 .map(|c| camel_case(c))
1162 .collect::<Vec<_>>()
1163 .join(", "),
1164 fk.referenced_columns
1165 .iter()
1166 .map(|c| camel_case(c))
1167 .collect::<Vec<_>>()
1168 .join(", ")
1169 )];
1170
1171 if fk.on_delete != ReferentialAction::NoAction {
1173 attrs[0].push_str(&format!(", onDelete: {}", fk.on_delete.to_prax()));
1174 }
1175 if fk.on_update != ReferentialAction::NoAction {
1176 attrs[0].push_str(&format!(", onUpdate: {}", fk.on_update.to_prax()));
1177 }
1178
1179 attrs[0].push(')');
1180
1181 format!(" {} {} {}\n", field_name, ref_name, attrs.join(" "))
1182}
1183
1184fn generate_model_attributes(table: &TableInfo) -> String {
1185 let mut output = String::new();
1186
1187 let model_name = pascal_case(&table.name);
1189 if model_name.to_lowercase() != table.name.to_lowercase() {
1190 output.push_str(&format!(
1191 " @@map(\"{}\")\n",
1192 escape_map_value(&table.name)
1193 ));
1194 }
1195
1196 if table.primary_key.len() > 1 {
1198 let fields: Vec<_> = table.primary_key.iter().map(|c| camel_case(c)).collect();
1199 output.push_str(&format!(" @@id([{}])\n", fields.join(", ")));
1200 }
1201
1202 for idx in &table.indexes {
1204 if !idx.is_primary {
1205 let cols: Vec<_> = idx.columns.iter().map(|c| camel_case(&c.name)).collect();
1206 if idx.is_unique {
1207 output.push_str(&format!(" @@unique([{}])\n", cols.join(", ")));
1208 } else {
1209 output.push_str(&format!(" @@index([{}])\n", cols.join(", ")));
1210 }
1211 }
1212 }
1213
1214 output
1215}
1216
1217fn generate_view(view: &ViewInfo) -> String {
1218 let mut output = String::new();
1219
1220 let keyword = if view.is_materialized {
1221 "materializedView"
1222 } else {
1223 "view"
1224 };
1225 output.push_str(&format!("{} {} {{\n", keyword, pascal_case(&view.name)));
1226
1227 for col in &view.columns {
1228 let type_str = col.normalized_type.to_prax_type();
1229 let optional = if col.nullable { "?" } else { "" };
1230 output.push_str(&format!(
1231 " {} {}{}\n",
1232 camel_case(&col.name),
1233 type_str,
1234 optional
1235 ));
1236 }
1237
1238 if let Some(ref def) = view.definition {
1239 output.push_str(&format!("\n @@sql(\"{}\")\n", escape_map_value(def)));
1240 }
1241
1242 output.push_str("}\n");
1243 output
1244}
1245
1246pub mod mongodb {
1252 use serde_json::Value as JsonValue;
1253
1254 use super::{ColumnInfo, NormalizedType, TableInfo};
1255
1256 #[derive(Debug, Clone, Default)]
1258 pub struct SchemaInferrer {
1259 pub fields: std::collections::HashMap<String, FieldSchema>,
1261 pub samples: usize,
1263 }
1264
1265 #[derive(Debug, Clone, Default)]
1267 pub struct FieldSchema {
1268 pub name: String,
1270 pub types: Vec<String>,
1272 pub required: bool,
1274 pub nested: Option<Box<SchemaInferrer>>,
1276 pub array_type: Option<String>,
1278 }
1279
1280 impl SchemaInferrer {
1281 pub fn new() -> Self {
1283 Self::default()
1284 }
1285
1286 pub fn add_document(&mut self, doc: &JsonValue) {
1288 self.samples += 1;
1289
1290 if let Some(obj) = doc.as_object() {
1291 for (key, value) in obj {
1292 self.infer_field(key, value);
1293 }
1294 }
1295 }
1296
1297 fn infer_field(&mut self, name: &str, value: &JsonValue) {
1298 let field = self
1299 .fields
1300 .entry(name.to_string())
1301 .or_insert_with(|| FieldSchema {
1302 name: name.to_string(),
1303 required: true,
1304 ..Default::default()
1305 });
1306
1307 let type_name = match value {
1308 JsonValue::Null => "null",
1309 JsonValue::Bool(_) => "boolean",
1310 JsonValue::Number(n) if n.is_i64() => "int",
1311 JsonValue::Number(n) if n.is_f64() => "double",
1312 JsonValue::Number(_) => "number",
1313 JsonValue::String(s) => {
1314 if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
1316 "objectId"
1317 } else if is_iso_datetime(s) {
1318 "date"
1319 } else {
1320 "string"
1321 }
1322 }
1323 JsonValue::Array(arr) => {
1324 if let Some(first) = arr.first() {
1325 let elem_type = match first {
1326 JsonValue::Object(_) => "object",
1327 JsonValue::String(_) => "string",
1328 JsonValue::Number(_) => "number",
1329 JsonValue::Bool(_) => "boolean",
1330 _ => "mixed",
1331 };
1332 field.array_type = Some(elem_type.to_string());
1333 }
1334 "array"
1335 }
1336 JsonValue::Object(_) => {
1337 let mut nested = field.nested.take().unwrap_or_default();
1339 nested.add_document(value);
1340 field.nested = Some(nested);
1341 "object"
1342 }
1343 };
1344
1345 if !field.types.contains(&type_name.to_string()) {
1346 field.types.push(type_name.to_string());
1347 }
1348 }
1349
1350 pub fn to_table_info(&self, collection_name: &str) -> TableInfo {
1352 let mut columns = Vec::new();
1353
1354 for (name, field) in &self.fields {
1355 let normalized = infer_normalized_type(field);
1356 columns.push(ColumnInfo {
1357 name: name.clone(),
1358 db_type: field.types.join("|"),
1359 normalized_type: normalized,
1360 nullable: !field.required || field.types.contains(&"null".to_string()),
1361 is_primary_key: name == "_id",
1362 ..Default::default()
1363 });
1364 }
1365
1366 TableInfo {
1367 name: collection_name.to_string(),
1368 columns,
1369 primary_key: vec!["_id".to_string()],
1370 ..Default::default()
1371 }
1372 }
1373 }
1374
1375 fn infer_normalized_type(field: &FieldSchema) -> NormalizedType {
1376 if field.types.contains(&"objectId".to_string()) {
1378 NormalizedType::String } else if field.types.contains(&"date".to_string()) {
1380 NormalizedType::DateTime
1381 } else if field.types.contains(&"boolean".to_string()) {
1382 NormalizedType::Boolean
1383 } else if field.types.contains(&"int".to_string()) {
1384 NormalizedType::Int
1385 } else if field.types.contains(&"double".to_string())
1386 || field.types.contains(&"number".to_string())
1387 {
1388 NormalizedType::Double
1389 } else if field.types.contains(&"array".to_string()) {
1390 let inner = match field.array_type.as_deref() {
1391 Some("string") => NormalizedType::String,
1392 Some("number") => NormalizedType::Double,
1393 Some("boolean") => NormalizedType::Boolean,
1394 _ => NormalizedType::Json,
1395 };
1396 NormalizedType::Array(Box::new(inner))
1397 } else if field.types.contains(&"object".to_string()) {
1398 NormalizedType::Json
1399 } else if field.types.contains(&"string".to_string()) {
1400 NormalizedType::String
1401 } else {
1402 NormalizedType::Unknown(field.types.join("|"))
1403 }
1404 }
1405
1406 pub fn list_indexes_command(collection: &str) -> JsonValue {
1408 serde_json::json!({
1409 "listIndexes": collection
1410 })
1411 }
1412
1413 pub fn list_collections_command() -> JsonValue {
1415 serde_json::json!({
1416 "listCollections": 1
1417 })
1418 }
1419
1420 fn is_iso_datetime(s: &str) -> bool {
1422 if s.len() < 10 {
1424 return false;
1425 }
1426
1427 let bytes = s.as_bytes();
1428 bytes.get(4) == Some(&b'-')
1430 && bytes.get(7) == Some(&b'-')
1431 && bytes[0..4].iter().all(|b| b.is_ascii_digit())
1432 && bytes[5..7].iter().all(|b| b.is_ascii_digit())
1433 && bytes[8..10].iter().all(|b| b.is_ascii_digit())
1434 }
1435}
1436
1437pub fn pascal_case(s: &str) -> String {
1449 s.split('_')
1450 .map(|part| {
1451 let mut chars = part.chars();
1452 match chars.next() {
1453 None => String::new(),
1454 Some(c) => c.to_uppercase().chain(chars).collect(),
1455 }
1456 })
1457 .collect()
1458}
1459
1460fn camel_case(s: &str) -> String {
1461 let pascal = pascal_case(s);
1462 let mut chars = pascal.chars();
1463 match chars.next() {
1464 None => String::new(),
1465 Some(c) => c.to_lowercase().chain(chars).collect(),
1466 }
1467}
1468
1469fn simplify_default(default: &str) -> String {
1470 let d = default.trim();
1472
1473 if d.eq_ignore_ascii_case("now()") || d.eq_ignore_ascii_case("current_timestamp") {
1474 return "now()".to_string();
1475 }
1476
1477 if d.starts_with("'") && d.ends_with("'") {
1478 return format!("\"{}\"", escape_map_value(&d[1..d.len() - 1]));
1479 }
1480
1481 if d.eq_ignore_ascii_case("true") || d.eq_ignore_ascii_case("false") {
1482 return d.to_lowercase();
1483 }
1484
1485 if d.parse::<i64>().is_ok() || d.parse::<f64>().is_ok() {
1486 return d.to_string();
1487 }
1488
1489 format!("dbgenerated(\"{}\")", escape_map_value(d))
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495
1496 #[test]
1497 fn test_pascal_case() {
1498 assert_eq!(pascal_case("user_profile"), "UserProfile");
1499 assert_eq!(pascal_case("id"), "Id");
1500 assert_eq!(pascal_case("created_at"), "CreatedAt");
1501 }
1502
1503 #[test]
1504 fn test_camel_case() {
1505 assert_eq!(camel_case("user_profile"), "userProfile");
1506 assert_eq!(camel_case("ID"), "iD");
1507 assert_eq!(camel_case("created_at"), "createdAt");
1508 }
1509
1510 #[test]
1511 fn test_normalize_postgres_type() {
1512 assert_eq!(
1513 normalize_postgres_type("int4", None, None, None),
1514 NormalizedType::Int
1515 );
1516 assert_eq!(
1517 normalize_postgres_type("bigint", None, None, None),
1518 NormalizedType::BigInt
1519 );
1520 assert_eq!(
1521 normalize_postgres_type("text", None, None, None),
1522 NormalizedType::Text
1523 );
1524 assert_eq!(
1525 normalize_postgres_type("timestamptz", None, None, None),
1526 NormalizedType::DateTime
1527 );
1528 assert_eq!(
1529 normalize_postgres_type("jsonb", None, None, None),
1530 NormalizedType::Json
1531 );
1532 assert_eq!(
1533 normalize_postgres_type("uuid", None, None, None),
1534 NormalizedType::Uuid
1535 );
1536 }
1537
1538 #[test]
1539 fn test_normalize_mysql_type() {
1540 assert_eq!(
1541 normalize_mysql_type("int", None, None, None),
1542 NormalizedType::Int
1543 );
1544 assert_eq!(
1545 normalize_mysql_type("varchar", Some(255), None, None),
1546 NormalizedType::VarChar { length: Some(255) }
1547 );
1548 assert_eq!(
1549 normalize_mysql_type("datetime", None, None, None),
1550 NormalizedType::DateTime
1551 );
1552 }
1553
1554 #[test]
1555 fn test_parse_mysql_enum_values() {
1556 assert_eq!(
1557 parse_mysql_enum_values("enum('active','inactive')"),
1558 vec!["active".to_string(), "inactive".to_string()]
1559 );
1560 assert_eq!(
1561 parse_mysql_enum_values("enum('it''s ok','plain')"),
1562 vec!["it's ok".to_string(), "plain".to_string()]
1563 );
1564 assert_eq!(
1565 parse_mysql_enum_values("enum('solo')"),
1566 vec!["solo".to_string()]
1567 );
1568 assert_eq!(
1571 parse_mysql_enum_values("enum('a,b','c')"),
1572 vec!["a,b".to_string(), "c".to_string()]
1573 );
1574 assert_eq!(
1576 parse_mysql_enum_values("ENUM('active')"),
1577 vec!["active".to_string()]
1578 );
1579 assert_eq!(parse_mysql_enum_values("日本語"), Vec::<String>::new());
1582 assert_eq!(parse_mysql_enum_values("日本"), Vec::<String>::new());
1583 assert_eq!(parse_mysql_enum_values(""), Vec::<String>::new());
1584 }
1585
1586 #[test]
1587 fn test_sanitize_identifier() {
1588 assert_eq!(sanitize_identifier("active"), "active");
1589 assert_eq!(sanitize_identifier("in-progress"), "in_progress");
1590 assert_eq!(sanitize_identifier("1"), "V1");
1591 assert_eq!(sanitize_identifier(""), "V");
1592 }
1593
1594 #[test]
1595 fn test_escape_map_value() {
1596 assert_eq!(escape_map_value("plain"), "plain");
1597 assert_eq!(escape_map_value("say \"hi\""), "say \\\"hi\\\"");
1601 assert_eq!(escape_map_value("a\\b"), "a\\\\b");
1602 }
1603
1604 #[test]
1605 fn test_sanitize_variants_disambiguates_collisions() {
1606 let raw = vec![
1607 "in-progress".to_string(),
1608 "in_progress".to_string(),
1609 "done".to_string(),
1610 ];
1611 assert_eq!(
1612 sanitize_variants(&raw),
1613 vec![
1614 "in_progress".to_string(),
1615 "in_progress_2".to_string(),
1616 "done".to_string(),
1617 ]
1618 );
1619 }
1620
1621 #[test]
1622 fn test_enum_to_prax_type_matches_generate_enum_declaration() {
1623 let enum_info = EnumInfo {
1624 name: "users_status".to_string(),
1625 schema: None,
1626 values: vec!["active".to_string()],
1627 };
1628 let declared = generate_enum(&enum_info);
1629 assert!(declared.starts_with("enum UsersStatus {"));
1630 assert!(declared.contains("@@map(\"users_status\")"));
1634 assert_eq!(
1635 NormalizedType::Enum("users_status".to_string()).to_prax_type(),
1636 "UsersStatus"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_generate_enum_pins_sanitized_variant_values_with_map() {
1642 let enum_info = EnumInfo {
1643 name: "task_status".to_string(),
1644 schema: None,
1645 values: vec!["in-progress".to_string(), "done".to_string()],
1646 };
1647 let declared = generate_enum(&enum_info);
1648 assert!(declared.contains("in_progress @map(\"in-progress\")"));
1650 assert!(declared.contains(" done\n"));
1652 assert!(!declared.contains("done @map"));
1653 }
1654
1655 #[test]
1656 fn test_generate_enum_escapes_embedded_quotes_in_map_value() {
1657 let enum_info = EnumInfo {
1662 name: "task_status".to_string(),
1663 schema: None,
1664 values: vec!["say \"hi\"".to_string(), "a\\b".to_string()],
1665 };
1666 let declared = generate_enum(&enum_info);
1667 assert!(declared.contains("@map(\"say \\\"hi\\\"\")"));
1668 assert!(declared.contains("@map(\"a\\\\b\")"));
1669 }
1670
1671 #[test]
1672 fn test_referential_action() {
1673 assert_eq!(
1674 ReferentialAction::from_str("CASCADE"),
1675 ReferentialAction::Cascade
1676 );
1677 assert_eq!(
1678 ReferentialAction::from_str("SET NULL"),
1679 ReferentialAction::SetNull
1680 );
1681 assert_eq!(
1682 ReferentialAction::from_str("NO ACTION"),
1683 ReferentialAction::NoAction
1684 );
1685 }
1686
1687 #[test]
1688 fn test_generate_simple_model() {
1689 let table = TableInfo {
1690 name: "users".to_string(),
1691 columns: vec![
1692 ColumnInfo {
1693 name: "id".to_string(),
1694 normalized_type: NormalizedType::Int,
1695 auto_increment: true,
1696 ..Default::default()
1697 },
1698 ColumnInfo {
1699 name: "email".to_string(),
1700 normalized_type: NormalizedType::String,
1701 is_unique: true,
1702 ..Default::default()
1703 },
1704 ColumnInfo {
1705 name: "created_at".to_string(),
1706 normalized_type: NormalizedType::DateTime,
1707 nullable: true,
1708 default: Some("now()".to_string()),
1709 ..Default::default()
1710 },
1711 ],
1712 primary_key: vec!["id".to_string()],
1713 ..Default::default()
1714 };
1715
1716 let schema = generate_model(&table, &[]);
1717 assert!(schema.contains("model Users"));
1718 assert!(schema.contains("id Int @id @auto"));
1719 assert!(schema.contains("email String @unique"));
1720 assert!(schema.contains("createdAt DateTime?"));
1721 }
1722
1723 #[test]
1724 fn test_simplify_default() {
1725 assert_eq!(simplify_default("NOW()"), "now()");
1726 assert_eq!(simplify_default("CURRENT_TIMESTAMP"), "now()");
1727 assert_eq!(simplify_default("'hello'"), "\"hello\"");
1728 assert_eq!(simplify_default("'say \"hi\"'"), "\"say \\\"hi\\\"\"");
1731 assert_eq!(simplify_default("42"), "42");
1732 assert_eq!(simplify_default("true"), "true");
1733 }
1734
1735 #[test]
1736 fn test_queries_tables() {
1737 let pg = queries::tables_query(DatabaseType::PostgreSQL, Some("public"));
1738 assert!(pg.contains("information_schema.tables"));
1739 assert!(pg.contains("public"));
1740
1741 let mysql = queries::tables_query(DatabaseType::MySQL, None);
1742 assert!(mysql.contains("information_schema.tables"));
1743
1744 let sqlite = queries::tables_query(DatabaseType::SQLite, None);
1745 assert!(sqlite.contains("sqlite_master"));
1746 }
1747
1748 #[test]
1749 fn test_escape_literal() {
1750 assert_eq!(escape_literal("public"), "public");
1751 assert_eq!(escape_literal("o'brien"), "o''brien");
1752 assert_eq!(
1753 escape_literal("'; DROP TABLE users; --"),
1754 "''; DROP TABLE users; --"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_queries_escape_interpolated_names() {
1760 let sql = queries::columns_query(DatabaseType::SQLite, "we'ird", None);
1762 assert!(sql.contains("PRAGMA table_info('we''ird')"), "got: {sql}");
1763
1764 let sql = queries::tables_query(DatabaseType::MySQL, Some("my'schema"));
1766 assert!(
1767 sql.contains("AND table_schema = 'my''schema'"),
1768 "got: {sql}"
1769 );
1770 let sql = queries::tables_query(DatabaseType::PostgreSQL, Some("my'schema"));
1771 assert!(sql.contains("table_schema = 'my''schema'"), "got: {sql}");
1772 }
1773
1774 mod mongodb_tests {
1775 use super::super::mongodb::*;
1776
1777 #[test]
1778 fn test_schema_inferrer() {
1779 let mut inferrer = SchemaInferrer::new();
1780
1781 inferrer.add_document(&serde_json::json!({
1782 "_id": "507f1f77bcf86cd799439011",
1783 "name": "Alice",
1784 "age": 30,
1785 "active": true
1786 }));
1787
1788 inferrer.add_document(&serde_json::json!({
1789 "_id": "507f1f77bcf86cd799439012",
1790 "name": "Bob",
1791 "age": 25,
1792 "active": false,
1793 "email": "bob@example.com"
1794 }));
1795
1796 let table = inferrer.to_table_info("users");
1797 assert_eq!(table.name, "users");
1798 assert!(table.columns.iter().any(|c| c.name == "_id"));
1799 assert!(table.columns.iter().any(|c| c.name == "name"));
1800 assert!(table.columns.iter().any(|c| c.name == "age"));
1801 }
1802 }
1803}