Skip to main content

sqlmodel_schema/
lib.rs

1//! Schema definition and migration support for SQLModel Rust.
2//!
3//! `sqlmodel-schema` is the **DDL and migrations layer**. It inspects `Model` metadata
4//! to generate CREATE/ALTER SQL and provides tooling for schema diffs and migrations.
5//!
6//! # Role In The Architecture
7//!
8//! - **Schema extraction**: derive expected tables/columns from `Model` definitions.
9//! - **Diff engine**: compare desired vs. actual schema for migration planning.
10//! - **DDL generation**: emit dialect-specific SQL for SQLite, MySQL, and Postgres.
11//! - **Migration runner**: track, apply, and validate migrations.
12//!
13//! Applications typically use this via `sqlmodel::SchemaBuilder`, but it can also be
14//! embedded in custom tooling or CI migration checks.
15
16pub mod create;
17pub mod ddl;
18pub mod diff;
19pub mod expected;
20pub mod introspect;
21pub mod migrate;
22
23pub use create::{CreateTable, SchemaBuilder};
24pub use ddl::{
25    DdlGenerator, MysqlDdlGenerator, PostgresDdlGenerator, SqliteDdlGenerator,
26    generator_for_dialect,
27};
28pub use expected::{
29    ModelSchema, ModelTuple, expected_schema, normalize_sql_type, table_schema_from_fields,
30    table_schema_from_model,
31};
32pub use introspect::{
33    CheckConstraintInfo, ColumnInfo, DatabaseSchema, Dialect, ForeignKeyInfo, IndexInfo,
34    Introspector, ParsedSqlType, TableInfo, UniqueConstraintInfo,
35};
36pub use migrate::{Migration, MigrationFormat, MigrationRunner, MigrationStatus, MigrationWriter};
37
38use asupersync::{Cx, Outcome};
39use sqlmodel_core::{Connection, Model, quote_ident};
40
41/// Create a table for a model type.
42///
43/// # Example
44///
45/// ```ignore
46/// use sqlmodel::{Model, create_table};
47///
48/// #[derive(Model)]
49/// struct Hero {
50///     id: Option<i64>,
51///     name: String,
52/// }
53///
54/// // Generate CREATE TABLE SQL
55/// let sql = create_table::<Hero>().if_not_exists().build();
56/// ```
57pub fn create_table<M: Model>() -> CreateTable<M> {
58    CreateTable::new()
59}
60
61/// Create all tables for the given models.
62///
63/// This is a convenience function for creating multiple tables
64/// in the correct order based on foreign key dependencies.
65pub async fn create_all<C: Connection>(
66    cx: &Cx,
67    conn: &C,
68    schemas: &[&str],
69) -> Outcome<(), sqlmodel_core::Error> {
70    for sql in schemas {
71        match conn.execute(cx, sql, &[]).await {
72            Outcome::Ok(_) => continue,
73            Outcome::Err(e) => return Outcome::Err(e),
74            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
75            Outcome::Panicked(p) => return Outcome::Panicked(p),
76        }
77    }
78    Outcome::Ok(())
79}
80
81/// Drop a table.
82pub async fn drop_table<C: Connection>(
83    cx: &Cx,
84    conn: &C,
85    table_name: &str,
86    if_exists: bool,
87) -> Outcome<(), sqlmodel_core::Error> {
88    let sql = if if_exists {
89        format!("DROP TABLE IF EXISTS {}", quote_ident(table_name))
90    } else {
91        format!("DROP TABLE {}", quote_ident(table_name))
92    };
93
94    conn.execute(cx, &sql, &[]).await.map(|_| ())
95}
96
97/// Generate DROP TABLE SQL (for testing/inspection).
98///
99/// This is the same SQL that `drop_table` would execute.
100pub fn drop_table_sql(table_name: &str, if_exists: bool) -> String {
101    if if_exists {
102        format!("DROP TABLE IF EXISTS {}", quote_ident(table_name))
103    } else {
104        format!("DROP TABLE {}", quote_ident(table_name))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    // ================================================================================
113    // DROP TABLE Identifier Quoting Tests
114    // ================================================================================
115
116    #[test]
117    fn test_drop_table_sql_simple() {
118        let sql = drop_table_sql("users", true);
119        assert_eq!(sql, "DROP TABLE IF EXISTS \"users\"");
120
121        let sql = drop_table_sql("heroes", false);
122        assert_eq!(sql, "DROP TABLE \"heroes\"");
123    }
124
125    #[test]
126    fn test_drop_table_sql_with_keyword_name() {
127        // SQL keywords must be quoted
128        let sql = drop_table_sql("order", true);
129        assert_eq!(sql, "DROP TABLE IF EXISTS \"order\"");
130
131        let sql = drop_table_sql("select", true);
132        assert_eq!(sql, "DROP TABLE IF EXISTS \"select\"");
133
134        let sql = drop_table_sql("user", true);
135        assert_eq!(sql, "DROP TABLE IF EXISTS \"user\"");
136    }
137
138    #[test]
139    fn test_drop_table_sql_with_embedded_quotes() {
140        // Embedded quotes must be doubled
141        let sql = drop_table_sql("my\"table", true);
142        assert_eq!(sql, "DROP TABLE IF EXISTS \"my\"\"table\"");
143
144        let sql = drop_table_sql("test\"\"name", false);
145        assert_eq!(sql, "DROP TABLE \"test\"\"\"\"name\"");
146
147        // Just a quote
148        let sql = drop_table_sql("\"", true);
149        assert_eq!(sql, "DROP TABLE IF EXISTS \"\"\"\"");
150    }
151
152    #[test]
153    fn test_drop_table_sql_with_spaces() {
154        let sql = drop_table_sql("my table", true);
155        assert_eq!(sql, "DROP TABLE IF EXISTS \"my table\"");
156    }
157
158    #[test]
159    fn test_drop_table_sql_with_unicode() {
160        let sql = drop_table_sql("用户", true);
161        assert_eq!(sql, "DROP TABLE IF EXISTS \"用户\"");
162
163        let sql = drop_table_sql("tâble_émoji_🦀", false);
164        assert_eq!(sql, "DROP TABLE \"tâble_émoji_🦀\"");
165    }
166
167    #[test]
168    fn test_drop_table_sql_edge_cases() {
169        // Empty table name (unusual but should be quoted)
170        let sql = drop_table_sql("", true);
171        assert_eq!(sql, "DROP TABLE IF EXISTS \"\"");
172
173        // Single character
174        let sql = drop_table_sql("x", true);
175        assert_eq!(sql, "DROP TABLE IF EXISTS \"x\"");
176
177        // Numbers at start (not valid unquoted identifier in most DBs)
178        let sql = drop_table_sql("123table", true);
179        assert_eq!(sql, "DROP TABLE IF EXISTS \"123table\"");
180
181        // Special characters
182        let sql = drop_table_sql("table-with-dashes", true);
183        assert_eq!(sql, "DROP TABLE IF EXISTS \"table-with-dashes\"");
184
185        let sql = drop_table_sql("table.with.dots", true);
186        assert_eq!(sql, "DROP TABLE IF EXISTS \"table.with.dots\"");
187    }
188
189    #[test]
190    fn test_drop_table_sql_sql_injection_attempt_neutralized() {
191        // SQL injection attempt - the quote_ident should neutralize it
192        let malicious = "users\"; DROP TABLE secrets; --";
193        let sql = drop_table_sql(malicious, true);
194        // The embedded quote should be doubled, neutralizing the injection
195        assert_eq!(
196            sql,
197            "DROP TABLE IF EXISTS \"users\"\"; DROP TABLE secrets; --\""
198        );
199        // Verify the whole thing is treated as a single identifier
200        assert!(sql.starts_with("DROP TABLE IF EXISTS \""));
201        assert!(sql.ends_with('"'));
202        // Count quotes: 1 opening + 2 for doubled embedded quote + 1 closing = 4
203        let quote_count = sql.matches('"').count();
204        assert_eq!(quote_count, 4);
205    }
206}