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
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            // The database should be a valid URL that can be parsed
150            // protocol://username:password@host/database_name
151            let url = Url::parse(&database_url)?;
152
153            // Make sure we have all the required url components
154            //
155            // Missing scheme will have been caught by the Url::parse() call
156            // above
157            let is_sqlite = url.scheme() == "sqlite";
158
159            // Closures for filtering tables
160            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                // The database name should be the first element of the path string
175                //
176                // Throwing an error if there is no database name since it might be
177                // accepted by the database without it, while we're looking to dump
178                // information from a particular database
179                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                // An empty string as the database name is also an error
191                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                                // Skip generated columns (see #3094).
233                                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                                // Skip generated columns: codegen can't round-trip them, and
272                                // emitting them as ordinary fields makes INSERT/UPDATE fail
273                                // ("cannot INSERT/UPDATE a generated column"). See #3094.
274                                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                                // Skip generated columns (see #3094).
313                                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            // Process extra derives and attributes, splitting by comma while respecting parentheses
325            // This handles cases like `--model-extra-attributes 'cfg_attr(debug_assertions, derive(Debug))'`
326            // which should be treated as a single attribute, not split into `cfg_attr(debug_assertions` and ` derive(Debug))`
327            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            // Format each of the files
412            for OutputFile { name, .. } in output.files.iter() {
413                let exit_status = Command::new("rustfmt").arg(dir.join(name)).status()?; // Get the status code
414                if !exit_status.success() {
415                    // Propagate the error if any
416                    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    // Set search_path for Postgres, E.g. Some("public") by default
450    // MySQL & SQLite connection initialize with schema `None`
451    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        // Simple comma-separated values should split normally
586        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        // Comma inside parentheses should NOT split
593        let result = super::split_by_comma_ignoring_parentheses("test(a, b)");
594        assert_eq!(result, vec!["test(a, b)"]);
595
596        // Multiple values, one with parentheses containing comma
597        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        // Nested parentheses with commas
604        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        // Multiple nested parentheses
609        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        // Brackets should also be respected
624        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        // Brackets with commas
633        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        // Braces should also be respected
640        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        // Empty string should return empty vec
647        let result = super::split_by_comma_ignoring_parentheses("");
648        assert!(result.is_empty());
649
650        // Only whitespace should return empty vec
651        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        // Whitespace around values should be trimmed
658        let result = super::split_by_comma_ignoring_parentheses("  a  ,  b  ");
659        assert_eq!(result, vec!["a", "b"]);
660
661        // Whitespace inside parentheses should be preserved
662        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        // Process multiple strings, each potentially containing comma-separated values
669        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        // Real-world example: cfg_attr with derive
681        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        // Real-world example: multiple derives
693        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    // Regression test for #3094: generated columns must be dropped during
703    // `generate entity`, otherwise they are emitted as ordinary writable fields
704    // and every INSERT/UPDATE fails ("cannot INSERT/UPDATE a generated column").
705    #[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        // The predicate flags only the generated columns.
736        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        // After filtering + write(), generated columns are absent from the DDL.
742        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}