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.
+
+
+
+
+
+
+
+
+
+ Features •
+ Quick Start •
+ Installation •
+ Configuration •
+ Usage •
+ Type Mappings
+
+
+---
-
-
+```
+$ 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