1use crate::{BannerVersion, BigIntegerType, DateTimeCrate, GenerateSubcommands};
2use core::time;
3use sea_orm_codegen::{
4 BannerVersion as CodegenBannerVersion, BigIntegerType as CodegenBigIntegerType,
5 DateTimeCrate as CodegenDateTimeCrate, EntityFormat, EntityTransformer, EntityWriterContext,
6 MergeReport, OutputFile, WithPrelude, WithSerde, merge_entity_files,
7};
8use std::{error::Error, fs, path::Path, process::Command, str::FromStr};
9use tracing_subscriber::{EnvFilter, prelude::*};
10use url::Url;
11
12fn split_by_comma_ignoring_parentheses(s: &str) -> Vec<String> {
16 let mut result = Vec::new();
17 let mut current = String::new();
18 let mut paren_depth = 0usize;
19 let mut bracket_depth = 0usize;
20 let mut brace_depth = 0usize;
21
22 for c in s.chars() {
23 match c {
24 '(' => {
25 paren_depth += 1;
26 current.push(c);
27 }
28 ')' => {
29 paren_depth = paren_depth.saturating_sub(1);
30 current.push(c);
31 }
32 '[' => {
33 bracket_depth += 1;
34 current.push(c);
35 }
36 ']' => {
37 bracket_depth = bracket_depth.saturating_sub(1);
38 current.push(c);
39 }
40 '{' => {
41 brace_depth += 1;
42 current.push(c);
43 }
44 '}' => {
45 brace_depth = brace_depth.saturating_sub(1);
46 current.push(c);
47 }
48 ',' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => {
49 let trimmed = current.trim();
50 if !trimmed.is_empty() {
51 result.push(trimmed.to_string());
52 }
53 current.clear();
54 }
55 _ => {
56 current.push(c);
57 }
58 }
59 }
60
61 let trimmed = current.trim();
63 if !trimmed.is_empty() {
64 result.push(trimmed.to_string());
65 }
66
67 result
68}
69
70fn process_comma_separated_values(values: Vec<String>) -> Vec<String> {
74 values
75 .into_iter()
76 .flat_map(|s| split_by_comma_ignoring_parentheses(&s))
77 .collect()
78}
79
80#[cfg(feature = "sqlx-sqlite")]
86fn sqlite_column_is_generated(col: &sea_schema::sqlite::def::ColumnInfo) -> bool {
87 use sea_schema::sqlite::def::ColumnVisibility;
88 matches!(
89 col.hidden,
90 ColumnVisibility::GeneratedVirtual | ColumnVisibility::GeneratedStored
91 )
92}
93
94pub async fn run_generate_command(
95 command: GenerateSubcommands,
96 verbose: bool,
97) -> Result<(), Box<dyn Error>> {
98 match command {
99 GenerateSubcommands::Entity {
100 entity_format,
101 compact_format: _,
102 expanded_format,
103 frontend_format,
104 include_hidden_tables,
105 tables,
106 ignore_tables,
107 max_connections,
108 acquire_timeout,
109 output_dir,
110 database_schema,
111 database_url,
112 with_prelude,
113 with_serde,
114 serde_skip_deserializing_primary_key,
115 serde_skip_hidden_column,
116 with_copy_enums,
117 date_time_crate,
118 big_integer_type,
119 lib,
120 model_extra_derives,
121 model_extra_attributes,
122 enum_extra_derives,
123 enum_extra_attributes,
124 column_extra_derives,
125 seaography,
126 impl_active_model_behavior,
127 preserve_user_modifications,
128 banner_version,
129 er_diagram,
130 } => {
131 if verbose {
132 let _ = tracing_subscriber::fmt()
133 .with_max_level(tracing::Level::DEBUG)
134 .with_test_writer()
135 .try_init();
136 } else {
137 let filter_layer = EnvFilter::try_new("sea_orm_codegen=info").unwrap();
138 let fmt_layer = tracing_subscriber::fmt::layer()
139 .with_target(false)
140 .with_level(false)
141 .without_time();
142
143 let _ = tracing_subscriber::registry()
144 .with(filter_layer)
145 .with(fmt_layer)
146 .try_init();
147 }
148
149 let url = Url::parse(&database_url)?;
152
153 let is_sqlite = url.scheme() == "sqlite";
158
159 let filter_tables =
161 |table: &String| -> bool { tables.is_empty() || tables.contains(table) };
162
163 let filter_hidden_tables = |table: &str| -> bool {
164 if include_hidden_tables {
165 true
166 } else {
167 !table.starts_with('_')
168 }
169 };
170
171 let filter_skip_tables = |table: &String| -> bool { !ignore_tables.contains(table) };
172
173 let _database_name = if !is_sqlite {
174 let database_name = url
180 .path_segments()
181 .unwrap_or_else(|| {
182 panic!(
183 "There is no database name as part of the url path: {}",
184 url.as_str()
185 )
186 })
187 .next()
188 .unwrap();
189
190 if database_name.is_empty() {
192 panic!(
193 "There is no database name as part of the url path: {}",
194 url.as_str()
195 );
196 }
197
198 database_name
199 } else {
200 Default::default()
201 };
202
203 let (schema_name, table_stmts) = match url.scheme() {
204 "mysql" => {
205 #[cfg(not(feature = "sqlx-mysql"))]
206 {
207 panic!("mysql feature is off")
208 }
209 #[cfg(feature = "sqlx-mysql")]
210 {
211 use sea_schema::mysql::discovery::SchemaDiscovery;
212 use sqlx::MySql;
213
214 println!("Connecting to MySQL ...");
215 let connection = sqlx_connect::<MySql>(
216 max_connections,
217 acquire_timeout,
218 url.as_str(),
219 None,
220 )
221 .await?;
222 println!("Discovering schema ...");
223 let schema_discovery = SchemaDiscovery::new(connection, _database_name);
224 let schema = schema_discovery.discover().await?;
225 let table_stmts = schema
226 .tables
227 .into_iter()
228 .filter(|schema| filter_tables(&schema.info.name))
229 .filter(|schema| filter_hidden_tables(&schema.info.name))
230 .filter(|schema| filter_skip_tables(&schema.info.name))
231 .map(|mut schema| {
232 schema.columns.retain(|col| !col.extra.generated);
234 schema.write()
235 })
236 .collect();
237 (None, table_stmts)
238 }
239 }
240 "sqlite" => {
241 #[cfg(not(feature = "sqlx-sqlite"))]
242 {
243 panic!("sqlite feature is off")
244 }
245 #[cfg(feature = "sqlx-sqlite")]
246 {
247 use sea_schema::sqlite::discovery::SchemaDiscovery;
248 use sqlx::Sqlite;
249
250 println!("Connecting to SQLite ...");
251 let connection = sqlx_connect::<Sqlite>(
252 max_connections,
253 acquire_timeout,
254 url.as_str(),
255 None,
256 )
257 .await?;
258 println!("Discovering schema ...");
259 let schema_discovery = SchemaDiscovery::new(connection);
260 let schema = schema_discovery
261 .discover()
262 .await?
263 .merge_indexes_into_table();
264 let table_stmts = schema
265 .tables
266 .into_iter()
267 .filter(|schema| filter_tables(&schema.name))
268 .filter(|schema| filter_hidden_tables(&schema.name))
269 .filter(|schema| filter_skip_tables(&schema.name))
270 .map(|mut schema| {
271 schema
275 .columns
276 .retain(|col| !sqlite_column_is_generated(col));
277 schema.write()
278 })
279 .collect();
280 (None, table_stmts)
281 }
282 }
283 "postgres" | "postgresql" => {
284 #[cfg(not(feature = "sqlx-postgres"))]
285 {
286 panic!("postgres feature is off")
287 }
288 #[cfg(feature = "sqlx-postgres")]
289 {
290 use sea_schema::postgres::discovery::SchemaDiscovery;
291 use sqlx::Postgres;
292
293 println!("Connecting to Postgres ...");
294 let schema = database_schema.as_deref().unwrap_or("public");
295 let connection = sqlx_connect::<Postgres>(
296 max_connections,
297 acquire_timeout,
298 url.as_str(),
299 Some(schema),
300 )
301 .await?;
302 println!("Discovering schema ...");
303 let schema_discovery = SchemaDiscovery::new(connection, schema);
304 let schema = schema_discovery.discover().await?;
305 let table_stmts = schema
306 .tables
307 .into_iter()
308 .filter(|schema| filter_tables(&schema.info.name))
309 .filter(|schema| filter_hidden_tables(&schema.info.name))
310 .filter(|schema| filter_skip_tables(&schema.info.name))
311 .map(|mut schema| {
312 schema.columns.retain(|col| col.generated.is_none());
314 schema.write()
315 })
316 .collect();
317 (database_schema, table_stmts)
318 }
319 }
320 _ => unimplemented!("{} is not supported", url.scheme()),
321 };
322 println!("... discovered.");
323
324 let model_extra_derives = process_comma_separated_values(model_extra_derives);
328 let model_extra_attributes = process_comma_separated_values(model_extra_attributes);
329 let enum_extra_derives = process_comma_separated_values(enum_extra_derives);
330 let enum_extra_attributes = process_comma_separated_values(enum_extra_attributes);
331 let column_extra_derives = process_comma_separated_values(column_extra_derives);
332
333 let writer_context = EntityWriterContext::new(
334 if expanded_format {
335 EntityFormat::Expanded
336 } else if frontend_format {
337 EntityFormat::Frontend
338 } else if let Some(entity_format) = entity_format {
339 EntityFormat::from_str(&entity_format).expect("Invalid entity-format option")
340 } else {
341 EntityFormat::default()
342 },
343 WithPrelude::from_str(&with_prelude).expect("Invalid prelude option"),
344 WithSerde::from_str(&with_serde).expect("Invalid serde derive option"),
345 with_copy_enums,
346 date_time_crate.into(),
347 big_integer_type.into(),
348 schema_name,
349 lib,
350 serde_skip_deserializing_primary_key,
351 serde_skip_hidden_column,
352 model_extra_derives,
353 model_extra_attributes,
354 enum_extra_derives,
355 enum_extra_attributes,
356 column_extra_derives,
357 seaography,
358 impl_active_model_behavior,
359 banner_version.into(),
360 );
361 let entity_writer = EntityTransformer::transform(table_stmts)?;
362
363 let dir = Path::new(&output_dir);
364 fs::create_dir_all(dir)?;
365
366 if er_diagram {
367 let diagram = entity_writer.generate_er_diagram();
368 let diagram_path = dir.join("entities.mermaid");
369 fs::write(&diagram_path, &diagram)?;
370 println!("Writing {}", diagram_path.display());
371 }
372
373 let output = entity_writer.generate(&writer_context);
374
375 let mut merge_fallback_files: Vec<String> = Vec::new();
376
377 for OutputFile { name, content } in output.files.iter() {
378 let file_path = dir.join(name);
379 println!("Writing {}", file_path.display());
380
381 if !matches!(
382 name.as_str(),
383 "mod.rs" | "lib.rs" | "prelude.rs" | "sea_orm_active_enums.rs"
384 ) && file_path.exists()
385 && preserve_user_modifications
386 {
387 let prev_content = fs::read_to_string(&file_path)?;
388 match merge_entity_files(&prev_content, content) {
389 Ok(merged) => {
390 fs::write(file_path, merged)?;
391 }
392 Err(MergeReport {
393 output,
394 warnings,
395 fallback_applied,
396 }) => {
397 for message in warnings {
398 eprintln!("{message}");
399 }
400 fs::write(file_path, output)?;
401 if fallback_applied {
402 merge_fallback_files.push(name.clone());
403 }
404 }
405 }
406 } else {
407 fs::write(file_path, content)?;
408 };
409 }
410
411 for OutputFile { name, .. } in output.files.iter() {
413 let exit_status = Command::new("rustfmt").arg(dir.join(name)).status()?; if !exit_status.success() {
415 return Err(format!("Fail to format file `{name}`").into());
417 }
418 }
419
420 if merge_fallback_files.is_empty() {
421 println!("... Done.");
422 } else {
423 return Err(format!(
424 "Merge fallback applied for {} file(s): \n{}",
425 merge_fallback_files.len(),
426 merge_fallback_files.join("\n")
427 )
428 .into());
429 }
430 }
431 }
432
433 Ok(())
434}
435
436async fn sqlx_connect<DB>(
437 max_connections: u32,
438 acquire_timeout: u64,
439 url: &str,
440 schema: Option<&str>,
441) -> Result<sqlx::Pool<DB>, Box<dyn Error>>
442where
443 DB: sqlx::Database,
444 for<'a> &'a mut <DB as sqlx::Database>::Connection: sqlx::Executor<'a>,
445{
446 let mut pool_options = sqlx::pool::PoolOptions::<DB>::new()
447 .max_connections(max_connections)
448 .acquire_timeout(time::Duration::from_secs(acquire_timeout));
449 if let Some(schema) = schema {
452 let sql = format!("SET search_path = '{schema}'");
453 pool_options = pool_options.after_connect(move |conn, _| {
454 let sql = sql.clone();
455 Box::pin(async move {
456 sqlx::Executor::execute(conn, sqlx::AssertSqlSafe(sql))
457 .await
458 .map(|_| ())
459 })
460 });
461 }
462 pool_options.connect(url).await.map_err(Into::into)
463}
464
465impl From<DateTimeCrate> for CodegenDateTimeCrate {
466 fn from(date_time_crate: DateTimeCrate) -> CodegenDateTimeCrate {
467 match date_time_crate {
468 DateTimeCrate::Chrono => CodegenDateTimeCrate::Chrono,
469 DateTimeCrate::Time => CodegenDateTimeCrate::Time,
470 }
471 }
472}
473
474impl From<BigIntegerType> for CodegenBigIntegerType {
475 fn from(date_time_crate: BigIntegerType) -> CodegenBigIntegerType {
476 match date_time_crate {
477 BigIntegerType::I64 => CodegenBigIntegerType::I64,
478 BigIntegerType::I32 => CodegenBigIntegerType::I32,
479 }
480 }
481}
482
483impl From<BannerVersion> for CodegenBannerVersion {
484 fn from(banner_version: BannerVersion) -> CodegenBannerVersion {
485 match banner_version {
486 BannerVersion::Off => CodegenBannerVersion::Off,
487 BannerVersion::Major => CodegenBannerVersion::Major,
488 BannerVersion::Minor => CodegenBannerVersion::Minor,
489 BannerVersion::Patch => CodegenBannerVersion::Patch,
490 }
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use clap::Parser;
497
498 use super::*;
499 use crate::{Cli, Commands};
500
501 #[test]
502 #[should_panic(
503 expected = "called `Result::unwrap()` on an `Err` value: RelativeUrlWithoutBase"
504 )]
505 fn test_generate_entity_no_protocol() {
506 let cli = Cli::parse_from([
507 "sea-orm-cli",
508 "generate",
509 "entity",
510 "--database-url",
511 "://root:root@localhost:3306/database",
512 ]);
513
514 match cli.command {
515 Commands::Generate { command } => {
516 smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
517 }
518 _ => unreachable!(),
519 }
520 }
521
522 #[test]
523 #[should_panic(
524 expected = "There is no database name as part of the url path: postgresql://root:root@localhost:3306"
525 )]
526 fn test_generate_entity_no_database_section() {
527 let cli = Cli::parse_from([
528 "sea-orm-cli",
529 "generate",
530 "entity",
531 "--database-url",
532 "postgresql://root:root@localhost:3306",
533 ]);
534
535 match cli.command {
536 Commands::Generate { command } => {
537 smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
538 }
539 _ => unreachable!(),
540 }
541 }
542
543 #[test]
544 #[should_panic(
545 expected = "There is no database name as part of the url path: mysql://root:root@localhost:3306/"
546 )]
547 fn test_generate_entity_no_database_path() {
548 let cli = Cli::parse_from([
549 "sea-orm-cli",
550 "generate",
551 "entity",
552 "--database-url",
553 "mysql://root:root@localhost:3306/",
554 ]);
555
556 match cli.command {
557 Commands::Generate { command } => {
558 smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
559 }
560 _ => unreachable!(),
561 }
562 }
563
564 #[test]
565 #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: EmptyHost")]
566 fn test_generate_entity_no_host() {
567 let cli = Cli::parse_from([
568 "sea-orm-cli",
569 "generate",
570 "entity",
571 "--database-url",
572 "postgres://root:root@/database",
573 ]);
574
575 match cli.command {
576 Commands::Generate { command } => {
577 smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
578 }
579 _ => unreachable!(),
580 }
581 }
582
583 #[test]
584 fn test_split_by_comma_simple() {
585 let result = super::split_by_comma_ignoring_parentheses("a,b,c");
587 assert_eq!(result, vec!["a", "b", "c"]);
588 }
589
590 #[test]
591 fn test_split_by_comma_with_parentheses() {
592 let result = super::split_by_comma_ignoring_parentheses("test(a, b)");
594 assert_eq!(result, vec!["test(a, b)"]);
595
596 let result = super::split_by_comma_ignoring_parentheses("attr1,test(a, b)");
598 assert_eq!(result, vec!["attr1", "test(a, b)"]);
599 }
600
601 #[test]
602 fn test_split_by_comma_with_nested_parentheses() {
603 let result =
605 super::split_by_comma_ignoring_parentheses("cfg_attr(debug_assertions, derive(Debug))");
606 assert_eq!(result, vec!["cfg_attr(debug_assertions, derive(Debug))"]);
607
608 let result = super::split_by_comma_ignoring_parentheses(
610 "cfg_attr(feature1, attr(a, b)),cfg_attr(feature2, attr(c, d))",
611 );
612 assert_eq!(
613 result,
614 vec![
615 "cfg_attr(feature1, attr(a, b))",
616 "cfg_attr(feature2, attr(c, d))"
617 ]
618 );
619 }
620
621 #[test]
622 fn test_split_by_comma_with_brackets() {
623 let result = super::split_by_comma_ignoring_parentheses(
625 "serde(rename_all = \"camelCase\"),ts(export)",
626 );
627 assert_eq!(
628 result,
629 vec!["serde(rename_all = \"camelCase\")", "ts(export)"]
630 );
631
632 let result = super::split_by_comma_ignoring_parentheses("attr[key, value],other");
634 assert_eq!(result, vec!["attr[key, value]", "other"]);
635 }
636
637 #[test]
638 fn test_split_by_comma_with_braces() {
639 let result = super::split_by_comma_ignoring_parentheses("derive{a, b},other");
641 assert_eq!(result, vec!["derive{a, b}", "other"]);
642 }
643
644 #[test]
645 fn test_split_by_comma_empty() {
646 let result = super::split_by_comma_ignoring_parentheses("");
648 assert!(result.is_empty());
649
650 let result = super::split_by_comma_ignoring_parentheses(" ");
652 assert!(result.is_empty());
653 }
654
655 #[test]
656 fn test_split_by_comma_whitespace_handling() {
657 let result = super::split_by_comma_ignoring_parentheses(" a , b ");
659 assert_eq!(result, vec!["a", "b"]);
660
661 let result = super::split_by_comma_ignoring_parentheses("test( a , b )");
663 assert_eq!(result, vec!["test( a , b )"]);
664 }
665
666 #[test]
667 fn test_process_comma_separated_values() {
668 let input = vec![
670 "attr1,attr2".to_string(),
671 "test(a, b)".to_string(),
672 "attr3".to_string(),
673 ];
674 let result = super::process_comma_separated_values(input);
675 assert_eq!(result, vec!["attr1", "attr2", "test(a, b)", "attr3"]);
676 }
677
678 #[test]
679 fn test_split_by_comma_real_world_examples() {
680 let result = super::split_by_comma_ignoring_parentheses(
682 "cfg_attr(debug_assertions, derive(Debug)),serde(rename_all = \"camelCase\")",
683 );
684 assert_eq!(
685 result,
686 vec![
687 "cfg_attr(debug_assertions, derive(Debug))",
688 "serde(rename_all = \"camelCase\")"
689 ]
690 );
691
692 let result = super::split_by_comma_ignoring_parentheses(
694 "derive(Debug, Clone),derive(Serialize, Deserialize)",
695 );
696 assert_eq!(
697 result,
698 vec!["derive(Debug, Clone)", "derive(Serialize, Deserialize)"]
699 );
700 }
701
702 #[cfg(feature = "sqlx-sqlite")]
706 #[test]
707 fn test_generate_entity_skips_sqlite_generated_columns() {
708 use sea_schema::sea_query::ColumnType;
709 use sea_schema::sqlite::def::{ColumnInfo, ColumnVisibility, DefaultType, TableDef};
710
711 let col = |cid, name: &str, hidden| ColumnInfo {
712 cid,
713 name: name.to_owned(),
714 r#type: ColumnType::Integer,
715 not_null: true,
716 default_value: DefaultType::Unspecified,
717 primary_key: cid == 0,
718 hidden,
719 };
720
721 let mut table = TableDef {
722 name: "widget".to_owned(),
723 foreign_keys: vec![],
724 indexes: vec![],
725 constraints: vec![],
726 columns: vec![
727 col(0, "id", ColumnVisibility::Visible),
728 col(1, "w", ColumnVisibility::Visible),
729 col(2, "area", ColumnVisibility::GeneratedVirtual),
730 col(3, "area_stored", ColumnVisibility::GeneratedStored),
731 ],
732 auto_increment: false,
733 };
734
735 assert!(!super::sqlite_column_is_generated(&table.columns[0]));
737 assert!(!super::sqlite_column_is_generated(&table.columns[1]));
738 assert!(super::sqlite_column_is_generated(&table.columns[2]));
739 assert!(super::sqlite_column_is_generated(&table.columns[3]));
740
741 table
743 .columns
744 .retain(|col| !super::sqlite_column_is_generated(col));
745 let stmt = table.write();
746 let names: Vec<String> = stmt
747 .get_columns()
748 .iter()
749 .map(|c| c.get_column_name())
750 .collect();
751 assert_eq!(names, ["id", "w"]);
752 }
753}