diff --git a/.sqlxrc.sample.json b/.sqlxrc.sample.json index ee7909cf..0ef0204d 100644 --- a/.sqlxrc.sample.json +++ b/.sqlxrc.sample.json @@ -14,6 +14,21 @@ "DB_NAME": "postgres", "PG_SEARCH_PATH": "public,myschema" }, + "db_custom_types": { + "DB_TYPE": "postgres", + "DB_HOST": "127.0.0.1", + "DB_PORT": 54321, + "DB_USER": "postgres", + "DB_PASS": "postgres", + "DB_NAME": "postgres", + "PG_SEARCH_PATH": "public,myschema", + "type_mapping": { + "bigint": "string", + "interval": "string", + "timestamp": { "type": "DateTime", "import": "import type { DateTime } from 'luxon'" }, + "timestamptz": { "type": "Dayjs", "import": "import type { Dayjs } from 'dayjs'" } + } + }, "db_mysql": { "DB_TYPE": "mysql", "DB_HOST": "127.0.0.1", diff --git a/Cargo.lock b/Cargo.lock index fc1c7148..3d10d4a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,13 +137,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f349889e..b9971b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ colored = "3.1.1" mysql_async = { version = "0.36.2", features = ["minimal"] } tokio-postgres = "0.7.17" tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "default"]} -async-recursion = "1.1.1" +async-recursion = "1.2.0" bb8 = "0.9.1" rusqlite = { version = "0.39", features = ["bundled"] } log = "0.4.29" diff --git a/book/docs/connect/config-file.md b/book/docs/connect/config-file.md index 15efa981..fa00c51b 100644 --- a/book/docs/connect/config-file.md +++ b/book/docs/connect/config-file.md @@ -116,6 +116,52 @@ Supported fields of each connection include - `PG_SEARCH_PATH`: PostgreSQL schema search path (default is "$user,public") [https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH](https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH) - `POOL_SIZE`: Size of the connection pool to establish per connection type - `CONNECTION_TIMEOUT`: Timeout in second of Database connection attempt +- `type_mapping`: Overrides the generated TypeScript type of database column types (see below) + +#### type_mapping + +By default, SQLX-TS translates each database column type into a built-in TypeScript type (e.g. `bigint` -> `number`). +You can override this per connection by mapping a database column type to any TypeScript type. A mapping is either +a type, or an object with `type` and an optional `import`; the import statement is added at the top of the generated +types file. + +```json +{ + "connections": { + "default": { + "DB_TYPE": "postgres", + "DB_URL": "postgres://postgres:postgres@127.0.0.1:5432/mydb", + "type_mapping": { + "bigint": "string", + "numeric": "string | number", + "_int8": "string[]", + "timestamp": { "type": "DateTime", "import": "import type { DateTime } from 'luxon'" } + } + } + } +} +``` + +**Matching** + +- Keys are matched case-insensitively against the column type reported by the database, e.g. `bigint`, `DATETIME` +- An exact match wins, otherwise type modifiers are ignored, so `varchar` matches `VARCHAR(255)` and `bigint unsigned` matches `bigint(20) unsigned` +- PostgreSQL: both the `data_type` (e.g. `timestamp without time zone`) and the `udt_name` (e.g. `timestamp`, `int8`, or the name of an enum or extension type such as `citext`) are matched. Domain types are matched by their underlying type +- PostgreSQL arrays: array columns are reported as `ARRAY`, so the mapping of the element type does not apply. Map the array's `udt_name` instead, which is the element type prefixed with `_`, e.g. `"_int8": "string[]"` +- MySQL: both the full `COLUMN_TYPE` (e.g. `tinyint(1)`, `bigint unsigned`) and the `DATA_TYPE` (e.g. `tinyint`, `bigint`) are matched, with `COLUMN_TYPE` taking priority. This lets you map `"tinyint(1)": "boolean"` separately from `"tinyint": "number"` + +**Generated types** + +- Nullable columns still produce `| null`, e.g. `string | null` +- The mapping applies to both query results and parameters +- `@result` and `@param` [annotations](../type-generation/annotations.md) take priority over the mapping +- The mapping only applies to values typed from a table column, including expressions derived from one such as `COALESCE(col, 0)`. Types from the SQL itself, such as `CAST(col AS BIGINT)` or column definitions of a table-valued function (`jsonb_to_recordset($1) AS t(id BIGINT)`), are not mapped + +**Imports** + +- Each import is written once per generated file, including when all types are generated into a single file with `--generate-path` +- Multi-line imports are written on a single line +- Imports are de-duplicated by their text, so use the same import statement for every mapping that imports the same type. For example, `import type { DateTime } from 'luxon'` and `import type { DateTime } from "luxon"` are written as two imports, which TypeScript reports as a duplicate identifier ### generate_types diff --git a/playpen/db/mysql_migration.sql b/playpen/db/mysql_migration.sql index ca03f912..e06d9a3a 100644 --- a/playpen/db/mysql_migration.sql +++ b/playpen/db/mysql_migration.sql @@ -223,3 +223,11 @@ INSERT INTO json_test_data (name, data, metadata) VALUES ('analytics', '{"date": "2024-01-15", "metrics": {"visitors": 1500, "pageViews": 4500, "bounceRate": 0.35, "sources": {"organic": 850, "direct": 400, "referral": 250}}}', '{"source": "analytics", "version": "1.0"}'); + +CREATE TABLE type_mapping_test ( + id INT PRIMARY KEY, + is_active TINYINT(1) NOT NULL, + flags TINYINT NOT NULL, + big_id BIGINT UNSIGNED NOT NULL, + amount BIGINT NOT NULL +); diff --git a/playpen/db/mysql_migration_5_6.sql b/playpen/db/mysql_migration_5_6.sql index 0c805fe2..d5566a58 100644 --- a/playpen/db/mysql_migration_5_6.sql +++ b/playpen/db/mysql_migration_5_6.sql @@ -209,3 +209,11 @@ INSERT INTO json_test_data (name, data, metadata) VALUES ('analytics', '{"date": "2024-01-15", "metrics": {"visitors": 1500, "pageViews": 4500, "bounceRate": 0.35, "sources": {"organic": 850, "direct": 400, "referral": 250}}}', '{"source": "analytics", "version": "1.0"}'); + +CREATE TABLE type_mapping_test ( + id INT PRIMARY KEY, + is_active TINYINT(1) NOT NULL, + flags TINYINT NOT NULL, + big_id BIGINT UNSIGNED NOT NULL, + amount BIGINT NOT NULL +); diff --git a/src/common/config.rs b/src/common/config.rs index 61cd3369..7bb74793 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -7,6 +7,7 @@ use regex::Regex; use serde; use serde::{Deserialize, Serialize}; use serde_json; +use serde_json::Value as JsonValue; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -20,6 +21,69 @@ pub struct SqlxConfig { pub connections: HashMap, } +#[derive(Clone, Debug)] +pub enum CustomTypeMapping { + Simple(String), + WithImport { type_name: String, import: String }, +} + +impl<'de> Deserialize<'de> for CustomTypeMapping { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = JsonValue::deserialize(deserializer)?; + let non_empty = |type_name: &str| -> Result { + let type_name = type_name.trim(); + if type_name.is_empty() { + return Err(serde::de::Error::custom("type in type_mapping cannot be empty")); + } + Ok(type_name.to_string()) + }; + match value { + JsonValue::String(s) => Ok(CustomTypeMapping::Simple(non_empty(&s)?)), + JsonValue::Object(map) => { + let type_name = map + .get("type") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + let type_name = non_empty(type_name)?; + let import = match map.get("import") { + None | Some(JsonValue::Null) => None, + Some(JsonValue::String(import)) if import.trim().is_empty() => None, + Some(JsonValue::String(import)) => Some(import.to_string()), + Some(_) => return Err(serde::de::Error::custom("import in type_mapping must be a string")), + }; + match import { + Some(import) => Ok(CustomTypeMapping::WithImport { type_name, import }), + None => Ok(CustomTypeMapping::Simple(type_name)), + } + } + _ => Err(serde::de::Error::custom( + "Expected a string or an object for CustomTypeMapping", + )), + } + } +} + +impl Serialize for CustomTypeMapping { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + CustomTypeMapping::Simple(s) => serializer.serialize_str(s), + CustomTypeMapping::WithImport { type_name, import } => { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", type_name)?; + map.serialize_entry("import", import)?; + map.end() + } + } + } +} + pub const fn default_bool() -> bool { V } @@ -58,6 +122,47 @@ pub struct DbConnectionConfig { pub pool_size: u32, #[serde(rename = "CONNECTION_TIMEOUT", default = "default_connection_timeout")] pub connection_timeout: u64, + #[serde(default)] + pub type_mapping: Option>, +} + +impl DbConnectionConfig { + pub fn find_type_mapping(&self, db_types: &[&str]) -> Option<&CustomTypeMapping> { + let type_mapping = self.type_mapping.as_ref()?; + let normalized: HashMap = type_mapping + .iter() + .map(|(k, v)| (k.trim().to_lowercase(), v)) + .collect(); + + db_types.iter().find_map(|db_type| { + let db_type = db_type + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + normalized + .get(&db_type) + .or_else(|| normalized.get(&strip_type_modifiers(&db_type))) + .copied() + }) + } +} + +/// e.g. `bigint(20) unsigned` -> `bigint unsigned`, `varchar(255)` -> `varchar` +fn strip_type_modifiers(db_type: &str) -> String { + let mut depth = 0; + let stripped: String = db_type + .chars() + .filter(|c| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => return depth == 0, + } + false + }) + .collect(); + stripped.split_whitespace().collect::>().join(" ") } fn default_pool_size() -> u32 { @@ -317,6 +422,8 @@ impl Config { .or_else(|| Some(default_connection_timeout())) .unwrap(); + let type_mapping = default_config.and_then(|x| x.type_mapping.clone()); + DbConnectionConfig { db_type: db_type.to_owned(), db_host, @@ -328,6 +435,7 @@ impl Config { pg_search_path: pg_search_path.to_owned(), pool_size, connection_timeout, + type_mapping, } } diff --git a/src/common/config.test.rs b/src/common/config.test.rs new file mode 100644 index 00000000..2c106aa9 --- /dev/null +++ b/src/common/config.test.rs @@ -0,0 +1,79 @@ +#[cfg(test)] +mod tests { + use crate::common::config::{CustomTypeMapping, DbConnectionConfig}; + + fn connection_with_mapping(type_mapping: &str) -> DbConnectionConfig { + serde_json::from_str(&format!(r#"{{ "DB_TYPE": "mysql", "type_mapping": {type_mapping} }}"#)).unwrap() + } + + fn mapped_type(conn: &DbConnectionConfig, db_types: &[&str]) -> Option { + conn.find_type_mapping(db_types).map(|mapping| match mapping { + CustomTypeMapping::Simple(type_name) | CustomTypeMapping::WithImport { type_name, .. } => type_name.to_owned(), + }) + } + + #[test] + fn should_match_case_insensitively() { + let conn = connection_with_mapping(r#"{ "BigInt": "string" }"#); + assert_eq!(mapped_type(&conn, &["BIGINT"]), Some("string".to_string())); + } + + #[test] + fn should_ignore_type_modifiers() { + let conn = connection_with_mapping(r#"{ "varchar": "Name", "bigint unsigned": "string" }"#); + assert_eq!(mapped_type(&conn, &["VARCHAR(255)"]), Some("Name".to_string())); + assert_eq!(mapped_type(&conn, &["bigint(20) unsigned"]), Some("string".to_string())); + assert_eq!(mapped_type(&conn, &["bigint unsigned"]), Some("string".to_string())); + } + + #[test] + fn should_prefer_exact_match_and_earlier_candidates() { + let conn = connection_with_mapping(r#"{ "tinyint(1)": "boolean", "tinyint": "number", "bigint": "bigint" }"#); + assert_eq!( + mapped_type(&conn, &["tinyint(1)", "tinyint"]), + Some("boolean".to_string()) + ); + assert_eq!( + mapped_type(&conn, &["tinyint(4)", "tinyint"]), + Some("number".to_string()) + ); + assert_eq!( + mapped_type(&conn, &["bigint(20) unsigned", "bigint"]), + Some("bigint".to_string()) + ); + } + + #[test] + fn should_return_none_without_mapping() { + let conn = connection_with_mapping("null"); + assert_eq!(mapped_type(&conn, &["bigint"]), None); + let conn = connection_with_mapping(r#"{ "bigint": "string" }"#); + assert_eq!(mapped_type(&conn, &["integer"]), None); + } + + #[test] + fn should_parse_object_mapping_with_optional_import() { + let conn = connection_with_mapping( + r#"{ "a": { "type": "A" }, "b": { "type": "B", "import": "" }, "c": { "type": "C", "import": "import type { C } from 'c'" } }"#, + ); + let mapping = conn.type_mapping.unwrap(); + assert!(matches!(&mapping["a"], CustomTypeMapping::Simple(t) if t == "A")); + assert!(matches!(&mapping["b"], CustomTypeMapping::Simple(t) if t == "B")); + assert!(matches!(&mapping["c"], CustomTypeMapping::WithImport { type_name, .. } if type_name == "C")); + } + + #[test] + fn should_reject_invalid_mappings() { + for type_mapping in [ + r#"{ "bigint": "" }"#, + r#"{ "bigint": { "type": " " } }"#, + r#"{ "bigint": { "import": "import x from 'x'" } }"#, + r#"{ "bigint": { "type": "X", "import": 1 } }"#, + r#"{ "bigint": 1 }"#, + ] { + let result: Result = + serde_json::from_str(&format!(r#"{{ "DB_TYPE": "mysql", "type_mapping": {type_mapping} }}"#)); + assert!(result.is_err(), "Expected {type_mapping} to be rejected"); + } + } +} diff --git a/src/common/lazy.rs b/src/common/lazy.rs index 7e15dfc0..23353518 100644 --- a/src/common/lazy.rs +++ b/src/common/lazy.rs @@ -47,7 +47,7 @@ pub static DB_CONN_CACHE: LazyLock>>> = LazyLo .await .expect(&ERR_DB_CONNECTION_ISSUE); - DBConn::MySQLPooledConn(Mutex::new(pool)) + DBConn::MySQLPooledConn(Mutex::new(pool), connection.to_string()) }) }), DatabaseType::Sqlite => task::block_in_place(|| { @@ -61,7 +61,7 @@ pub static DB_CONN_CACHE: LazyLock>>> = LazyLo .await .expect(&ERR_DB_CONNECTION_ISSUE); - DBConn::SqliteConn(Mutex::new(pool)) + DBConn::SqliteConn(Mutex::new(pool), connection.to_string()) }) }), DatabaseType::Postgres => task::block_in_place(|| { @@ -75,10 +75,10 @@ pub static DB_CONN_CACHE: LazyLock>>> = LazyLo .await .expect(&ERR_DB_CONNECTION_ISSUE); - let db_conn = DBConn::PostgresConn(Mutex::new(pool)); + let db_conn = DBConn::PostgresConn(Mutex::new(pool), connection.to_string()); let conn = match &db_conn { - DBConn::PostgresConn(conn) => conn, + DBConn::PostgresConn(conn, _) => conn, _ => panic!("Invalid connection type"), }; diff --git a/src/common/mod.rs b/src/common/mod.rs index 01108013..ac195cde 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -5,6 +5,9 @@ use swc_common::MultiSpan; pub mod cli; pub mod config; +#[cfg(test)] +#[path = "./config.test.rs"] +mod config_test; pub mod dotenv; pub mod lazy; pub mod types; diff --git a/src/core/connection.rs b/src/core/connection.rs index 01129926..a1f46fac 100644 --- a/src/core/connection.rs +++ b/src/core/connection.rs @@ -20,9 +20,9 @@ use swc_common::errors::Handler; /// Enum to hold a specific database connection instance #[allow(clippy::enum_variant_names)] pub enum DBConn { - MySQLPooledConn(Mutex>), - PostgresConn(Mutex>), - SqliteConn(Mutex>), + MySQLPooledConn(Mutex>, String), + PostgresConn(Mutex>, String), + SqliteConn(Mutex>, String), } impl DBConn { @@ -33,9 +33,9 @@ impl DBConn { handler: &Handler, ) -> Result<(bool, Option)> { let (explain_failed, ts_query) = match &self { - DBConn::MySQLPooledConn(_conn) => mysql_explain::prepare(self, sql, should_generate_types, handler).await?, - DBConn::PostgresConn(_conn) => postgres_explain::prepare(self, sql, should_generate_types, handler).await?, - DBConn::SqliteConn(_conn) => sqlite_explain::prepare(self, sql, should_generate_types, handler).await?, + DBConn::MySQLPooledConn(..) => mysql_explain::prepare(self, sql, should_generate_types, handler).await?, + DBConn::PostgresConn(..) => postgres_explain::prepare(self, sql, should_generate_types, handler).await?, + DBConn::SqliteConn(..) => sqlite_explain::prepare(self, sql, should_generate_types, handler).await?, }; Ok((explain_failed, ts_query)) @@ -44,9 +44,15 @@ impl DBConn { /// Get the database type for this connection pub fn get_db_type(&self) -> DatabaseType { match self { - DBConn::MySQLPooledConn(_) => DatabaseType::Mysql, - DBConn::PostgresConn(_) => DatabaseType::Postgres, - DBConn::SqliteConn(_) => DatabaseType::Sqlite, + DBConn::MySQLPooledConn(..) => DatabaseType::Mysql, + DBConn::PostgresConn(..) => DatabaseType::Postgres, + DBConn::SqliteConn(..) => DatabaseType::Sqlite, + } + } + + pub fn get_connection_name(&self) -> &str { + match self { + DBConn::MySQLPooledConn(_, name) | DBConn::PostgresConn(_, name) | DBConn::SqliteConn(_, name) => name, } } } diff --git a/src/core/execute.rs b/src/core/execute.rs index f2016bdc..793421c9 100644 --- a/src/core/execute.rs +++ b/src/core/execute.rs @@ -18,6 +18,7 @@ pub async fn execute(queries: &HashMap>, handler: &Handler) -> for (file_path, sqls) in queries { let mut sqls_to_write: Vec = vec![]; + let mut imports: Vec = vec![]; for sql in sqls { let mut connection = DB_CONNECTIONS.lock().await; let connection = &connection.get_connection(&sql.query).clone(); @@ -31,6 +32,11 @@ pub async fn execute(queries: &HashMap>, handler: &Handler) -> if *should_generate_types { let ts_query = &ts_query.clone().expect("Failed to generate types from query"); + for import in ts_query.imports() { + if !imports.contains(&import) { + imports.push(import); + } + } let ts_query = &ts_query.to_string(); sqls_to_write.push(ts_query.to_owned()); } @@ -46,10 +52,10 @@ pub async fn execute(queries: &HashMap>, handler: &Handler) -> if CLI_ARGS.generate_path.is_none() { // generates types colocated to source code - write_colocated_ts_file(file_path, sqls_to_write)?; + write_colocated_ts_file(file_path, sqls_to_write, &imports)?; } else { // generates types in a single directory/file - write_single_ts_file(sqls_to_write)?; + write_single_ts_file(sqls_to_write, &imports)?; } } } diff --git a/src/core/mysql/prepare.rs b/src/core/mysql/prepare.rs index b4f49e19..91039f3e 100644 --- a/src/core/mysql/prepare.rs +++ b/src/core/mysql/prepare.rs @@ -19,7 +19,7 @@ pub async fn prepare( let mut failed = false; let conn = match &db_conn { - DBConn::MySQLPooledConn(conn) => conn, + DBConn::MySQLPooledConn(conn, _) => conn, _ => panic!("Invalid connection type"), }; diff --git a/src/core/postgres/prepare.rs b/src/core/postgres/prepare.rs index c1402f0f..00347193 100644 --- a/src/core/postgres/prepare.rs +++ b/src/core/postgres/prepare.rs @@ -17,7 +17,7 @@ pub async fn prepare( let mut failed = false; let conn = match &db_conn { - DBConn::PostgresConn(conn) => conn, + DBConn::PostgresConn(conn, _) => conn, _ => panic!("Invalid connection type"), }; diff --git a/src/core/sqlite/prepare.rs b/src/core/sqlite/prepare.rs index 90fa4da1..71a32ed1 100644 --- a/src/core/sqlite/prepare.rs +++ b/src/core/sqlite/prepare.rs @@ -18,7 +18,7 @@ pub async fn prepare( let mut failed = false; let conn = match &db_conn { - DBConn::SqliteConn(conn) => conn, + DBConn::SqliteConn(conn, _) => conn, _ => panic!("Invalid connection type"), }; diff --git a/src/ts_generator/generator.rs b/src/ts_generator/generator.rs index d0ba9a88..bd451f28 100644 --- a/src/ts_generator/generator.rs +++ b/src/ts_generator/generator.rs @@ -52,8 +52,29 @@ pub fn get_query_name(sql: &SQL) -> Result { Err(TsGeneratorError::EmptyQueryNameFromVarDecl(sql.query.to_string()).into()) } +fn normalize_imports(imports: &[String]) -> Vec { + imports + .iter() + .map(|import| { + let import = import.split_whitespace().collect::>().join(" "); + if import.ends_with(';') { + import + } else { + format!("{import};") + } + }) + .collect() +} + +fn with_imports(imports: &[String], sqls_to_write: &str) -> String { + if imports.is_empty() { + return sqls_to_write.to_string(); + } + format!("{}\n\n{sqls_to_write}", imports.join("\n")) +} + /// Write colocated Type definition file next to the TS source code -pub fn write_colocated_ts_file(file_path: &Path, sqls_to_write: String) -> Result<()> { +pub fn write_colocated_ts_file(file_path: &Path, sqls_to_write: String, imports: &[String]) -> Result<()> { let path = file_path.parent().unwrap(); let file = file_path.file_stem().unwrap(); let file_name = file.to_str().unwrap(); @@ -65,12 +86,13 @@ pub fn write_colocated_ts_file(file_path: &Path, sqls_to_write: String) -> Resul let mut file_to_write = fs::File::create(query_ts_file_path)?; + let sqls_to_write = with_imports(&normalize_imports(imports), &sqls_to_write); file_to_write.write_all(sqls_to_write.as_ref())?; Ok(()) } /// Write a single TS file to a target destination according to CLI_ARGS.generate_path -pub fn write_single_ts_file(sqls_to_write: String) -> Result<()> { +pub fn write_single_ts_file(sqls_to_write: String, imports: &[String]) -> Result<()> { let generate_path = CONFIG.generate_types_config.clone().and_then(|x| x.generate_path); let output = generate_path.ok_or(eyre!( "TS generation path (--generate-path=) is required if you want to generate the SQL at a single path" @@ -81,6 +103,30 @@ pub fn write_single_ts_file(sqls_to_write: String) -> Result<()> { fs::create_dir_all(parent_output_path)?; } + let imports = normalize_imports(imports); + if !imports.is_empty() { + let existing = fs::read_to_string(&output).unwrap_or_default(); + let mut lines = existing.lines().peekable(); + let mut merged_imports: Vec = vec![]; + while let Some(line) = lines.next_if(|line| line.starts_with("import ")) { + merged_imports.push(line.to_string()); + } + for import in imports { + if !merged_imports.contains(&import) { + merged_imports.push(import); + } + } + let existing_types = lines.collect::>().join("\n"); + let existing_types = existing_types.trim_start(); + let types = if existing_types.is_empty() { + sqls_to_write + } else { + format!("{existing_types}\n{sqls_to_write}") + }; + fs::write(&output, with_imports(&merged_imports, &types))?; + return Ok(()); + } + let mut file_to_write = OpenOptions::new() .create(true) .read(true) diff --git a/src/ts_generator/information_schema.rs b/src/ts_generator/information_schema.rs index 79fd86ef..f1d6380b 100644 --- a/src/ts_generator/information_schema.rs +++ b/src/ts_generator/information_schema.rs @@ -1,4 +1,6 @@ +use crate::common::config::{CustomTypeMapping, DbConnectionConfig}; use crate::common::errors::{DB_CONN_POOL_RETRIEVE_ERROR, DB_SCHEME_READ_ERROR}; +use crate::common::lazy::CONFIG; use crate::common::logger::*; use crate::core::connection::DBConn; use crate::core::mysql::pool::MySqlConnectionManager; @@ -19,6 +21,24 @@ pub struct Field { pub type Fields = HashMap; +fn resolve_field_type( + conn_config: Option<&DbConnectionConfig>, + db_types: &[&str], + default_type: impl FnOnce() -> TsFieldType, +) -> TsFieldType { + match conn_config.and_then(|x| x.find_type_mapping(db_types)) { + Some(CustomTypeMapping::Simple(type_name)) => TsFieldType::Custom { + type_name: type_name.to_owned(), + import: None, + }, + Some(CustomTypeMapping::WithImport { type_name, import }) => TsFieldType::Custom { + type_name: type_name.to_owned(), + import: Some(import.to_owned()), + }, + None => default_type(), + } +} + pub struct DBSchema { // Holds cache details for table / columns of the target database tables_cache: HashMap, @@ -45,7 +65,9 @@ impl DBSchema { /// # PostgreSQL Notes /// - PostgresSQL would utilise SEARCH_PATH option to search for the table in the database https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH pub async fn fetch_table(&mut self, table_name: &Vec<&str>, conn: &DBConn) -> Option { - let table_key: String = table_name.join(","); + let connection_name = conn.get_connection_name(); + let conn_config = CONFIG.connections.get(connection_name); + let table_key: String = format!("{connection_name}:{}", table_name.join(",")); let cached_table_result = self.tables_cache.get(table_key.as_str()); if let Some(cached_table_result) = cached_table_result { @@ -53,9 +75,11 @@ impl DBSchema { } let result = match &conn { - DBConn::MySQLPooledConn(conn) => Self::mysql_fetch_table(self, table_name, conn).await, - DBConn::PostgresConn(conn) => Self::postgres_fetch_table(self, &"public".to_string(), table_name, conn).await, - DBConn::SqliteConn(conn) => Self::sqlite_fetch_table(self, table_name, conn).await, + DBConn::MySQLPooledConn(conn, _) => Self::mysql_fetch_table(self, table_name, conn, conn_config).await, + DBConn::PostgresConn(conn, _) => { + Self::postgres_fetch_table(self, &"public".to_string(), table_name, conn, conn_config).await + } + DBConn::SqliteConn(conn, _) => Self::sqlite_fetch_table(self, table_name, conn, conn_config).await, }; if let Some(result) = &result { @@ -70,6 +94,7 @@ impl DBSchema { schema: &String, table_names: &Vec<&str>, conn: &Mutex>, + conn_config: Option<&DbConnectionConfig>, ) -> Option { let table_names = table_names .iter() @@ -92,7 +117,8 @@ impl DBSchema { where n.nspname = '{schema}' and t.typname = udt_name group by n.nspname, t.typname - ) as enum_values + ) as enum_values, + UDT_NAME as udt_name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '{schema}' AND TABLE_NAME IN ({table_names}); @@ -115,14 +141,17 @@ impl DBSchema { .try_get(4) .ok() .map(|val: String| val.split(",").map(|x| x.to_string()).collect()); + let udt_name: String = row.try_get(5).unwrap_or_default(); let field = Field { - field_type: TsFieldType::get_ts_field_type_from_postgres_field_type( - field_type.to_owned(), - field_name.to_owned(), - table_name, - enum_values, - ), + field_type: resolve_field_type(conn_config, &[&field_type, &udt_name], || { + TsFieldType::get_ts_field_type_from_postgres_field_type( + field_type.to_owned(), + field_name.to_owned(), + table_name, + enum_values, + ) + }), is_nullable: is_nullable == "YES", }; if field.field_type == TsFieldType::Any { @@ -144,6 +173,7 @@ impl DBSchema { &self, table_names: &Vec<&str>, conn: &Mutex>, + conn_config: Option<&DbConnectionConfig>, ) -> Option { let table_names = table_names .iter() @@ -169,7 +199,8 @@ impl DBSchema { WHERE subcols.TABLE_SCHEMA = (SELECT DATABASE()) AND subcols.TABLE_NAME = C.TABLE_NAME AND subcols.COLUMN_NAME = C.COLUMN_NAME - ) AS enums + ) AS enums, + COLUMN_TYPE as column_type FROM information_schema.COLUMNS C WHERE TABLE_SCHEMA = (SELECT DATABASE()) AND TABLE_NAME IN ({table_names}) @@ -187,6 +218,7 @@ impl DBSchema { let field_type: String = row.clone().take(1).expect(DB_SCHEME_READ_ERROR); let is_nullable: String = row.clone().take(2).expect(DB_SCHEME_READ_ERROR); let table_name: String = row.clone().take(3).expect(DB_SCHEME_READ_ERROR); + let column_type: String = row.clone().take(5).unwrap_or_default(); let enum_values: Option> = if field_type == "enum" { let enums: String = row.clone().take(4).expect(DB_SCHEME_READ_ERROR); @@ -196,12 +228,14 @@ impl DBSchema { None }; let field = Field { - field_type: TsFieldType::get_ts_field_type_from_mysql_field_type( - field_type.to_owned(), - table_name.to_owned(), - field_name.to_owned(), - enum_values.to_owned(), - ), + field_type: resolve_field_type(conn_config, &[&column_type, &field_type], || { + TsFieldType::get_ts_field_type_from_mysql_field_type( + field_type.to_owned(), + table_name.to_owned(), + field_name.to_owned(), + enum_values.to_owned(), + ) + }), is_nullable: is_nullable == "YES", }; fields.insert(field_name.to_owned(), field); @@ -217,6 +251,7 @@ impl DBSchema { &self, table_names: &Vec<&str>, conn: &Mutex>, + conn_config: Option<&'static DbConnectionConfig>, ) -> Option { let mut fields: HashMap = HashMap::new(); let conn = conn.lock().await; @@ -249,7 +284,9 @@ impl DBSchema { for (field_name, field_type, notnull, pk, tbl_name) in rows.flatten() { let field = Field { - field_type: TsFieldType::get_ts_field_type_from_sqlite_field_type(field_type, tbl_name, field_name.clone()), + field_type: resolve_field_type(conn_config, &[&field_type], || { + TsFieldType::get_ts_field_type_from_sqlite_field_type(field_type.to_owned(), tbl_name, field_name.clone()) + }), is_nullable: !notnull && pk == 0, }; all_fields.insert(field_name, field); diff --git a/src/ts_generator/types/ts_query.rs b/src/ts_generator/types/ts_query.rs index 3c78ec8f..b0f8c932 100644 --- a/src/ts_generator/types/ts_query.rs +++ b/src/ts_generator/types/ts_query.rs @@ -122,6 +122,10 @@ pub enum TsFieldType { Array(Box), #[allow(dead_code)] Never, + Custom { + type_name: String, + import: Option, + }, } impl fmt::Display for TsFieldType { @@ -151,6 +155,7 @@ impl fmt::Display for TsFieldType { TsFieldType::Null => write!(f, "null"), TsFieldType::Never => write!(f, "never"), TsFieldType::Unknown => write!(f, "unknown"), + TsFieldType::Custom { type_name, .. } => write!(f, "{type_name}"), TsFieldType::Array(ts_field_type) => { let ts_field_type = ts_field_type.clone(); let ts_field_type = *ts_field_type; @@ -280,6 +285,18 @@ impl TsFieldType { Self::Any } + fn collect_imports(&self, imports: &mut Vec) { + match self { + TsFieldType::Custom { + import: Some(import), .. + } if !imports.contains(import) => imports.push(import.to_owned()), + TsFieldType::Array(inner) => inner.collect_imports(imports), + TsFieldType::Array2D(rows) => rows.iter().flatten().for_each(|x| x.collect_imports(imports)), + TsFieldType::StructuredObject(fields) => fields.iter().for_each(|(_, x, _)| x.collect_imports(imports)), + _ => {} + } + } + pub fn get_ts_field_from_annotation(annotated_type: &str) -> Self { if annotated_type == "string" { return Self::String; @@ -548,6 +565,20 @@ impl TsQuery { Ok(()) } + pub fn imports(&self) -> Vec { + let mut imports = vec![]; + let params = self.params.values().flatten(); + let insert_params = self.insert_params.values().flat_map(|x| x.values()).flatten(); + let mut result_keys = Vec::from_iter(self.result.keys()); + result_keys.sort(); + let results = result_keys.into_iter().flat_map(|key| &self.result[key]); + + for ts_field_type in params.chain(insert_params).chain(results) { + ts_field_type.collect_imports(&mut imports); + } + imports + } + /// The method is to format SQL params extracted via translate methods /// It can work for SELECT, INSERT, DELETE and UPDATE queries fn fmt_params(&self, _: &mut fmt::Formatter<'_>) -> String { diff --git a/tests/configs/.sqlxrc.type_mapping.json b/tests/configs/.sqlxrc.type_mapping.json new file mode 100644 index 00000000..01d9bf14 --- /dev/null +++ b/tests/configs/.sqlxrc.type_mapping.json @@ -0,0 +1,24 @@ +{ + "generateTypes": { + "enabled": true + }, + "connections": { + "default": { + "DB_TYPE": "postgres", + "DB_HOST": "127.0.0.1", + "DB_PORT": 54321, + "DB_USER": "postgres", + "DB_PASS": "postgres", + "DB_NAME": "postgres", + "PG_SEARCH_PATH": "public,myschema", + "type_mapping": { + "bigint": "string", + "interval": "string", + "jsonb": "Record", + "faction_enum": { "type": "Faction", "import": "import type { Faction } from './enums'" }, + "timestamp": { "type": "DateTime", "import": "import type { DateTime } from 'luxon'" }, + "timestamptz": { "type": "Dayjs", "import": "import type { Dayjs } from 'dayjs'" } + } + } + } +} diff --git a/tests/configs/.sqlxrc.type_mapping_mysql.json b/tests/configs/.sqlxrc.type_mapping_mysql.json new file mode 100644 index 00000000..4c1106a7 --- /dev/null +++ b/tests/configs/.sqlxrc.type_mapping_mysql.json @@ -0,0 +1,20 @@ +{ + "generateTypes": { + "enabled": true + }, + "connections": { + "default": { + "DB_TYPE": "mysql", + "DB_HOST": "127.0.0.1", + "DB_PORT": 33306, + "DB_USER": "root", + "DB_NAME": "sqlx-ts", + "type_mapping": { + "tinyint(1)": "boolean", + "tinyint": "number", + "bigint unsigned": "string", + "bigint": "bigint" + } + } + } +} diff --git a/tests/custom_type_mapping.rs b/tests/custom_type_mapping.rs new file mode 100644 index 00000000..86ce2691 --- /dev/null +++ b/tests/custom_type_mapping.rs @@ -0,0 +1,556 @@ +#[cfg(test)] +mod custom_type_mapping_tests { + use std::fs; + use std::io::Write; + use tempfile::tempdir; + + use assert_cmd::cargo::cargo_bin_cmd; + use pretty_assertions::assert_eq; + use test_utils::test_utils::TSString; + + fn run_type_mapping_test( + schema_sql: &str, + ts_content: &str, + type_mapping_json: &str, + ) -> Result<(String, String), Box> { + run_type_mapping_test_with_files(schema_sql, &[("index.ts", ts_content)], type_mapping_json, None) + } + + fn run_type_mapping_test_with_files( + schema_sql: &str, + ts_files: &[(&str, &str)], + type_mapping_json: &str, + generate_path: Option<&str>, + ) -> Result<(String, String), Box> { + let (output, type_file) = run_sqlx_ts(schema_sql, ts_files, type_mapping_json, generate_path)?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + assert!( + output.status.success(), + "sqlx-ts failed!\nstdout: {stdout}\nstderr: {stderr}" + ); + + Ok((stdout, type_file)) + } + + fn run_sqlx_ts( + schema_sql: &str, + ts_files: &[(&str, &str)], + type_mapping_json: &str, + generate_path: Option<&str>, + ) -> Result<(std::process::Output, String), Box> { + let dir = tempdir()?; + let parent_path = dir.path(); + + let db_path = parent_path.join("test.db"); + let conn = rusqlite::Connection::open(&db_path)?; + conn.execute_batch(schema_sql)?; + drop(conn); + + let config = format!( + r#"{{ + "generate_types": {{ + "enabled": true + }}, + "connections": {{ + "default": {{ + "DB_TYPE": "sqlite", + "DB_NAME": "{}", + "type_mapping": {} + }} + }} +}}"#, + db_path.display(), + type_mapping_json + ); + let config_path = parent_path.join(".sqlxrc.json"); + let mut config_file = fs::File::create(&config_path)?; + write!(config_file, "{}", config)?; + + for (file_name, ts_content) in ts_files { + let file_path = parent_path.join(file_name); + let mut temp_file = fs::File::create(&file_path)?; + writeln!(temp_file, "{}", ts_content)?; + } + + let mut cmd = cargo_bin_cmd!("sqlx-ts"); + cmd + .arg(parent_path.to_str().unwrap()) + .arg("--ext=ts") + .arg("--db-type=sqlite") + .arg(format!("--db-name={}", db_path.display())) + .arg(format!("--config={}", config_path.display())) + .arg("-g"); + if let Some(generate_path) = generate_path { + cmd.arg(format!("--generate-path={}", parent_path.join(generate_path).display())); + } + + let output = cmd.output()?; + + let type_file_path = match generate_path { + Some(generate_path) => parent_path.join(generate_path), + None => parent_path.join("index.queries.ts"), + }; + let type_file = if type_file_path.exists() { + fs::read_to_string(type_file_path)? + } else { + String::new() + }; + + Ok((output, type_file)) + } + + #[test] + fn should_override_integer_to_string() -> Result<(), Box> { + let schema = "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, count BIGINT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT * FROM test_custom_types` +"#; + + let type_mapping = r#"{ "bigint": "string" }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + let expected = r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + count: string; + id: number; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_override_with_union_type() -> Result<(), Box> { + let schema = "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, count BIGINT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT * FROM test_custom_types` +"#; + + let type_mapping = r#"{ "bigint": "string | number" }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + let expected = r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + count: string | number; + id: number; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_override_with_import() -> Result<(), Box> { + let schema = "CREATE TABLE events (id INTEGER PRIMARY KEY NOT NULL, created_at DATETIME NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT * FROM events` +"#; + + let type_mapping = r#"{ "datetime": { "type": "DateTime", "import": "import type { DateTime } from \"luxon\"" } }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + assert!( + type_file.contains("import type { DateTime } from \"luxon\""), + "Expected import statement in generated file, got:\n{type_file}" + ); + + assert!( + type_file.contains("created_at: DateTime;"), + "Expected DateTime type for created_at, got:\n{type_file}" + ); + + Ok(()) + } + + #[test] + fn should_not_override_unmapped_types() -> Result<(), Box> { + let schema = + "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, count BIGINT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT * FROM test_custom_types` +"#; + + let type_mapping = r#"{ "bigint": "string" }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + let expected = r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + count: string; + id: number; + name: string; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_keep_nullability_and_apply_to_params() -> Result<(), Box> { + let schema = "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, count BIGINT);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT * FROM test_custom_types WHERE count = ?` +"#; + + let type_mapping = r#"{ "BIGINT": "string" }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + let expected = r#" +export type SomeQueryParams = [string | null]; + +export interface ISomeQueryResult { + count: string | null; + id: number; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_write_import_once_with_generate_path() -> Result<(), Box> { + let schema = "CREATE TABLE events (id INTEGER PRIMARY KEY NOT NULL, created_at DATETIME NOT NULL);"; + + let first = r#" +import { sql } from 'sqlx-ts' +const firstQuery = sql`SELECT * FROM events` +"#; + let second = r#" +import { sql } from 'sqlx-ts' +const secondQuery = sql`SELECT created_at FROM events` +"#; + + let type_mapping = r#"{ "datetime": { "type": "DateTime", "import": "import type { DateTime } from \"luxon\"" } }"#; + + let (_, type_file) = run_type_mapping_test_with_files( + schema, + &[("first.ts", first), ("second.ts", second)], + type_mapping, + Some("types.ts"), + )?; + + assert_eq!( + type_file.matches("import type { DateTime } from \"luxon\";").count(), + 1, + "Expected a single import statement in generated file, got:\n{type_file}" + ); + assert!( + type_file.starts_with("import type { DateTime } from \"luxon\";"), + "Expected the import statement at the top of generated file, got:\n{type_file}" + ); + assert_eq!(type_file.matches("created_at: DateTime;").count(), 2); + Ok(()) + } + + const COMPLEX_SCHEMA: &str = "CREATE TABLE users (id INTEGER PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL, balance DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, deleted_at DATETIME); CREATE TABLE orders (id INTEGER PRIMARY KEY NOT NULL, user_id INTEGER NOT NULL, total DECIMAL(10,2) NOT NULL, ordered_at DATETIME NOT NULL, shipped_at TIMESTAMP);"; + + const COMPLEX_TYPE_MAPPING: &str = r#"{ "varchar": "UserName", "decimal": "string", "datetime": { "type": "DateTime", "import": "import type { DateTime } from \"luxon\"" }, "timestamp": { "type": "Dayjs", "import": "import type { Dayjs } from \"dayjs\"" } }"#; + + fn assert_complex_query_types(ts_content: &str, expected: &str) -> Result<(), Box> { + let (_, type_file) = run_type_mapping_test(COMPLEX_SCHEMA, ts_content, COMPLEX_TYPE_MAPPING)?; + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_map_types_across_joins() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const joinQuery = sql`SELECT u.id AS id, u.name AS name, o.total AS total, o.shipped_at AS shipped_at FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE o.ordered_at > ? AND u.balance >= ?` +"#, + r#" +import type { DateTime } from "luxon"; +import type { Dayjs } from "dayjs"; + +export type JoinQueryParams = [DateTime, string]; + +export interface IJoinQueryResult { + id: number; + name: UserName; + shipped_at: Dayjs | null; + total: string; +} + +export interface IJoinQueryQuery { + params: JoinQueryParams; + result: IJoinQueryResult; +} +"#, + ) + } + + #[test] + fn should_map_param_types_in_subquery() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const subqueryQuery = sql`SELECT id, created_at FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > ?)` +"#, + r#" +import type { DateTime } from "luxon"; + +export type SubqueryQueryParams = [string]; + +export interface ISubqueryQueryResult { + created_at: DateTime; + id: number; +} + +export interface ISubqueryQueryQuery { + params: SubqueryQueryParams; + result: ISubqueryQueryResult; +} +"#, + ) + } + + #[test] + fn should_map_types_in_cte() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const cteQuery = sql`WITH recent AS (SELECT user_id, ordered_at FROM orders WHERE ordered_at > ?) SELECT r.user_id AS user_id, r.ordered_at AS ordered_at FROM recent r` +"#, + r#" +import type { DateTime } from "luxon"; + +export type CteQueryParams = [DateTime]; + +export interface ICteQueryResult { + ordered_at: DateTime; + user_id: number; +} + +export interface ICteQueryQuery { + params: CteQueryParams; + result: ICteQueryResult; +} +"#, + ) + } + + #[test] + fn should_map_insert_params_and_returning() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const insertQuery = sql`INSERT INTO orders (user_id, total, ordered_at, shipped_at) VALUES (?, ?, ?, ?) RETURNING id, total, ordered_at` +"#, + r#" +import type { DateTime } from "luxon"; +import type { Dayjs } from "dayjs"; + +export type InsertQueryParams = [[number, string, DateTime, Dayjs | null]]; + +export interface IInsertQueryResult { + id: number; + ordered_at: DateTime; + total: string; +} + +export interface IInsertQueryQuery { + params: InsertQueryParams; + result: IInsertQueryResult; +} +"#, + ) + } + + #[test] + fn should_map_update_params() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const updateQuery = sql`UPDATE users SET balance = ?, deleted_at = ? WHERE created_at < ?` +"#, + r#" +import type { DateTime } from "luxon"; + +export type UpdateQueryParams = [string, DateTime | null, DateTime]; + +export interface IUpdateQueryResult { + +} + +export interface IUpdateQueryQuery { + params: UpdateQueryParams; + result: IUpdateQueryResult; +} +"#, + ) + } + + #[test] + fn should_map_delete_params() -> Result<(), Box> { + assert_complex_query_types( + r#" +import { sql } from 'sqlx-ts' +const deleteQuery = sql`DELETE FROM orders WHERE shipped_at < ? AND total < ?` +"#, + r#" +import type { Dayjs } from "dayjs"; + +export type DeleteQueryParams = [Dayjs | null, string]; + +export interface IDeleteQueryResult { + +} + +export interface IDeleteQueryQuery { + params: DeleteQueryParams; + result: IDeleteQueryResult; +} +"#, + ) + } + + #[test] + fn should_write_multiline_import_once_with_generate_path() -> Result<(), Box> { + let schema = "CREATE TABLE events (id INTEGER PRIMARY KEY NOT NULL, created_at DATETIME NOT NULL);"; + + let first = r#" +import { sql } from 'sqlx-ts' +const firstQuery = sql`SELECT created_at FROM events` +"#; + let second = r#" +import { sql } from 'sqlx-ts' +const secondQuery = sql`SELECT created_at FROM events` +"#; + + let type_mapping = + r#"{ "datetime": { "type": "DT", "import": "import type {\n DateTime as DT\n} from \"luxon\"" } }"#; + + let (_, type_file) = run_type_mapping_test_with_files( + schema, + &[("first.ts", first), ("second.ts", second)], + type_mapping, + Some("types.ts"), + )?; + + assert!( + type_file.starts_with("import type { DateTime as DT } from \"luxon\";\n\n"), + "Expected a single-line import at the top of generated file, got:\n{type_file}" + ); + assert_eq!(type_file.matches("import ").count(), 1); + assert_eq!(type_file.matches("created_at: DT;").count(), 2); + Ok(()) + } + + #[test] + fn should_allow_object_mapping_without_import() -> Result<(), Box> { + let schema = "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, count BIGINT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT count FROM test_custom_types` +"#; + + let type_mapping = r#"{ "bigint": { "type": "bigint" } }"#; + + let (_, type_file) = run_type_mapping_test(schema, ts_content, type_mapping)?; + + let expected = r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + count: bigint; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_reject_empty_type() -> Result<(), Box> { + let schema = "CREATE TABLE test_custom_types (id INTEGER PRIMARY KEY NOT NULL, count BIGINT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' +const someQuery = sql`SELECT count FROM test_custom_types` +"#; + + for type_mapping in [r#"{ "bigint": "" }"#, r#"{ "bigint": { "type": " " } }"#] { + let (output, type_file) = run_sqlx_ts(schema, &[("index.ts", ts_content)], type_mapping, None)?; + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + assert!(!output.status.success(), "Expected sqlx-ts to fail for {type_mapping}"); + assert!( + stderr.contains("type in type_mapping cannot be empty"), + "Unexpected stderr for {type_mapping}:\n{stderr}" + ); + assert!(type_file.is_empty()); + } + Ok(()) + } +} diff --git a/tests/mysql_custom_type_mapping.rs b/tests/mysql_custom_type_mapping.rs new file mode 100644 index 00000000..2daea5e0 --- /dev/null +++ b/tests/mysql_custom_type_mapping.rs @@ -0,0 +1,40 @@ +#[cfg(test)] +mod mysql_custom_type_mapping_tests { + use std::env; + use std::fs; + use std::io::Write; + use tempfile::tempdir; + + use pretty_assertions::assert_eq; + use test_utils::test_utils::TSString; + use test_utils::{run_test, sandbox::TestConfig}; + + #[rustfmt::skip] +run_test!(should_prefer_column_type_over_data_type, TestConfig::new("mysql", true, None, Some(".sqlxrc.type_mapping_mysql.json".to_string())), +//// TS query //// +r#" +const someQuery = sql` +SELECT id, is_active, flags, big_id, amount +FROM type_mapping_test +WHERE big_id = ? AND is_active = ? +` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [string, boolean]; + +export interface ISomeQueryResult { + amount: bigint; + big_id: string; + flags: number; + id: number; + is_active: boolean; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); +} diff --git a/tests/postgres_custom_type_mapping.rs b/tests/postgres_custom_type_mapping.rs new file mode 100644 index 00000000..d7d33d75 --- /dev/null +++ b/tests/postgres_custom_type_mapping.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod postgres_custom_type_mapping_tests { + use std::env; + use std::fs; + use std::io::Write; + use tempfile::tempdir; + + use pretty_assertions::assert_eq; + use test_utils::test_utils::TSString; + use test_utils::{run_test, sandbox::TestConfig}; + + #[rustfmt::skip] +run_test!(should_map_types_across_joins_with_enum_and_params, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const joinQuery = sql` +SELECT c.id AS id, c.experience AS experience, c.login_time AS login_time, f.name AS faction +FROM characters c +INNER JOIN races r ON r.id = c.race_id +INNER JOIN factions f ON f.id = r.faction_id +WHERE c.created_at > $1 AND c.experience >= $2 AND f.name = $3 +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; +import type { Faction } from './enums'; + +export type JoinQueryParams = [DateTime | null, string | null, Faction]; + +export interface IJoinQueryResult { + experience: string | null; + faction: Faction; + id: number; + login_time: DateTime | null; +} + +export interface IJoinQueryQuery { + params: JoinQueryParams; + result: IJoinQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_types_in_left_join, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const leftJoinQuery = sql` +SELECT c.id AS id, gm.joined_at AS joined_at, c.last_trade_time AS last_trade_time +FROM characters c +LEFT JOIN guild_members gm ON gm.character_id = c.id +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; + +export type LeftJoinQueryParams = []; + +export interface ILeftJoinQueryResult { + id: number; + joined_at: DateTime | null; + last_trade_time: string | null; +} + +export interface ILeftJoinQueryQuery { + params: LeftJoinQueryParams; + result: ILeftJoinQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_types_in_cte, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const cteQuery = sql` +WITH active AS ( + SELECT id, experience, logout_time FROM characters WHERE logout_time > $1 +) +SELECT a.id AS id, a.experience AS experience, a.logout_time AS logout_time FROM active a +` +"#, + +//// Generated TS interfaces //// +r#" +import type { Dayjs } from 'dayjs'; + +export type CteQueryParams = [Dayjs | null]; + +export interface ICteQueryResult { + experience: string; + id: number; + logout_time: Dayjs; +} + +export interface ICteQueryQuery { + params: CteQueryParams; + result: ICteQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_param_types_in_subquery, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const subqueryQuery = sql` +SELECT id, last_trade_time FROM characters +WHERE id IN (SELECT character_id FROM guild_members WHERE joined_at > $1) +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; + +export type SubqueryQueryParams = [DateTime | null]; + +export interface ISubqueryQueryResult { + id: number; + last_trade_time: string | null; +} + +export interface ISubqueryQueryQuery { + params: SubqueryQueryParams; + result: ISubqueryQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_insert_params_and_returning, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const insertQuery = sql` +INSERT INTO characters (name, experience, login_time, logout_time) +VALUES ($1, $2, $3, $4) +RETURNING id, experience, created_at +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; +import type { Dayjs } from 'dayjs'; + +export type InsertQueryParams = [string, string | null, DateTime | null, Dayjs | null]; + +export interface IInsertQueryResult { + created_at: DateTime | null; + experience: string | null; + id: number; +} + +export interface IInsertQueryQuery { + params: InsertQueryParams; + result: IInsertQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_update_params_and_returning, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const updateQuery = sql` +UPDATE characters SET experience = $1, logout_time = $2 WHERE login_time < $3 +RETURNING id, experience, logout_time +` +"#, + +//// Generated TS interfaces //// +r#" +import type { Dayjs } from 'dayjs'; +import type { DateTime } from 'luxon'; + +export type UpdateQueryParams = [string | null, Dayjs | null, DateTime | null]; + +export interface IUpdateQueryResult { + experience: string | null; + id: number; + logout_time: Dayjs | null; +} + +export interface IUpdateQueryQuery { + params: UpdateQueryParams; + result: IUpdateQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_delete_params_and_returning, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const deleteQuery = sql` +DELETE FROM characters WHERE created_at < $1 RETURNING id, created_at +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; + +export type DeleteQueryParams = [DateTime | null]; + +export interface IDeleteQueryResult { + created_at: DateTime | null; + id: number; +} + +export interface IDeleteQueryQuery { + params: DeleteQueryParams; + result: IDeleteQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_collect_imports_from_nested_json_object, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const jsonQuery = sql` +SELECT id, jsonb_build_object('experience', experience, 'loginTime', login_time) AS meta +FROM characters +` +"#, + +//// Generated TS interfaces //// +r#" +import type { DateTime } from 'luxon'; + +export type JsonQueryParams = []; + +export interface IJsonQueryResult { + id: number; + meta: { experience: string | null; loginTime: DateTime | null }; +} + +export interface IJsonQueryQuery { + params: JsonQueryParams; + result: IJsonQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(should_map_jsonb_without_import, TestConfig::new("postgres", true, None, Some(".sqlxrc.type_mapping.json".to_string())), +//// TS query //// +r#" +const jsonbColumnQuery = sql` +SELECT id, rewards FROM quests WHERE rewards = $1 +` +"#, + +//// Generated TS interfaces //// +r#" +export type JsonbColumnQueryParams = [Record | null]; + +export interface IJsonbColumnQueryResult { + id: number; + rewards: Record | null; +} + +export interface IJsonbColumnQueryQuery { + params: JsonbColumnQueryParams; + result: IJsonbColumnQueryResult; +} +"#); +}