Skip to main content

sea_orm_cli/commands/
generate.rs

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
12/// Split a string by comma while respecting parentheses nesting.
13/// This allows attributes like `test(a, b)` to be treated as a single value
14/// instead of being split into `test(a` and ` b)`.
15fn 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    // Add the last segment
62    let trimmed = current.trim();
63    if !trimmed.is_empty() {
64        result.push(trimmed.to_string());
65    }
66
67    result
68}
69
70/// Process a vector of strings that may contain comma-separated values with nested parentheses.
71/// This handles the case where clap no longer splits by comma, so we need to manually split
72/// each string while respecting parentheses nesting.
73fn 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/// Whether a discovered SQLite column is a generated (computed) column.
81///
82/// Generated columns cannot be inserted or updated, so they are dropped from
83/// generated entities — emitting them as ordinary fields makes every
84/// `INSERT`/`UPDATE` fail with "cannot INSERT/UPDATE a generated column" (#3094).
85#[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
94#[cfg(feature = "sqlx-postgres")]
95fn remove_postgres_partial_unique_indexes(table: &mut sea_schema::postgres::def::TableDef) {
96    table
97        .unique_constraints
98        .retain(|constraint| !constraint.is_partial);
99}
100
101/// The database name carried by a connection URL, or `None` if it has none.
102///
103/// The usual shape is hierarchical — `protocol://user:pass@host:port/database_name`
104/// — where the name is the first path segment. PostgreSQL also accepts the
105/// shorthand `postgres:database_name`, which has no authority component; the URL
106/// crate treats that as a "cannot-be-a-base" URL and yields no path segments at
107/// all, so the whole path is the database name (#2647).
108fn database_name_from_url(url: &Url) -> Option<&str> {
109    match url.path_segments() {
110        Some(mut segments) => segments.next(),
111        None => Some(url.path()),
112    }
113    .filter(|name| !name.is_empty())
114}
115
116pub async fn run_generate_command(
117    command: GenerateSubcommands,
118    verbose: bool,
119) -> Result<(), Box<dyn Error>> {
120    match command {
121        GenerateSubcommands::Entity {
122            entity_format,
123            compact_format: _,
124            expanded_format,
125            frontend_format,
126            include_hidden_tables,
127            tables,
128            ignore_tables,
129            max_connections,
130            acquire_timeout,
131            output_dir,
132            database_schema,
133            database_url,
134            with_prelude,
135            with_serde,
136            serde_skip_deserializing_primary_key,
137            serde_skip_hidden_column,
138            with_copy_enums,
139            date_time_crate,
140            big_integer_type,
141            lib,
142            model_extra_derives,
143            model_extra_attributes,
144            enum_extra_derives,
145            enum_extra_attributes,
146            column_extra_derives,
147            seaography,
148            impl_active_model_behavior,
149            preserve_user_modifications,
150            banner_version,
151            er_diagram,
152        } => {
153            if verbose {
154                let _ = tracing_subscriber::fmt()
155                    .with_max_level(tracing::Level::DEBUG)
156                    .with_test_writer()
157                    .try_init();
158            } else {
159                let filter_layer = EnvFilter::try_new("sea_orm_codegen=info").unwrap();
160                let fmt_layer = tracing_subscriber::fmt::layer()
161                    .with_target(false)
162                    .with_level(false)
163                    .without_time();
164
165                let _ = tracing_subscriber::registry()
166                    .with(filter_layer)
167                    .with(fmt_layer)
168                    .try_init();
169            }
170
171            // The database should be a valid URL that can be parsed
172            // protocol://username:password@host/database_name
173            let url = Url::parse(&database_url)?;
174
175            // Make sure we have all the required url components
176            //
177            // Missing scheme will have been caught by the Url::parse() call
178            // above
179            let is_sqlite = url.scheme() == "sqlite";
180
181            // Closures for filtering tables
182            let filter_tables =
183                |table: &String| -> bool { tables.is_empty() || tables.contains(table) };
184
185            let filter_hidden_tables = |table: &str| -> bool {
186                if include_hidden_tables {
187                    true
188                } else {
189                    !table.starts_with('_')
190                }
191            };
192
193            let filter_skip_tables = |table: &String| -> bool { !ignore_tables.contains(table) };
194
195            let _database_name = if !is_sqlite {
196                // Throwing an error if there is no database name since it might be
197                // accepted by the database without it, while we're looking to dump
198                // information from a particular database
199                database_name_from_url(&url).unwrap_or_else(|| {
200                    panic!(
201                        "There is no database name as part of the url path: {}",
202                        url.as_str()
203                    )
204                })
205            } else {
206                Default::default()
207            };
208
209            let (schema_name, table_stmts) = match url.scheme() {
210                "mysql" => {
211                    #[cfg(not(feature = "sqlx-mysql"))]
212                    {
213                        panic!("mysql feature is off")
214                    }
215                    #[cfg(feature = "sqlx-mysql")]
216                    {
217                        use sea_schema::mysql::discovery::SchemaDiscovery;
218                        use sqlx::MySql;
219
220                        println!("Connecting to MySQL ...");
221                        let connection = sqlx_connect::<MySql>(
222                            max_connections,
223                            acquire_timeout,
224                            url.as_str(),
225                            None,
226                        )
227                        .await?;
228                        println!("Discovering schema ...");
229                        let schema_discovery = SchemaDiscovery::new(connection, _database_name);
230                        let schema = schema_discovery.discover().await?;
231                        let table_stmts = schema
232                            .tables
233                            .into_iter()
234                            .filter(|schema| filter_tables(&schema.info.name))
235                            .filter(|schema| filter_hidden_tables(&schema.info.name))
236                            .filter(|schema| filter_skip_tables(&schema.info.name))
237                            .map(|mut schema| {
238                                // Skip generated columns (see #3094).
239                                schema.columns.retain(|col| !col.extra.generated);
240                                schema.write()
241                            })
242                            .collect();
243                        (None, table_stmts)
244                    }
245                }
246                "sqlite" => {
247                    #[cfg(not(feature = "sqlx-sqlite"))]
248                    {
249                        panic!("sqlite feature is off")
250                    }
251                    #[cfg(feature = "sqlx-sqlite")]
252                    {
253                        use sea_schema::sqlite::discovery::SchemaDiscovery;
254                        use sqlx::Sqlite;
255
256                        println!("Connecting to SQLite ...");
257                        let connection = sqlx_connect::<Sqlite>(
258                            max_connections,
259                            acquire_timeout,
260                            url.as_str(),
261                            None,
262                        )
263                        .await?;
264                        println!("Discovering schema ...");
265                        let schema_discovery = SchemaDiscovery::new(connection);
266                        let schema = schema_discovery
267                            .discover()
268                            .await?
269                            .merge_indexes_into_table();
270                        let table_stmts = schema
271                            .tables
272                            .into_iter()
273                            .filter(|schema| filter_tables(&schema.name))
274                            .filter(|schema| filter_hidden_tables(&schema.name))
275                            .filter(|schema| filter_skip_tables(&schema.name))
276                            .map(|mut schema| {
277                                // Skip generated columns: codegen can't round-trip them, and
278                                // emitting them as ordinary fields makes INSERT/UPDATE fail
279                                // ("cannot INSERT/UPDATE a generated column"). See #3094.
280                                schema
281                                    .columns
282                                    .retain(|col| !sqlite_column_is_generated(col));
283                                schema.write()
284                            })
285                            .collect();
286                        (None, table_stmts)
287                    }
288                }
289                "postgres" | "postgresql" => {
290                    #[cfg(not(feature = "sqlx-postgres"))]
291                    {
292                        panic!("postgres feature is off")
293                    }
294                    #[cfg(feature = "sqlx-postgres")]
295                    {
296                        use sea_schema::postgres::discovery::SchemaDiscovery;
297                        use sqlx::Postgres;
298
299                        println!("Connecting to Postgres ...");
300                        let schema = database_schema.as_deref().unwrap_or("public");
301                        let connection = sqlx_connect::<Postgres>(
302                            max_connections,
303                            acquire_timeout,
304                            url.as_str(),
305                            Some(schema),
306                        )
307                        .await?;
308                        println!("Discovering schema ...");
309                        let schema_discovery = SchemaDiscovery::new(connection, schema);
310                        let schema = schema_discovery.discover().await?;
311                        let table_stmts = schema
312                            .tables
313                            .into_iter()
314                            .filter(|schema| filter_tables(&schema.info.name))
315                            .filter(|schema| filter_hidden_tables(&schema.info.name))
316                            .filter(|schema| filter_skip_tables(&schema.info.name))
317                            .map(|mut schema| {
318                                // Skip generated columns (see #3094).
319                                schema.columns.retain(|col| col.generated.is_none());
320                                // Remove them because we don't support partial unique indexes in codegen yet.
321                                remove_postgres_partial_unique_indexes(&mut schema);
322                                schema.write()
323                            })
324                            .collect();
325                        (database_schema, table_stmts)
326                    }
327                }
328                _ => unimplemented!("{} is not supported", url.scheme()),
329            };
330            println!("... discovered.");
331
332            // Process extra derives and attributes, splitting by comma while respecting parentheses
333            // This handles cases like `--model-extra-attributes 'cfg_attr(debug_assertions, derive(Debug))'`
334            // which should be treated as a single attribute, not split into `cfg_attr(debug_assertions` and ` derive(Debug))`
335            let model_extra_derives = process_comma_separated_values(model_extra_derives);
336            let model_extra_attributes = process_comma_separated_values(model_extra_attributes);
337            let enum_extra_derives = process_comma_separated_values(enum_extra_derives);
338            let enum_extra_attributes = process_comma_separated_values(enum_extra_attributes);
339            let column_extra_derives = process_comma_separated_values(column_extra_derives);
340
341            let writer_context = EntityWriterContext::new(
342                if expanded_format {
343                    EntityFormat::Expanded
344                } else if frontend_format {
345                    EntityFormat::Frontend
346                } else if let Some(entity_format) = entity_format {
347                    EntityFormat::from_str(&entity_format).expect("Invalid entity-format option")
348                } else {
349                    EntityFormat::default()
350                },
351                WithPrelude::from_str(&with_prelude).expect("Invalid prelude option"),
352                WithSerde::from_str(&with_serde).expect("Invalid serde derive option"),
353                with_copy_enums,
354                date_time_crate.into(),
355                big_integer_type.into(),
356                schema_name,
357                lib,
358                serde_skip_deserializing_primary_key,
359                serde_skip_hidden_column,
360                model_extra_derives,
361                model_extra_attributes,
362                enum_extra_derives,
363                enum_extra_attributes,
364                column_extra_derives,
365                seaography,
366                impl_active_model_behavior,
367                banner_version.into(),
368            );
369            let entity_writer = EntityTransformer::transform(table_stmts)?;
370
371            let dir = Path::new(&output_dir);
372            fs::create_dir_all(dir)?;
373
374            if er_diagram {
375                let diagram = entity_writer.generate_er_diagram();
376                let diagram_path = dir.join("entities.mermaid");
377                fs::write(&diagram_path, &diagram)?;
378                println!("Writing {}", diagram_path.display());
379            }
380
381            let output = entity_writer.generate(&writer_context);
382
383            let mut merge_fallback_files: Vec<String> = Vec::new();
384
385            for OutputFile { name, content } in output.files.iter() {
386                let file_path = dir.join(name);
387                println!("Writing {}", file_path.display());
388
389                if !matches!(
390                    name.as_str(),
391                    "mod.rs" | "lib.rs" | "prelude.rs" | "sea_orm_active_enums.rs"
392                ) && file_path.exists()
393                    && preserve_user_modifications
394                {
395                    let prev_content = fs::read_to_string(&file_path)?;
396                    match merge_entity_files(&prev_content, content) {
397                        Ok(merged) => {
398                            fs::write(file_path, merged)?;
399                        }
400                        Err(MergeReport {
401                            output,
402                            warnings,
403                            fallback_applied,
404                        }) => {
405                            for message in warnings {
406                                eprintln!("{message}");
407                            }
408                            fs::write(file_path, output)?;
409                            if fallback_applied {
410                                merge_fallback_files.push(name.clone());
411                            }
412                        }
413                    }
414                } else {
415                    fs::write(file_path, content)?;
416                };
417            }
418
419            // Format each of the files
420            for OutputFile { name, .. } in output.files.iter() {
421                let exit_status = Command::new("rustfmt").arg(dir.join(name)).status()?; // Get the status code
422                if !exit_status.success() {
423                    // Propagate the error if any
424                    return Err(format!("Fail to format file `{name}`").into());
425                }
426            }
427
428            if merge_fallback_files.is_empty() {
429                println!("... Done.");
430            } else {
431                return Err(format!(
432                    "Merge fallback applied for {} file(s): \n{}",
433                    merge_fallback_files.len(),
434                    merge_fallback_files.join("\n")
435                )
436                .into());
437            }
438        }
439    }
440
441    Ok(())
442}
443
444async fn sqlx_connect<DB>(
445    max_connections: u32,
446    acquire_timeout: u64,
447    url: &str,
448    schema: Option<&str>,
449) -> Result<sqlx::Pool<DB>, Box<dyn Error>>
450where
451    DB: sqlx::Database,
452    for<'a> &'a mut <DB as sqlx::Database>::Connection: sqlx::Executor<'a>,
453{
454    let mut pool_options = sqlx::pool::PoolOptions::<DB>::new()
455        .max_connections(max_connections)
456        .acquire_timeout(time::Duration::from_secs(acquire_timeout));
457    // Set search_path for Postgres, E.g. Some("public") by default
458    // MySQL & SQLite connection initialize with schema `None`
459    if let Some(schema) = schema {
460        let sql = format!("SET search_path = '{schema}'");
461        pool_options = pool_options.after_connect(move |conn, _| {
462            let sql = sql.clone();
463            Box::pin(async move {
464                sqlx::Executor::execute(conn, sqlx::AssertSqlSafe(sql))
465                    .await
466                    .map(|_| ())
467            })
468        });
469    }
470    pool_options.connect(url).await.map_err(Into::into)
471}
472
473impl From<DateTimeCrate> for CodegenDateTimeCrate {
474    fn from(date_time_crate: DateTimeCrate) -> CodegenDateTimeCrate {
475        match date_time_crate {
476            DateTimeCrate::Chrono => CodegenDateTimeCrate::Chrono,
477            DateTimeCrate::Time => CodegenDateTimeCrate::Time,
478        }
479    }
480}
481
482impl From<BigIntegerType> for CodegenBigIntegerType {
483    fn from(date_time_crate: BigIntegerType) -> CodegenBigIntegerType {
484        match date_time_crate {
485            BigIntegerType::I64 => CodegenBigIntegerType::I64,
486            BigIntegerType::I32 => CodegenBigIntegerType::I32,
487        }
488    }
489}
490
491impl From<BannerVersion> for CodegenBannerVersion {
492    fn from(banner_version: BannerVersion) -> CodegenBannerVersion {
493        match banner_version {
494            BannerVersion::Off => CodegenBannerVersion::Off,
495            BannerVersion::Major => CodegenBannerVersion::Major,
496            BannerVersion::Minor => CodegenBannerVersion::Minor,
497            BannerVersion::Patch => CodegenBannerVersion::Patch,
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use clap::Parser;
505
506    use super::*;
507    use crate::{Cli, Commands};
508
509    #[test]
510    fn test_database_name_from_url() {
511        let cases = [
512            // Shorthand with no authority component -- the case from #2647.
513            ("postgres:my_db", Some("my_db")),
514            ("postgresql:my_db", Some("my_db")),
515            // The usual hierarchical forms.
516            ("postgres://user:pass@localhost:5432/my_db", Some("my_db")),
517            ("postgres:///my_db", Some("my_db")),
518            ("mysql://root:root@localhost:3306/my_db", Some("my_db")),
519            // Only the first segment is the database name.
520            ("postgres://localhost/my_db/extra", Some("my_db")),
521            // No database name in any form.
522            ("postgresql://root:root@localhost:3306", None),
523            ("mysql://root:root@localhost:3306/", None),
524            ("postgres:", None),
525        ];
526
527        for (input, expected) in cases {
528            let url = Url::parse(input).unwrap_or_else(|e| panic!("could not parse {input}: {e}"));
529            assert_eq!(
530                database_name_from_url(&url),
531                expected,
532                "unexpected database name for {input}"
533            );
534        }
535    }
536
537    #[test]
538    #[should_panic(
539        expected = "called `Result::unwrap()` on an `Err` value: RelativeUrlWithoutBase"
540    )]
541    fn test_generate_entity_no_protocol() {
542        let cli = Cli::parse_from([
543            "sea-orm-cli",
544            "generate",
545            "entity",
546            "--database-url",
547            "://root:root@localhost:3306/database",
548        ]);
549
550        match cli.command {
551            Commands::Generate { command } => {
552                smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
553            }
554            _ => unreachable!(),
555        }
556    }
557
558    #[test]
559    #[should_panic(
560        expected = "There is no database name as part of the url path: postgresql://root:root@localhost:3306"
561    )]
562    fn test_generate_entity_no_database_section() {
563        let cli = Cli::parse_from([
564            "sea-orm-cli",
565            "generate",
566            "entity",
567            "--database-url",
568            "postgresql://root:root@localhost:3306",
569        ]);
570
571        match cli.command {
572            Commands::Generate { command } => {
573                smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
574            }
575            _ => unreachable!(),
576        }
577    }
578
579    #[test]
580    #[should_panic(
581        expected = "There is no database name as part of the url path: mysql://root:root@localhost:3306/"
582    )]
583    fn test_generate_entity_no_database_path() {
584        let cli = Cli::parse_from([
585            "sea-orm-cli",
586            "generate",
587            "entity",
588            "--database-url",
589            "mysql://root:root@localhost:3306/",
590        ]);
591
592        match cli.command {
593            Commands::Generate { command } => {
594                smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
595            }
596            _ => unreachable!(),
597        }
598    }
599
600    #[test]
601    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: EmptyHost")]
602    fn test_generate_entity_no_host() {
603        let cli = Cli::parse_from([
604            "sea-orm-cli",
605            "generate",
606            "entity",
607            "--database-url",
608            "postgres://root:root@/database",
609        ]);
610
611        match cli.command {
612            Commands::Generate { command } => {
613                smol::block_on(run_generate_command(command, cli.verbose)).unwrap();
614            }
615            _ => unreachable!(),
616        }
617    }
618
619    #[test]
620    fn test_split_by_comma_simple() {
621        // Simple comma-separated values should split normally
622        let result = super::split_by_comma_ignoring_parentheses("a,b,c");
623        assert_eq!(result, vec!["a", "b", "c"]);
624    }
625
626    #[test]
627    fn test_split_by_comma_with_parentheses() {
628        // Comma inside parentheses should NOT split
629        let result = super::split_by_comma_ignoring_parentheses("test(a, b)");
630        assert_eq!(result, vec!["test(a, b)"]);
631
632        // Multiple values, one with parentheses containing comma
633        let result = super::split_by_comma_ignoring_parentheses("attr1,test(a, b)");
634        assert_eq!(result, vec!["attr1", "test(a, b)"]);
635    }
636
637    #[test]
638    fn test_split_by_comma_with_nested_parentheses() {
639        // Nested parentheses with commas
640        let result =
641            super::split_by_comma_ignoring_parentheses("cfg_attr(debug_assertions, derive(Debug))");
642        assert_eq!(result, vec!["cfg_attr(debug_assertions, derive(Debug))"]);
643
644        // Multiple nested parentheses
645        let result = super::split_by_comma_ignoring_parentheses(
646            "cfg_attr(feature1, attr(a, b)),cfg_attr(feature2, attr(c, d))",
647        );
648        assert_eq!(
649            result,
650            vec![
651                "cfg_attr(feature1, attr(a, b))",
652                "cfg_attr(feature2, attr(c, d))"
653            ]
654        );
655    }
656
657    #[test]
658    fn test_split_by_comma_with_brackets() {
659        // Brackets should also be respected
660        let result = super::split_by_comma_ignoring_parentheses(
661            "serde(rename_all = \"camelCase\"),ts(export)",
662        );
663        assert_eq!(
664            result,
665            vec!["serde(rename_all = \"camelCase\")", "ts(export)"]
666        );
667
668        // Brackets with commas
669        let result = super::split_by_comma_ignoring_parentheses("attr[key, value],other");
670        assert_eq!(result, vec!["attr[key, value]", "other"]);
671    }
672
673    #[test]
674    fn test_split_by_comma_with_braces() {
675        // Braces should also be respected
676        let result = super::split_by_comma_ignoring_parentheses("derive{a, b},other");
677        assert_eq!(result, vec!["derive{a, b}", "other"]);
678    }
679
680    #[test]
681    fn test_split_by_comma_empty() {
682        // Empty string should return empty vec
683        let result = super::split_by_comma_ignoring_parentheses("");
684        assert!(result.is_empty());
685
686        // Only whitespace should return empty vec
687        let result = super::split_by_comma_ignoring_parentheses("   ");
688        assert!(result.is_empty());
689    }
690
691    #[test]
692    fn test_split_by_comma_whitespace_handling() {
693        // Whitespace around values should be trimmed
694        let result = super::split_by_comma_ignoring_parentheses("  a  ,  b  ");
695        assert_eq!(result, vec!["a", "b"]);
696
697        // Whitespace inside parentheses should be preserved
698        let result = super::split_by_comma_ignoring_parentheses("test( a , b )");
699        assert_eq!(result, vec!["test( a , b )"]);
700    }
701
702    #[test]
703    fn test_process_comma_separated_values() {
704        // Process multiple strings, each potentially containing comma-separated values
705        let input = vec![
706            "attr1,attr2".to_string(),
707            "test(a, b)".to_string(),
708            "attr3".to_string(),
709        ];
710        let result = super::process_comma_separated_values(input);
711        assert_eq!(result, vec!["attr1", "attr2", "test(a, b)", "attr3"]);
712    }
713
714    #[test]
715    fn test_split_by_comma_real_world_examples() {
716        // Real-world example: cfg_attr with derive
717        let result = super::split_by_comma_ignoring_parentheses(
718            "cfg_attr(debug_assertions, derive(Debug)),serde(rename_all = \"camelCase\")",
719        );
720        assert_eq!(
721            result,
722            vec![
723                "cfg_attr(debug_assertions, derive(Debug))",
724                "serde(rename_all = \"camelCase\")"
725            ]
726        );
727
728        // Real-world example: multiple derives
729        let result = super::split_by_comma_ignoring_parentheses(
730            "derive(Debug, Clone),derive(Serialize, Deserialize)",
731        );
732        assert_eq!(
733            result,
734            vec!["derive(Debug, Clone)", "derive(Serialize, Deserialize)"]
735        );
736    }
737
738    // Regression test for #3094: generated columns must be dropped during
739    // `generate entity`, otherwise they are emitted as ordinary writable fields
740    // and every INSERT/UPDATE fails ("cannot INSERT/UPDATE a generated column").
741    #[cfg(feature = "sqlx-sqlite")]
742    #[test]
743    fn test_generate_entity_skips_sqlite_generated_columns() {
744        use sea_schema::sea_query::ColumnType;
745        use sea_schema::sqlite::def::{ColumnInfo, ColumnVisibility, DefaultType, TableDef};
746
747        let col = |cid, name: &str, hidden| ColumnInfo {
748            cid,
749            name: name.to_owned(),
750            r#type: ColumnType::Integer,
751            not_null: true,
752            default_value: DefaultType::Unspecified,
753            primary_key: cid == 0,
754            hidden,
755        };
756
757        let mut table = TableDef {
758            name: "widget".to_owned(),
759            foreign_keys: vec![],
760            indexes: vec![],
761            constraints: vec![],
762            columns: vec![
763                col(0, "id", ColumnVisibility::Visible),
764                col(1, "w", ColumnVisibility::Visible),
765                col(2, "area", ColumnVisibility::GeneratedVirtual),
766                col(3, "area_stored", ColumnVisibility::GeneratedStored),
767            ],
768            auto_increment: false,
769        };
770
771        // The predicate flags only the generated columns.
772        assert!(!super::sqlite_column_is_generated(&table.columns[0]));
773        assert!(!super::sqlite_column_is_generated(&table.columns[1]));
774        assert!(super::sqlite_column_is_generated(&table.columns[2]));
775        assert!(super::sqlite_column_is_generated(&table.columns[3]));
776
777        // After filtering + write(), generated columns are absent from the DDL.
778        table
779            .columns
780            .retain(|col| !super::sqlite_column_is_generated(col));
781        let stmt = table.write();
782        let names: Vec<String> = stmt
783            .get_columns()
784            .iter()
785            .map(|c| c.get_column_name())
786            .collect();
787        assert_eq!(names, ["id", "w"]);
788    }
789
790    // Discovery-backed companion to the regression test above. The test above
791    // hand-builds `ColumnVisibility`, so it proves the filter but not that real
792    // `GENERATED ALWAYS AS (...)` DDL actually surfaces as `Generated*` from
793    // `PRAGMA table_xinfo`. This exercises the discovery -> filter contract end
794    // to end against an in-memory SQLite database, which is the whole premise of
795    // the #3094 fix.
796    #[cfg(all(feature = "sqlx-sqlite", feature = "tokio"))]
797    #[tokio::test]
798    async fn test_sqlite_discovery_reports_generated_columns() {
799        use sea_schema::sqlite::discovery::SchemaDiscovery;
800        use sqlx::sqlite::SqlitePoolOptions;
801
802        // `sqlite::memory:` gives each connection its own private database, so
803        // pin the pool to a single connection or CREATE TABLE and discovery
804        // would run against different in-memory databases.
805        let pool = SqlitePoolOptions::new()
806            .max_connections(1)
807            .connect("sqlite::memory:")
808            .await
809            .expect("open in-memory sqlite");
810
811        sqlx::query(
812            "CREATE TABLE widget ( \
813                 id INTEGER PRIMARY KEY, \
814                 w INTEGER NOT NULL, \
815                 h INTEGER NOT NULL, \
816                 area_virtual INTEGER GENERATED ALWAYS AS (w * h) VIRTUAL, \
817                 area_stored INTEGER GENERATED ALWAYS AS (w * h) STORED \
818             )",
819        )
820        .execute(&pool)
821        .await
822        .expect("create table");
823
824        let schema = SchemaDiscovery::new(pool)
825            .discover()
826            .await
827            .expect("discover schema");
828
829        let table = schema
830            .tables
831            .iter()
832            .find(|table| table.name == "widget")
833            .expect("widget table discovered");
834
835        // Real generated DDL is discovered as `Generated*` visibility — the
836        // fact the hand-built unit test has to assume.
837        let generated: Vec<&str> = table
838            .columns
839            .iter()
840            .filter(|col| super::sqlite_column_is_generated(col))
841            .map(|col| col.name.as_str())
842            .collect();
843        assert_eq!(generated, ["area_virtual", "area_stored"]);
844
845        // After the retain performed by `generate entity`, only the base
846        // (writable) columns survive.
847        let kept: Vec<&str> = table
848            .columns
849            .iter()
850            .filter(|col| !super::sqlite_column_is_generated(col))
851            .map(|col| col.name.as_str())
852            .collect();
853        assert_eq!(kept, ["id", "w", "h"]);
854    }
855
856    #[cfg(feature = "sqlx-postgres")]
857    #[test]
858    fn filter_out_postgres_partial_unique_indexes() {
859        use sea_schema::postgres::def::{TableDef, TableInfo, Unique};
860
861        let unique_constraint = Unique {
862            name: "login_email_key".to_owned(),
863            columns: vec!["email".to_owned()],
864            is_partial: false,
865        };
866        let partial_constraint = Unique {
867            name: "login_human_login_id_key".to_owned(),
868            columns: vec!["human_login_id".to_owned()],
869            is_partial: true,
870        };
871        let mut table = TableDef {
872            info: TableInfo {
873                name: "login".to_owned(),
874                of_type: None,
875            },
876            columns: vec![],
877            check_constraints: vec![],
878            not_null_constraints: vec![],
879            unique_constraints: vec![unique_constraint.clone(), partial_constraint],
880            primary_key_constraints: vec![],
881            reference_constraints: vec![],
882            exclusion_constraints: vec![],
883        };
884
885        super::remove_postgres_partial_unique_indexes(&mut table);
886
887        assert_eq!(table.unique_constraints.len(), 1);
888        assert_eq!(table.unique_constraints[0], unique_constraint);
889    }
890}