diff --git a/Cargo.lock b/Cargo.lock index 3c066db7..0dffbae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,6 +1951,18 @@ checksum = "dbf5ea8d4d7c808e1af1cbabebca9a2abe603bcefc22294c5b95018d53200cb7" dependencies = [ "log", "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2a134223..18403146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ walkdir = "2.5.0" clap = { version = "4.6.1", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149" } -sqlparser = { version = "0.61.0" } +sqlparser = { version = "0.61.0", features = ["visitor"] } regex = { version = "1.12.3" } convert_case = "0.11.0" colored = "3.1.1" diff --git a/book/docs/limitations.md b/book/docs/limitations.md index eb3a0931..fc09e154 100644 --- a/book/docs/limitations.md +++ b/book/docs/limitations.md @@ -9,3 +9,9 @@ The page aims to list down limitations of SQLX-TS. So for the users who are inte ### 1. parsing of SQL is done using sqlparser-rs and any bugs in this modules would be inherited [sqlparser-rs](https://github.com/sqlparser-rs/sqlparser-rs) is an essential module of SQLX-TS in order to process SQLs into Typescript type definitions. As a result, any bug in this module will be inherited to sqlx-ts and we will need an update in the module in order to fix the problem. So far, sqlparser-rs is well maintained and being updated in the recent days. + +### 2. tables with the same name from different schemas cannot be used in the same query + +Columns of a table are looked up by its name, so a query that uses tables with the same name from different schemas +or databases, e.g. `staff.announcements` with `public.announcements`, cannot tell them apart. SQLX-TS warns about it +and reads the columns of the first qualified table. See [qualified table names](/type-generation#qualified-table-names). diff --git a/book/docs/type-generation/README.mdx b/book/docs/type-generation/README.mdx index 72f49629..f5d5ff55 100644 --- a/book/docs/type-generation/README.mdx +++ b/book/docs/type-generation/README.mdx @@ -88,6 +88,29 @@ the type name by setting the `@name` annotation, you can read more in the sectio ### Annotations for overrides +### Qualified table names + +Tables can be qualified with a schema in PostgreSQL, or with a database in MySQL. SQLX-TS reads the columns of the +table from that schema or database instead of the default one of the connection. + +```typescript +// PostgreSQL: reads columns of the `announcements` table in the `staff` schema +const announcements = sql`SELECT message FROM staff.announcements WHERE message = $1` + +// MySQL: reads columns of the `announcements` table in the `staff` database, +// even if `DB_NAME` of the connection is another database +const announcements = sql`SELECT message FROM staff.announcements WHERE message = ?` +``` + +- Unqualified tables are read from the `public` schema in PostgreSQL and from the `DB_NAME` database in MySQL +- For `database.schema.table` names in PostgreSQL, the schema is used +- In SQLite, tables can be qualified with `main`, e.g. `main.items` +- The database user of the connection needs access to the schema or database. `information_schema` only lists + tables the user has privileges on, so a table without access is reported as having no columns +- A query cannot use two tables with the same name from different schemas yet, e.g. `staff.announcements` with + `public.announcements`, or `staff.announcements` with an unqualified `announcements`. SQLX-TS warns about it and + reads the columns of the first qualified table, so the types of the other table may be incorrect + ## Q & A ##### Why doesn't SQLx support named parameterised queries? `e.g. SELECT * FROM items WHERE point = :point` diff --git a/playpen/db/mysql_migration.sql b/playpen/db/mysql_migration.sql index e06d9a3a..ccd3a10f 100644 --- a/playpen/db/mysql_migration.sql +++ b/playpen/db/mysql_migration.sql @@ -231,3 +231,11 @@ CREATE TABLE type_mapping_test ( big_id BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL ); + +CREATE DATABASE IF NOT EXISTS staff; + +CREATE TABLE staff.announcements ( + id INT PRIMARY KEY AUTO_INCREMENT, + message VARCHAR(1000), + priority ENUM('low', 'high') NOT NULL +); diff --git a/playpen/db/mysql_migration_5_6.sql b/playpen/db/mysql_migration_5_6.sql index d5566a58..498972d4 100644 --- a/playpen/db/mysql_migration_5_6.sql +++ b/playpen/db/mysql_migration_5_6.sql @@ -217,3 +217,11 @@ CREATE TABLE type_mapping_test ( big_id BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL ); + +CREATE DATABASE IF NOT EXISTS staff; + +CREATE TABLE staff.announcements ( + id INT PRIMARY KEY AUTO_INCREMENT, + message VARCHAR(1000), + priority ENUM('low', 'high') NOT NULL +); diff --git a/playpen/db/postgres_migration.sql b/playpen/db/postgres_migration.sql index 5caf844a..eeb5640a 100644 --- a/playpen/db/postgres_migration.sql +++ b/playpen/db/postgres_migration.sql @@ -269,3 +269,13 @@ 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 SCHEMA staff; + +CREATE TYPE staff.priority_enum AS ENUM ('low', 'high'); + +CREATE TABLE staff.announcements ( + id SERIAL PRIMARY KEY, + message VARCHAR(1000), + priority staff.priority_enum NOT NULL +); diff --git a/src/ts_generator/generator.rs b/src/ts_generator/generator.rs index bd451f28..e31eeca5 100644 --- a/src/ts_generator/generator.rs +++ b/src/ts_generator/generator.rs @@ -5,9 +5,11 @@ use std::{fs, path::Path}; use super::annotations::extract_param_annotations; use crate::common::lazy::CONFIG; +use crate::common::logger::*; use crate::common::SQL; use crate::core::connection::DBConn; use crate::ts_generator::annotations::extract_result_annotations; +use crate::ts_generator::information_schema::{collect_table_schemas, with_table_schemas}; use crate::ts_generator::sql_parser::translate_stmt::translate_stmt; use crate::ts_generator::types::ts_query::TsQuery; @@ -183,10 +185,25 @@ pub async fn generate_ts_interface(sql: &SQL, db_conn: &DBConn) -> Result(()) + }) + .await?; Ok(ts_query) } diff --git a/src/ts_generator/information_schema.rs b/src/ts_generator/information_schema.rs index f1d6380b..b0757c1d 100644 --- a/src/ts_generator/information_schema.rs +++ b/src/ts_generator/information_schema.rs @@ -6,9 +6,13 @@ use crate::core::connection::DBConn; use crate::core::mysql::pool::MySqlConnectionManager; use crate::core::postgres::pool::PostgresConnectionManager; use crate::core::sqlite::pool::SqliteConnectionManager; +use crate::ts_generator::sql_parser::quoted_strings::{DisplayIndent, DisplayObjectName}; use bb8::Pool; use mysql_async::prelude::Queryable; +use sqlparser::ast::{visit_relations, Statement}; use std::collections::HashMap; +use std::future::Future; +use std::ops::ControlFlow; use tokio::sync::Mutex; use super::types::ts_query::TsFieldType; @@ -39,6 +43,80 @@ fn resolve_field_type( } } +tokio::task_local! { + static TABLE_SCHEMAS: HashMap; +} + +#[derive(Debug, Default, PartialEq)] +pub struct TableSchemas { + pub schemas: HashMap, + pub ambiguous_tables: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct AmbiguousTable { + pub table_name: String, + pub references: Vec, + pub resolved_schema: String, +} + +pub fn collect_table_schemas(statements: &Vec) -> TableSchemas { + let mut references: Vec<(String, Vec>)> = vec![]; + let _ = visit_relations(statements, |relation| { + let parts = &relation.0; + let table_name = DisplayObjectName(relation).to_string(); + let schema = parts + .len() + .checked_sub(2) + .and_then(|index| parts[index].as_ident()) + .map(|schema| DisplayIndent(schema).to_string()); + + match references.iter_mut().find(|(name, _)| *name == table_name) { + Some((_, schemas)) if !schemas.contains(&schema) => schemas.push(schema), + Some(_) => {} + None => references.push((table_name, vec![schema])), + } + ControlFlow::<()>::Continue(()) + }); + + let mut table_schemas = TableSchemas::default(); + for (table_name, schemas) in references { + let Some(resolved_schema) = schemas.iter().flatten().next().cloned() else { + continue; + }; + if schemas.len() > 1 { + table_schemas.ambiguous_tables.push(AmbiguousTable { + table_name: table_name.to_owned(), + references: schemas + .iter() + .map(|schema| match schema { + Some(schema) => format!("{schema}.{table_name}"), + None => table_name.to_owned(), + }) + .collect(), + resolved_schema: resolved_schema.to_owned(), + }); + } + table_schemas.schemas.insert(table_name, resolved_schema); + } + table_schemas +} + +pub async fn with_table_schemas(table_schemas: HashMap, f: F) -> F::Output { + TABLE_SCHEMAS.scope(table_schemas, f).await +} + +fn get_table_schema(table_name: &str) -> Option { + TABLE_SCHEMAS + .try_with(|table_schemas| table_schemas.get(table_name).cloned()) + .ok() + .flatten() +} + +fn quote_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + pub struct DBSchema { // Holds cache details for table / columns of the target database tables_cache: HashMap, @@ -67,7 +145,21 @@ impl DBSchema { pub async fn fetch_table(&mut self, table_name: &Vec<&str>, conn: &DBConn) -> Option { 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 tables: Vec<(Option, String)> = table_name + .iter() + .map(|table_name| (get_table_schema(table_name), table_name.to_string())) + .collect(); + let table_key: String = format!( + "{connection_name}:{}", + tables + .iter() + .map(|(schema, table_name)| match schema { + Some(schema) => format!("{schema}.{table_name}"), + None => table_name.to_owned(), + }) + .collect::>() + .join(",") + ); let cached_table_result = self.tables_cache.get(table_key.as_str()); if let Some(cached_table_result) = cached_table_result { @@ -75,11 +167,9 @@ impl DBSchema { } let result = match &conn { - 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, + DBConn::MySQLPooledConn(conn, _) => Self::mysql_fetch_table(self, &tables, conn, conn_config).await, + DBConn::PostgresConn(conn, _) => Self::postgres_fetch_table(self, &tables, conn, conn_config).await, + DBConn::SqliteConn(conn, _) => Self::sqlite_fetch_table(self, &tables, conn, conn_config).await, }; if let Some(result) = &result { @@ -91,16 +181,22 @@ impl DBSchema { async fn postgres_fetch_table( &self, - schema: &String, - table_names: &Vec<&str>, + tables: &[(Option, String)], conn: &Mutex>, conn_config: Option<&DbConnectionConfig>, ) -> Option { - let table_names = table_names + let table_conditions = tables .iter() - .map(|x| format!("'{x}'")) + .map(|(schema, table_name)| { + let schema = schema.as_deref().unwrap_or("public"); + format!( + "(TABLE_SCHEMA = {} AND TABLE_NAME = {})", + quote_literal(schema), + quote_literal(table_name) + ) + }) .collect::>() - .join(","); + .join(" OR "); let query = format!( r" @@ -114,14 +210,13 @@ impl DBSchema { from pg_type t join pg_enum e on t.oid = e.enumtypid join pg_catalog.pg_namespace n ON n.oid = t.typnamespace - where n.nspname = '{schema}' + where n.nspname = udt_schema and t.typname = udt_name group by n.nspname, t.typname ) as enum_values, UDT_NAME as udt_name FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = '{schema}' - AND TABLE_NAME IN ({table_names}); + WHERE {table_conditions}; " ); @@ -171,15 +266,24 @@ impl DBSchema { async fn mysql_fetch_table( &self, - table_names: &Vec<&str>, + tables: &[(Option, String)], conn: &Mutex>, conn_config: Option<&DbConnectionConfig>, ) -> Option { - let table_names = table_names + let table_conditions = tables .iter() - .map(|x| format!("'{x}'")) + .map(|(database, table_name)| { + let database = database + .as_deref() + .map(quote_literal) + .unwrap_or_else(|| "(SELECT DATABASE())".to_string()); + format!( + "(TABLE_SCHEMA = {database} AND TABLE_NAME = {})", + quote_literal(table_name) + ) + }) .collect::>() - .join(","); + .join(" OR "); let query = format!( r" SELECT @@ -196,14 +300,13 @@ impl DBSchema { , '' ) FROM information_schema.COLUMNS subcols - WHERE subcols.TABLE_SCHEMA = (SELECT DATABASE()) + WHERE subcols.TABLE_SCHEMA = C.TABLE_SCHEMA AND subcols.TABLE_NAME = C.TABLE_NAME AND subcols.COLUMN_NAME = C.COLUMN_NAME ) AS enums, COLUMN_TYPE as column_type FROM information_schema.COLUMNS C - WHERE TABLE_SCHEMA = (SELECT DATABASE()) - AND TABLE_NAME IN ({table_names}) + WHERE {table_conditions} " ); @@ -249,7 +352,7 @@ impl DBSchema { async fn sqlite_fetch_table( &self, - table_names: &Vec<&str>, + tables: &[(Option, String)], conn: &Mutex>, conn_config: Option<&'static DbConnectionConfig>, ) -> Option { @@ -258,14 +361,21 @@ impl DBSchema { let pool_conn = conn.get().await.expect(DB_CONN_POOL_RETRIEVE_ERROR); let inner = pool_conn.conn.clone(); - let table_names_owned: Vec = table_names.iter().map(|s| s.to_string()).collect(); + let tables = tables.to_vec(); let result = tokio::task::spawn_blocking(move || { let conn = inner.lock().unwrap(); let mut all_fields: HashMap = HashMap::new(); - for table_name in &table_names_owned { - let query = format!("PRAGMA table_info('{}')", table_name); + for (schema, table_name) in &tables { + let query = match schema { + Some(schema) => format!( + "PRAGMA \"{}\".table_info({})", + schema.replace('"', "\"\""), + quote_literal(table_name) + ), + None => format!("PRAGMA table_info({})", quote_literal(table_name)), + }; let mut stmt = match conn.prepare(&query) { Ok(stmt) => stmt, Err(_) => continue, diff --git a/src/ts_generator/information_schema.test.rs b/src/ts_generator/information_schema.test.rs new file mode 100644 index 00000000..11ca2ac2 --- /dev/null +++ b/src/ts_generator/information_schema.test.rs @@ -0,0 +1,93 @@ +#[cfg(test)] +mod tests { + use crate::ts_generator::information_schema::{collect_table_schemas, AmbiguousTable, TableSchemas}; + use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + use std::collections::HashMap; + + fn collect(sql: &str) -> TableSchemas { + let statements = Parser::parse_sql(&PostgreSqlDialect {}, sql).unwrap(); + collect_table_schemas(&statements) + } + + fn schemas(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(table, schema)| (table.to_string(), schema.to_string())) + .collect() + } + + #[test] + fn should_ignore_unqualified_tables() { + assert_eq!(collect("SELECT id FROM items"), TableSchemas::default()); + } + + #[test] + fn should_collect_schemas_of_qualified_tables() { + let table_schemas = collect( + "SELECT a.message FROM staff.announcements a JOIN items i ON i.id = a.id WHERE a.id IN (SELECT id FROM audit.logs)", + ); + assert_eq!( + table_schemas.schemas, + schemas(&[("announcements", "staff"), ("logs", "audit")]) + ); + assert!(table_schemas.ambiguous_tables.is_empty()); + } + + #[test] + fn should_use_schema_of_database_schema_table_names() { + let table_schemas = collect("SELECT message FROM postgres.staff.announcements"); + assert_eq!(table_schemas.schemas, schemas(&[("announcements", "staff")])); + } + + #[test] + fn should_strip_quotes_from_qualified_names() { + let table_schemas = collect(r#"SELECT message FROM "staff"."announcements""#); + assert_eq!(table_schemas.schemas, schemas(&[("announcements", "staff")])); + } + + #[test] + fn should_collect_schemas_of_insert_update_and_delete() { + for sql in [ + "INSERT INTO staff.announcements (message) VALUES ($1)", + "UPDATE staff.announcements SET message = $1", + "DELETE FROM staff.announcements WHERE message = $1", + ] { + assert_eq!(collect(sql).schemas, schemas(&[("announcements", "staff")]), "{sql}"); + } + } + + #[test] + fn should_not_flag_the_same_qualified_table_referenced_twice() { + let table_schemas = collect("SELECT a.id FROM staff.announcements a JOIN staff.announcements b ON a.id = b.id"); + assert_eq!(table_schemas.schemas, schemas(&[("announcements", "staff")])); + assert!(table_schemas.ambiguous_tables.is_empty()); + } + + #[test] + fn should_flag_qualified_and_unqualified_table_with_the_same_name() { + let table_schemas = collect("SELECT s.message FROM staff.announcements s JOIN announcements p ON p.id = s.id"); + assert_eq!(table_schemas.schemas, schemas(&[("announcements", "staff")])); + assert_eq!( + table_schemas.ambiguous_tables, + vec![AmbiguousTable { + table_name: "announcements".to_string(), + references: vec!["staff.announcements".to_string(), "announcements".to_string()], + resolved_schema: "staff".to_string(), + }] + ); + } + + #[test] + fn should_flag_tables_with_the_same_name_in_different_schemas() { + let table_schemas = collect("SELECT p.title FROM public.announcements p JOIN staff.announcements s ON p.id = s.id"); + assert_eq!(table_schemas.schemas, schemas(&[("announcements", "public")])); + assert_eq!( + table_schemas.ambiguous_tables, + vec![AmbiguousTable { + table_name: "announcements".to_string(), + references: vec!["public.announcements".to_string(), "staff.announcements".to_string()], + resolved_schema: "public".to_string(), + }] + ); + } +} diff --git a/src/ts_generator/mod.rs b/src/ts_generator/mod.rs index fd1d9f6c..2b6c92ff 100644 --- a/src/ts_generator/mod.rs +++ b/src/ts_generator/mod.rs @@ -2,5 +2,8 @@ pub mod annotations; pub mod errors; pub mod generator; pub mod information_schema; +#[cfg(test)] +#[path = "./information_schema.test.rs"] +mod information_schema_test; pub mod sql_parser; pub mod types; diff --git a/tests/qualified_table_names.rs b/tests/qualified_table_names.rs new file mode 100644 index 00000000..277ad480 --- /dev/null +++ b/tests/qualified_table_names.rs @@ -0,0 +1,206 @@ +#[cfg(test)] +mod qualified_table_names_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!(mysql_should_resolve_table_in_another_database, TestConfig::new("mysql", true, None, None), +//// TS query //// +r#" +const someQuery = sql`SELECT message, priority FROM staff.announcements WHERE message = ?` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [string | null]; + +export interface ISomeQueryResult { + message: string | null; + priority: 'low' | 'high'; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(mysql_should_resolve_aliased_table_in_another_database_with_join, TestConfig::new("mysql", true, None, None), +//// TS query //// +r#" +const someQuery = sql` +SELECT a.message AS message, i.name AS item_name +FROM staff.announcements a +INNER JOIN items i ON i.id = a.id +WHERE a.priority = ? +` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = ['low' | 'high']; + +export interface ISomeQueryResult { + item_name: string; + message: string | null; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(mysql_should_resolve_insert_into_another_database, TestConfig::new("mysql", true, None, None), +//// TS query //// +r#" +const someQuery = sql`INSERT INTO staff.announcements (message, priority) VALUES (?, ?)` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [[string | null, 'low' | 'high']]; + +export interface ISomeQueryResult { + +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(postgres_should_resolve_table_in_another_schema, TestConfig::new("postgres", true, None, None), +//// TS query //// +r#" +const someQuery = sql`SELECT message, priority FROM staff.announcements WHERE message = $1` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [string | null]; + +export interface ISomeQueryResult { + message: string | null; + priority: 'low' | 'high'; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(postgres_should_resolve_wildcard_in_another_schema, TestConfig::new("postgres", true, None, None), +//// TS query //// +r#" +const someQuery = sql`SELECT * FROM staff.announcements` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + id: number; + message: string | null; + priority: 'low' | 'high'; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(postgres_should_resolve_update_in_another_schema, TestConfig::new("postgres", true, None, None), +//// TS query //// +r#" +const someQuery = sql`UPDATE staff.announcements SET message = $1 WHERE priority = $2` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [string | null, 'low' | 'high']; + +export interface ISomeQueryResult { + +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[rustfmt::skip] +run_test!(postgres_should_still_resolve_public_schema_explicitly, TestConfig::new("postgres", true, None, None), +//// TS query //// +r#" +const someQuery = sql`SELECT name FROM public.items WHERE id = $1` +"#, + +//// Generated TS interfaces //// +r#" +export type SomeQueryParams = [number]; + +export interface ISomeQueryResult { + name: string; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#); + + #[test] + fn postgres_should_warn_about_tables_with_the_same_name() -> Result<(), Box> { + use assert_cmd::cargo::cargo_bin_cmd; + + let dir = tempdir()?; + let parent_path = dir.path(); + let mut temp_file = fs::File::create(parent_path.join("index.ts"))?; + writeln!( + temp_file, + r#" +import {{ sql }} from 'sqlx-ts' +const someQuery = sql` +WITH announcements AS (SELECT 1 AS id) +SELECT s.message AS message +FROM staff.announcements s +JOIN announcements a ON a.id = s.id +` +"# + )?; + + let mut cmd = cargo_bin_cmd!("sqlx-ts"); + cmd + .arg(parent_path.to_str().unwrap()) + .arg("--ext=ts") + .arg("--db-type=postgres") + .arg("--db-host=127.0.0.1") + .arg("--db-port=54321") + .arg("--db-user=postgres") + .arg("--db-pass=postgres") + .arg("--db-name=postgres") + .arg("-g"); + + cmd.assert().success().stdout(predicates::str::contains( + "Table 'announcements' is referenced as staff.announcements and announcements in query SomeQuery", + )); + Ok(()) + } +} diff --git a/tests/sqlite_query_parameters.rs b/tests/sqlite_query_parameters.rs index b21c5abd..b9bc2dca 100644 --- a/tests/sqlite_query_parameters.rs +++ b/tests/sqlite_query_parameters.rs @@ -252,4 +252,37 @@ const someQuery = sql`SELECT * FROM nonexistent_table` ); Ok(()) } + + #[test] + fn should_resolve_table_qualified_with_main_database() -> Result<(), Box> { + let schema = "CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, price REAL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`SELECT i.name AS name, i.price AS price FROM main.items i WHERE i.id = ?` +"#; + + let (_, type_file) = run_sqlite_test(schema, ts_content, true)?; + + let expected = r#" +export type SomeQueryParams = [number]; + +export interface ISomeQueryResult { + name: string; + price: number | null; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } }