diff --git a/Cargo.lock b/Cargo.lock index bc9e3ef..c1d4572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,7 +454,7 @@ dependencies = [ [[package]] name = "db-migrator" -version = "0.3.0" +version = "0.3.2" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index cfedb53..9c41094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "db-migrator" -version = "0.3.0" +version = "0.3.2" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 0c54ae6..ea307b6 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,210 @@ -# MSSQL to MySQL Database Migration +

+

db-migrator

+

A fast, type-safe MSSQL to MySQL database migration tool written in Rust.

+

+ +

+ Rust + Release + License +

+ +

+ Features • + Quick Start • + Installation • + Configuration • + Usage • + Type Mappings +

+ +--- -![Rust Version](https://img.shields.io/badge/rust-1.61.0-orange.svg) -![License](https://img.shields.io/github/license/bitalizer/db-migrator) +``` +$ db-migrator --format --constraints --verbose -A Rust project to migrate MSSQL databases to MySQL, including table structures, column data types, and table data rows. +Migrating table: user_accounts +Table user_accounts migrated, rows: 12840, took: 1.23s +Migrating table: orders +Table orders migrated, rows: 58201, took: 3.47s +Migration finished, total time took: 4.82s +``` ## Features -- Connects to MSSQL and MySQL databases to perform the migration. -- Converts MSSQL table structures and column data types to their corresponding MySQL equivalents. -- Transfers table data rows from MSSQL to MySQL. -- Provides flexibility in configuring connection details, table mappings, and migration options. -- Handles differences in data types, constraints, and other database-specific details during the migration process. - -## Dependencies - -- [tokio](https://docs.rs/tokio/1) - Asynchronous runtime for Rust. -- [tokio-util](https://docs.rs/tokio-util/0.7) - Utilities for working with Tokio. -- [anyhow](https://docs.rs/anyhow/1.0) - Rust error handling library. -- [log](https://docs.rs/log/0.4) - Logging facade for Rust. -- [env_logger](https://docs.rs/env_logger/0.10) - Environment logger for Rust. -- [structopt](https://docs.rs/structopt/0.3) - Parse command line arguments in Rust. -- [chrono](https://docs.rs/chrono/0.4) - Date and time library for Rust. -- [toml](https://docs.rs/toml/0.7) - TOML parsing and serialization library for Rust. -- [async-trait](https://docs.rs/async-trait/0.1) - Async versions of Rust's trait objects. -- [hex](https://docs.rs/hex/0.4) - Hexadecimal encoding and decoding for Rust. -- [futures](https://docs.rs/futures/0.3) - Asynchronous programming using Rust's futures. -- [tiberius](https://docs.rs/tiberius/0.12) - MSSQL database driver for Rust. -- [bb8](https://docs.rs/bb8/0.8) - Connection pool for Rust. -- [bb8-tiberius](https://docs.rs/bb8-tiberius/0.15) - BB8 support for Tiberius. -- [sqlx](https://docs.rs/sqlx/0.6) - Database toolkit for Rust, including support for MySQL. +- **Schema + data migration** — transfers table structures, column types, constraints, and all rows +- **29 MSSQL types mapped** — built-in defaults for every type, zero config needed +- **Concurrent** — tables migrate in parallel with configurable parallelism +- **Batch inserts** — rows streamed and batched to respect MySQL `max_allowed_packet` +- **Constraints** — primary keys and foreign keys carried over automatically +- **Snake case** — optionally convert `PascalCase` names to `snake_case` +- **Customizable** — override any type mapping with a simple config file -## Usage +## Quick Start -### Option 1: Compile and Run - -1. Copy the `config.example.toml` file to `config.toml`. -2. Configure the connection details and whitelisted tables for the MSSQL and MySQL databases in the `config.toml` file. -3. Customize the table mappings and migration options in the `mappings.toml` file. -4. Build and run the migration tool using Cargo: ```shell +# 1. Configure your databases +cp config.example.toml config.toml # edit connection details + +# 2. Run cargo run --release ``` -### Option 2: Use Pre-compiled Binaries +That's it. All type mappings are built in — no extra config needed unless you want to [customize them](#type-mappings). -1. Go to the [GitHub Releases page](https://github.com/bitalizer/db-migrator/releases) of this repository. -2. Download the appropriate pre-compiled binary for your operating system and architecture. -3. Copy the `config.example.toml` file to `config.toml`. -4. Configure the connection details and whitelisted tables for the MSSQL and MySQL databases in the `config.toml` file. +## Installation + +### Pre-compiled Binaries -### Arguments +Download the latest binary for your platform from [Releases](https://github.com/bitalizer/db-migrator/releases). + +### Build from Source ```shell -USAGE: - db-migrator.exe [FLAGS] [OPTIONS] +git clone https://github.com/bitalizer/db-migrator.git +cd db-migrator +cargo build --release +``` + +Requires Rust 1.85+ (edition 2024). + +## Configuration -FLAGS: - -c, --constraints Create constraints - -d, --drop Drop tables before migration - -f, --format Format snake case table and column names - -h, --help Prints help information - -q, --quiet Activate quiet mode - -V, --version Prints version information - -v, --verbose Activate verbose mode +### Database Connections -OPTIONS: - -p, --parallelism Set parallelism [default: LOGICAL_CORES] +Create `config.toml` from the example: +```toml +[mssql_database] +host = "localhost" +port = 1433 +username = "db_user" +password = "db_pass" +database = "source_db" +[mysql_database] +host = "localhost" +port = 3306 +username = "db_user" +password = "db_pass" +database = "target_db" + +[settings] +max_packet_bytes = 1048576 +collation = "Latin1_General_CI_AS" +whitelisted_tables = ["Users", "Orders", "Products"] ``` -## Installation -Make sure you have Rust installed. You can install Rust from the official -website: https://www.rust-lang.org/tools/install +## Usage + +``` +db-migrator [FLAGS] [OPTIONS] +``` + +| Flag | Description | +|---|---| +| `-c, --constraints` | Create primary key and foreign key constraints | +| `-d, --drop` | Drop target tables before migration | +| `-f, --format` | Convert table and column names to snake_case | +| `-v, --verbose` | Verbose logging | +| `-q, --quiet` | Suppress output | + +| Option | Description | +|---|---| +| `-p, --parallelism ` | Max concurrent table migrations *(default: CPU cores)* | -Clone the repository: +### Examples ```shell -git clone https://github.com/bitalizer/db-migrator.git -``` \ No newline at end of file +# Basic migration +db-migrator + +# Full migration with snake_case and constraints +db-migrator --format --constraints + +# Verbose with custom parallelism +db-migrator -v -p 4 + +# Drop and recreate all tables +db-migrator --drop --constraints --format +``` + +## Type Mappings + +All 29 MSSQL types are mapped by default. No configuration required. + +
+View full type mapping table + +
+ +| MSSQL | MySQL | Notes | +|---|---|---| +| `bit` | `tinyint(1)` | Boolean equivalent | +| `tinyint` | `tinyint unsigned` | | +| `smallint` | `smallint` | | +| `int` | `int` | | +| `bigint` | `bigint` | | +| `decimal` / `numeric` | `decimal` | Precision and scale carried over | +| `money` | `decimal(19, 4)` | | +| `smallmoney` | `decimal(10, 2)` | | +| `float` | `float` | | +| `real` | `real` | | +| `char` / `nchar` | `char` | Length carried from source | +| `varchar` | `varchar` | Length carried, default 255 | +| `nvarchar` | `longtext` | Unicode safe | +| `text` / `ntext` | `longtext` | | +| `binary` | `binary` | Length carried from source | +| `varbinary` / `image` | `longblob` | | +| `date` | `date` | | +| `datetime` / `datetime2` / `smalldatetime` | `datetime` | | +| `datetimeoffset` | `datetime` | Offset stripped | +| `time` | `time` | | +| `uniqueidentifier` | `char(36)` | UUID as string | +| `timestamp` | `bigint` | Row version | +| `xml` | `longtext` | | + +
+ +### Custom Overrides *(optional)* + +To override any default mapping, create a `mappings.toml` file in the project root: + +```toml +[mappings] +nvarchar = "varchar(500)" +money = "decimal(10, 2)" +float = "float(53)" +xml = "longtext" +``` + +Supports three formats: `"type"`, `"type(length)"`, and `"type(precision, scale)"`. + +## Requirements + +- **Source:** MSSQL Server +- **Target:** MySQL 5.7+ / MariaDB 10.3+ +- **Build:** Rust 1.85+ + +
+Dependencies + +
+ +| Crate | Purpose | +|---|---| +| [tokio](https://docs.rs/tokio/1) | Async runtime | +| [tiberius](https://docs.rs/tiberius/0.12) | MSSQL driver | +| [sqlx](https://docs.rs/sqlx/0.8) | MySQL driver | +| [bb8](https://docs.rs/bb8/0.9) | Connection pooling | +| [clap](https://docs.rs/clap/4) | CLI parsing | +| [anyhow](https://docs.rs/anyhow/1.0) | Error handling | +| [chrono](https://docs.rs/chrono/0.4) | Date/time | +| [toml](https://docs.rs/toml/0.8) | Config parsing | + +
+ +## License + +See [LICENSE](LICENSE) for details. diff --git a/mappings.toml b/mappings.toml deleted file mode 100644 index 2f3bdb4..0000000 --- a/mappings.toml +++ /dev/null @@ -1,119 +0,0 @@ -[[mappings]] -from_type = "bit" -to_type = "tinyint" -type_parameters = true -numeric_precision = 1 - -[[mappings]] -from_type = "tinyint" -to_type = "tinyint" -type_parameters = true -numeric_precision = 3 - -[[mappings]] -from_type = "smallint" -to_type = "smallint" -type_parameters = true -numeric_precision = 5 - -[[mappings]] -from_type = "int" -to_type = "int" -type_parameters = true -numeric_precision = 10 - -[[mappings]] -from_type = "bigint" -to_type = "bigint" -type_parameters = true -numeric_precision = 19 - -[[mappings]] -from_type = "nchar" -to_type = "char" -type_parameters = true -max_characters_length = 1 - -[[mappings]] -from_type = "varchar" -to_type = "varchar" -type_parameters = true -max_characters_length = 255 - -[[mappings]] -from_type = "nvarchar" -to_type = "longtext" - -[[mappings]] -from_type = "text" -to_type = "text" - -[[mappings]] -from_type = "ntext" -to_type = "longtext" - -[[mappings]] -from_type = "uniqueidentifier" -to_type = "char" -type_parameters = true -max_characters_length = 36 - -[[mappings]] -from_type = "decimal" -to_type = "decimal" -type_parameters = true -numeric_precision = 10 -numeric_scale = 2 - -[[mappings]] -from_type = "float" -to_type = "float" - -[[mappings]] -from_type = "real" -to_type = "real" - -[[mappings]] -from_type = "numeric" -to_type = "decimal" -type_parameters = true -numeric_precision = 18 -numeric_scale = 0 - -[[mappings]] -from_type = "smallmoney" -to_type = "decimal" -type_parameters = true -numeric_precision = 10 -numeric_scale = 2 - -[[mappings]] -from_type = "money" -to_type = "decimal" -type_parameters = true -numeric_precision = 19 -numeric_scale = 4 - -[[mappings]] -from_type = "timestamp" -to_type = "timestamp" - -[[mappings]] -from_type = "datetimeoffset" -to_type = "datetime" - -[[mappings]] -from_type = "date" -to_type = "datetime" - -[[mappings]] -from_type = "datetime" -to_type = "datetime" - -[[mappings]] -from_type = "datetime2" -to_type = "datetime" - -[[mappings]] -from_type = "binary" -to_type = "binary" \ No newline at end of file diff --git a/src/common/errors.rs b/src/common/errors.rs index 6a16203..bdd2258 100644 --- a/src/common/errors.rs +++ b/src/common/errors.rs @@ -3,9 +3,6 @@ use std::fmt; /// Errors that can occur during the migration process. #[derive(Debug)] pub enum MigrationError { - /// A required type mapping was not found in mappings.toml - MappingNotFound { data_type: String }, - /// The target table already contains rows and cannot be migrated into TableAlreadyHasRows { table: String, count: i64 }, @@ -28,13 +25,6 @@ pub enum MigrationError { impl fmt::Display for MigrationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - MigrationError::MappingNotFound { data_type } => { - write!( - f, - "No type mapping found for MSSQL data type '{}'. Add it to mappings.toml", - data_type - ) - } MigrationError::TableAlreadyHasRows { table, count } => { write!( f, diff --git a/src/common/mod.rs b/src/common/mod.rs index 7067a99..a1c8f5d 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,5 +1,9 @@ pub mod constraints; pub mod errors; pub mod helpers; +pub mod mssql_type; +pub mod mysql_type; pub mod schema; pub mod sql; +pub mod target_schema; +pub mod type_mapping_entry; diff --git a/src/common/mssql_type.rs b/src/common/mssql_type.rs new file mode 100644 index 0000000..91d439a --- /dev/null +++ b/src/common/mssql_type.rs @@ -0,0 +1,191 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MssqlType { + Bit, + TinyInt, + SmallInt, + Int, + BigInt, + Decimal, + Numeric, + Money, + SmallMoney, + Float, + Real, + Char, + NChar, + Varchar, + NVarchar, + Text, + NText, + Binary, + VarBinary, + Image, + Date, + DateTime, + DateTime2, + SmallDateTime, + DateTimeOffset, + Time, + UniqueIdentifier, + Timestamp, + Xml, +} + +impl MssqlType { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "bit" => Some(Self::Bit), + "tinyint" => Some(Self::TinyInt), + "smallint" => Some(Self::SmallInt), + "int" => Some(Self::Int), + "bigint" => Some(Self::BigInt), + "decimal" => Some(Self::Decimal), + "numeric" => Some(Self::Numeric), + "money" => Some(Self::Money), + "smallmoney" => Some(Self::SmallMoney), + "float" => Some(Self::Float), + "real" => Some(Self::Real), + "char" => Some(Self::Char), + "nchar" => Some(Self::NChar), + "varchar" => Some(Self::Varchar), + "nvarchar" => Some(Self::NVarchar), + "text" => Some(Self::Text), + "ntext" => Some(Self::NText), + "binary" => Some(Self::Binary), + "varbinary" => Some(Self::VarBinary), + "image" => Some(Self::Image), + "date" => Some(Self::Date), + "datetime" => Some(Self::DateTime), + "datetime2" => Some(Self::DateTime2), + "smalldatetime" => Some(Self::SmallDateTime), + "datetimeoffset" => Some(Self::DateTimeOffset), + "time" => Some(Self::Time), + "uniqueidentifier" => Some(Self::UniqueIdentifier), + "timestamp" => Some(Self::Timestamp), + "xml" => Some(Self::Xml), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Bit => "bit", + Self::TinyInt => "tinyint", + Self::SmallInt => "smallint", + Self::Int => "int", + Self::BigInt => "bigint", + Self::Decimal => "decimal", + Self::Numeric => "numeric", + Self::Money => "money", + Self::SmallMoney => "smallmoney", + Self::Float => "float", + Self::Real => "real", + Self::Char => "char", + Self::NChar => "nchar", + Self::Varchar => "varchar", + Self::NVarchar => "nvarchar", + Self::Text => "text", + Self::NText => "ntext", + Self::Binary => "binary", + Self::VarBinary => "varbinary", + Self::Image => "image", + Self::Date => "date", + Self::DateTime => "datetime", + Self::DateTime2 => "datetime2", + Self::SmallDateTime => "smalldatetime", + Self::DateTimeOffset => "datetimeoffset", + Self::Time => "time", + Self::UniqueIdentifier => "uniqueidentifier", + Self::Timestamp => "timestamp", + Self::Xml => "xml", + } + } +} + +impl fmt::Display for MssqlType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_str_standard_types() { + assert_eq!(MssqlType::from_str("int"), Some(MssqlType::Int)); + assert_eq!(MssqlType::from_str("INT"), Some(MssqlType::Int)); + assert_eq!(MssqlType::from_str("nvarchar"), Some(MssqlType::NVarchar)); + assert_eq!(MssqlType::from_str("datetime2"), Some(MssqlType::DateTime2)); + assert_eq!( + MssqlType::from_str("uniqueidentifier"), + Some(MssqlType::UniqueIdentifier) + ); + } + + #[test] + fn test_from_str_unknown() { + assert_eq!(MssqlType::from_str("geometry"), None); + assert_eq!(MssqlType::from_str(""), None); + assert_eq!(MssqlType::from_str("varchat"), None); + } + + #[test] + fn test_as_str_roundtrip() { + let all_variants = [ + MssqlType::Bit, + MssqlType::TinyInt, + MssqlType::SmallInt, + MssqlType::Int, + MssqlType::BigInt, + MssqlType::Decimal, + MssqlType::Numeric, + MssqlType::Money, + MssqlType::SmallMoney, + MssqlType::Float, + MssqlType::Real, + MssqlType::Char, + MssqlType::NChar, + MssqlType::Varchar, + MssqlType::NVarchar, + MssqlType::Text, + MssqlType::NText, + MssqlType::Binary, + MssqlType::VarBinary, + MssqlType::Image, + MssqlType::Date, + MssqlType::DateTime, + MssqlType::DateTime2, + MssqlType::SmallDateTime, + MssqlType::DateTimeOffset, + MssqlType::Time, + MssqlType::UniqueIdentifier, + MssqlType::Timestamp, + MssqlType::Xml, + ]; + + for variant in all_variants { + assert_eq!( + MssqlType::from_str(variant.as_str()), + Some(variant), + "roundtrip failed for {:?}", + variant + ); + } + } + + #[test] + fn test_display() { + assert_eq!(format!("{}", MssqlType::Int), "int"); + assert_eq!(format!("{}", MssqlType::NVarchar), "nvarchar"); + assert_eq!(format!("{}", MssqlType::DateTime2), "datetime2"); + assert_eq!( + format!("{}", MssqlType::UniqueIdentifier), + "uniqueidentifier" + ); + assert_eq!(format!("{}", MssqlType::VarBinary), "varbinary"); + } +} diff --git a/src/common/mysql_type.rs b/src/common/mysql_type.rs new file mode 100644 index 0000000..d703a4b --- /dev/null +++ b/src/common/mysql_type.rs @@ -0,0 +1,347 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MySqlBaseType { + TinyInt, + SmallInt, + Int, + BigInt, + Decimal, + Float, + Real, + Char, + Varchar, + Text, + LongText, + Binary, + VarBinary, + LongBlob, + DateTime, + Timestamp, + Date, + Time, +} + +impl MySqlBaseType { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "tinyint" => Some(Self::TinyInt), + "smallint" => Some(Self::SmallInt), + "int" => Some(Self::Int), + "bigint" => Some(Self::BigInt), + "decimal" => Some(Self::Decimal), + "float" => Some(Self::Float), + "real" => Some(Self::Real), + "char" => Some(Self::Char), + "varchar" => Some(Self::Varchar), + "text" => Some(Self::Text), + "longtext" => Some(Self::LongText), + "binary" => Some(Self::Binary), + "varbinary" => Some(Self::VarBinary), + "longblob" => Some(Self::LongBlob), + "datetime" => Some(Self::DateTime), + "timestamp" => Some(Self::Timestamp), + "date" => Some(Self::Date), + "time" => Some(Self::Time), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::TinyInt => "tinyint", + Self::SmallInt => "smallint", + Self::Int => "int", + Self::BigInt => "bigint", + Self::Decimal => "decimal", + Self::Float => "float", + Self::Real => "real", + Self::Char => "char", + Self::Varchar => "varchar", + Self::Text => "text", + Self::LongText => "longtext", + Self::Binary => "binary", + Self::VarBinary => "varbinary", + Self::LongBlob => "longblob", + Self::DateTime => "datetime", + Self::Timestamp => "timestamp", + Self::Date => "date", + Self::Time => "time", + } + } + + pub fn accepts_length(&self) -> bool { + matches!( + self, + Self::Varchar | Self::Char | Self::Binary | Self::VarBinary + ) + } + + pub fn accepts_precision(&self) -> bool { + matches!(self, Self::Decimal | Self::Float | Self::Real) + } + + pub fn accepts_unsigned(&self) -> bool { + matches!( + self, + Self::TinyInt + | Self::SmallInt + | Self::Int + | Self::BigInt + | Self::Decimal + | Self::Float + | Self::Real + ) + } + + pub fn max_length(&self) -> Option { + match self { + Self::Char => Some(255), + Self::Varchar => Some(65535), + Self::Binary => Some(255), + Self::VarBinary => Some(65535), + _ => None, + } + } +} + +impl fmt::Display for MySqlBaseType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MySqlType { + pub base_type: MySqlBaseType, + pub length: Option, + pub precision: Option, + pub scale: Option, + pub unsigned: bool, + pub zerofill: bool, +} + +impl MySqlType { + pub fn to_sql(&self) -> String { + let mut s = self.base_type.as_str().to_string(); + + if self.base_type.accepts_length() + && let Some(len) = self.length + { + s.push_str(&format!("({})", len)); + } else if self.base_type.accepts_precision() + && let Some(prec) = self.precision + { + if let Some(scale) = self.scale { + s.push_str(&format!("({}, {})", prec, scale)); + } else { + s.push_str(&format!("({})", prec)); + } + } + + if self.base_type.accepts_unsigned() { + if self.unsigned { + s.push_str(" unsigned"); + } + if self.zerofill { + s.push_str(" zerofill"); + } + } + + s + } +} + +impl fmt::Display for MySqlType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_sql()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // MySqlBaseType tests + + #[test] + fn test_base_type_as_str() { + assert_eq!(MySqlBaseType::Int.as_str(), "int"); + assert_eq!(MySqlBaseType::BigInt.as_str(), "bigint"); + assert_eq!(MySqlBaseType::Varchar.as_str(), "varchar"); + assert_eq!(MySqlBaseType::LongText.as_str(), "longtext"); + assert_eq!(MySqlBaseType::DateTime.as_str(), "datetime"); + } + + #[test] + fn test_from_str_valid() { + assert_eq!(MySqlBaseType::from_str("int"), Some(MySqlBaseType::Int)); + assert_eq!(MySqlBaseType::from_str("INT"), Some(MySqlBaseType::Int)); + assert_eq!( + MySqlBaseType::from_str("varchar"), + Some(MySqlBaseType::Varchar) + ); + assert_eq!( + MySqlBaseType::from_str("longtext"), + Some(MySqlBaseType::LongText) + ); + } + + #[test] + fn test_from_str_unknown() { + assert_eq!(MySqlBaseType::from_str("geometry"), None); + assert_eq!(MySqlBaseType::from_str(""), None); + } + + #[test] + fn test_accepts_length() { + assert!(MySqlBaseType::Varchar.accepts_length()); + assert!(MySqlBaseType::Char.accepts_length()); + assert!(MySqlBaseType::Binary.accepts_length()); + assert!(MySqlBaseType::VarBinary.accepts_length()); + assert!(!MySqlBaseType::Int.accepts_length()); + assert!(!MySqlBaseType::LongText.accepts_length()); + } + + #[test] + fn test_accepts_precision() { + assert!(MySqlBaseType::Decimal.accepts_precision()); + assert!(MySqlBaseType::Float.accepts_precision()); + assert!(MySqlBaseType::Real.accepts_precision()); + assert!(!MySqlBaseType::Int.accepts_precision()); + assert!(!MySqlBaseType::Varchar.accepts_precision()); + } + + #[test] + fn test_accepts_unsigned() { + assert!(MySqlBaseType::Int.accepts_unsigned()); + assert!(MySqlBaseType::BigInt.accepts_unsigned()); + assert!(MySqlBaseType::Decimal.accepts_unsigned()); + assert!(!MySqlBaseType::Varchar.accepts_unsigned()); + assert!(!MySqlBaseType::DateTime.accepts_unsigned()); + assert!(!MySqlBaseType::LongText.accepts_unsigned()); + } + + #[test] + fn test_max_length() { + assert_eq!(MySqlBaseType::Char.max_length(), Some(255)); + assert_eq!(MySqlBaseType::Varchar.max_length(), Some(65535)); + assert_eq!(MySqlBaseType::Binary.max_length(), Some(255)); + assert_eq!(MySqlBaseType::VarBinary.max_length(), Some(65535)); + assert_eq!(MySqlBaseType::Int.max_length(), None); + assert_eq!(MySqlBaseType::LongText.max_length(), None); + } + + #[test] + fn test_display() { + assert_eq!(format!("{}", MySqlBaseType::Int), "int"); + assert_eq!(format!("{}", MySqlBaseType::Varchar), "varchar"); + assert_eq!(format!("{}", MySqlBaseType::LongText), "longtext"); + } + + // MySqlType tests + + #[test] + fn test_to_sql_base_only() { + let t = MySqlType { + base_type: MySqlBaseType::LongText, + length: None, + precision: None, + scale: None, + unsigned: false, + zerofill: false, + }; + assert_eq!(t.to_sql(), "longtext"); + } + + #[test] + fn test_to_sql_with_length() { + let t = MySqlType { + base_type: MySqlBaseType::Varchar, + length: Some(255), + precision: None, + scale: None, + unsigned: false, + zerofill: false, + }; + assert_eq!(t.to_sql(), "varchar(255)"); + } + + #[test] + fn test_to_sql_with_precision_and_scale() { + let t = MySqlType { + base_type: MySqlBaseType::Decimal, + length: None, + precision: Some(10), + scale: Some(2), + unsigned: false, + zerofill: false, + }; + assert_eq!(t.to_sql(), "decimal(10, 2)"); + } + + #[test] + fn test_to_sql_with_precision_only() { + let t = MySqlType { + base_type: MySqlBaseType::Float, + length: None, + precision: Some(24), + scale: None, + unsigned: false, + zerofill: false, + }; + assert_eq!(t.to_sql(), "float(24)"); + } + + #[test] + fn test_to_sql_unsigned() { + let t = MySqlType { + base_type: MySqlBaseType::Int, + length: None, + precision: None, + scale: None, + unsigned: true, + zerofill: false, + }; + assert_eq!(t.to_sql(), "int unsigned"); + } + + #[test] + fn test_to_sql_unsigned_zerofill() { + let t = MySqlType { + base_type: MySqlBaseType::BigInt, + length: None, + precision: None, + scale: None, + unsigned: true, + zerofill: true, + }; + assert_eq!(t.to_sql(), "bigint unsigned zerofill"); + } + + #[test] + fn test_to_sql_ignores_unsigned_on_non_numeric() { + let t = MySqlType { + base_type: MySqlBaseType::LongText, + length: None, + precision: None, + scale: None, + unsigned: true, + zerofill: false, + }; + assert_eq!(t.to_sql(), "longtext"); + } + + #[test] + fn test_to_sql_ignores_length_on_non_length_type() { + let t = MySqlType { + base_type: MySqlBaseType::LongText, + length: Some(255), + precision: None, + scale: None, + unsigned: false, + zerofill: false, + }; + assert_eq!(t.to_sql(), "longtext"); + } +} diff --git a/src/common/schema.rs b/src/common/schema.rs index d8f10f9..60688cf 100644 --- a/src/common/schema.rs +++ b/src/common/schema.rs @@ -3,11 +3,12 @@ use tiberius::Row; use crate::common::constraints::Constraint; use crate::common::errors::MigrationError; +use crate::common::mssql_type::MssqlType; #[derive(Debug, Clone)] pub struct ColumnSchema { pub column_name: String, - pub data_type: String, + pub data_type: MssqlType, pub character_maximum_length: Option, pub numeric_precision: Option, pub numeric_scale: Option, @@ -19,8 +20,14 @@ impl ColumnSchema { pub fn from_row(row: &Row) -> Result { let column_name: String = Column::get(row, "COLUMN_NAME").context("Failed to read COLUMN_NAME")?; - let data_type: String = + let data_type_str: String = Column::get(row, "DATA_TYPE").context("Failed to read DATA_TYPE")?; + let data_type = MssqlType::from_str(&data_type_str).ok_or_else(|| { + anyhow!( + "Unknown MSSQL data type '{}' for column '{}'. This type is not supported by the migrator.", + data_type_str, column_name + ) + })?; let character_maximum_length: Option = Column::get(row, "CHARACTER_MAXIMUM_LENGTH") .context("Failed to read CHARACTER_MAXIMUM_LENGTH")?; let numeric_precision: Option = diff --git a/src/common/target_schema.rs b/src/common/target_schema.rs new file mode 100644 index 0000000..240728a --- /dev/null +++ b/src/common/target_schema.rs @@ -0,0 +1,39 @@ +use crate::common::constraints::Constraint; +use crate::common::mysql_type::MySqlType; + +/// A column in the MySQL target schema. +#[derive(Debug, Clone)] +pub struct TargetColumn { + pub column_name: String, + pub data_type: MySqlType, + pub is_nullable: bool, + pub constraints: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::constraints::Constraint; + use crate::common::mysql_type::{MySqlBaseType, MySqlType}; + + #[test] + fn test_target_column_creation() { + let col = TargetColumn { + column_name: "user_id".to_string(), + data_type: MySqlType { + base_type: MySqlBaseType::Int, + length: None, + precision: None, + scale: None, + unsigned: true, + zerofill: false, + }, + is_nullable: false, + constraints: Some(Constraint::PrimaryKey), + }; + assert_eq!(col.column_name, "user_id"); + assert_eq!(col.data_type.to_sql(), "int unsigned"); + assert!(!col.is_nullable); + assert_eq!(col.constraints, Some(Constraint::PrimaryKey)); + } +} diff --git a/src/common/type_mapping_entry.rs b/src/common/type_mapping_entry.rs new file mode 100644 index 0000000..7f0fc59 --- /dev/null +++ b/src/common/type_mapping_entry.rs @@ -0,0 +1,15 @@ +use crate::common::mysql_type::MySqlBaseType; + +/// A single MSSQL-to-MySQL type mapping entry. +/// Lives in common/ because both the built-in registry and user overrides need it. +#[derive(Debug, Clone)] +pub struct TypeMappingEntry { + pub mysql_type: MySqlBaseType, + pub carry_length: bool, + pub carry_precision: bool, + pub default_length: Option, + pub default_precision: Option, + pub default_scale: Option, + pub unsigned: bool, + pub zerofill: bool, +} diff --git a/src/extract/format.rs b/src/extract/format.rs index c99537b..7b301f9 100644 --- a/src/extract/format.rs +++ b/src/extract/format.rs @@ -14,14 +14,14 @@ pub fn format_row_values(row: Row) -> Result> { pub fn format_column_value(item: ColumnData) -> Result { match item { - ColumnData::Binary(Some(val)) => Ok(format!("'0x{}'", encode(val))), + ColumnData::Binary(Some(val)) => Ok(format!("0x{}", encode(val))), ColumnData::Binary(None) => Ok("NULL".to_string()), - ColumnData::Bit(val) => Ok(val.unwrap_or_default().to_string()), + ColumnData::Bit(val) => Ok(format_number_value(val.map(|b| b as u8))), ColumnData::I16(val) => Ok(format_number_value(val)), ColumnData::I32(val) => Ok(format_number_value(val)), ColumnData::I64(val) => Ok(format_number_value(val)), - ColumnData::F32(val) => Ok(format_string_value(val)), - ColumnData::F64(val) => Ok(format_string_value(val)), + ColumnData::F32(val) => Ok(format_number_value(val)), + ColumnData::F64(val) => Ok(format_number_value(val)), ColumnData::Guid(val) => Ok(format_string_value(val)), ColumnData::Numeric(val) => Ok(format_numeric_value(val)), ColumnData::String(val) => Ok(format_string_value(val)), @@ -31,7 +31,7 @@ pub fn format_column_value(item: ColumnData) -> Result { ColumnData::DateTime(ref val) => format_datetime(val), ColumnData::DateTime2(ref val) => format_datetime2(val), ColumnData::DateTimeOffset(ref val) => format_datetime_offset(val), - ColumnData::U8(val) => Ok(val.unwrap_or_default().to_string()), + ColumnData::U8(val) => Ok(format_number_value(val)), ColumnData::Xml(val) => match val { Some(xml) => Ok(format_string_value(Some(xml.as_ref().to_string()))), None => Ok("NULL".to_string()), @@ -79,7 +79,7 @@ pub fn format_time(val: &Option