diff --git a/README.md b/README.md index 5d77651..39b1e36 100644 --- a/README.md +++ b/README.md @@ -289,14 +289,15 @@ Generates `ListUserEmailsRow` with only `{ id, email }` — not the full table t ### Current query boundary -sqlcx currently supports single-table query shape inference for generated row types and parameter typing. +sqlcx currently supports single-table query shape inference for generated row types and parameter typing, including qualified references to the base table. - `SELECT * FROM users` - `SELECT id, email FROM users` +- `SELECT users.id, users.name AS user_name FROM users` - `INSERT ... VALUES (...)` - `UPDATE ... RETURNING id, name` -Qualified select expressions and join-shaped projections such as `SELECT users.id, orgs.slug ...` are rejected for now instead of generating invalid code. That keeps the generated output sound while the multi-table IR is still intentionally narrow. +Join-shaped projections such as `SELECT users.id, orgs.slug ...` are still rejected for now instead of generating invalid code. That keeps the generated output sound while the multi-table IR is still intentionally narrow. ### Caching diff --git a/crates/sqlcx-core/src/parser/joins.rs b/crates/sqlcx-core/src/parser/joins.rs index 1532d87..73fb8aa 100644 --- a/crates/sqlcx-core/src/parser/joins.rs +++ b/crates/sqlcx-core/src/parser/joins.rs @@ -13,17 +13,17 @@ //! table and column in the alias map and return a fully-typed //! [`ColumnDef`] with `source_table` populated. //! -//! The helpers are **not yet wired into any dialect parser**. The existing -//! [`ensure_supported_select_expr`](super::ensure_supported_select_expr) -//! guard still rejects qualified selects in every dialect. A follow-up PR -//! per dialect (postgres, mysql, sqlite) will flip each to call into -//! these helpers when JOIN clauses are present. +//! Each dialect's `resolve_return_columns` calls into these helpers when +//! [`has_outer_join`] detects a JOIN in the outer FROM. Single-table queries +//! continue to use the per-dialect single-table path +//! ([`super::resolve_single_table_select_column`]), which now also accepts +//! `table.column` projections that resolve against the inferred base table. //! -//! Scope for v1.1: INNER JOIN only, qualified columns only, no `SELECT *` -//! across joins. OUTER JOIN nullability propagation, `USING`, NATURAL -//! JOIN, lateral joins, and self-joins with aliases are v1.2 work — they -//! would require `ColumnDef.nullable` to become per-query-context rather -//! than per-schema. +//! Scope: INNER JOIN only, qualified columns only, no `SELECT *` across +//! joins. OUTER JOIN nullability propagation, `USING`, NATURAL JOIN, +//! lateral joins, and self-joins with aliases require `ColumnDef.nullable` +//! to become per-query-context rather than per-schema; that's a later +//! release. use std::collections::HashMap; use std::sync::LazyLock; diff --git a/crates/sqlcx-core/src/parser/mod.rs b/crates/sqlcx-core/src/parser/mod.rs index 9c74457..43d2058 100644 --- a/crates/sqlcx-core/src/parser/mod.rs +++ b/crates/sqlcx-core/src/parser/mod.rs @@ -35,18 +35,81 @@ pub fn resolve_parser(name: &str) -> Result> { } } -pub(crate) fn ensure_supported_select_expr(expr: &str, source_file: &str) -> Result<()> { +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ParsedSelectExpr { + pub source_name: String, + pub alias: Option, +} + +pub(crate) fn strip_identifier_quotes(ident: &str) -> &str { + ident + .strip_prefix('`') + .and_then(|s| s.strip_suffix('`')) + .or_else(|| ident.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) + .or_else(|| ident.strip_prefix('[').and_then(|s| s.strip_suffix(']'))) + .unwrap_or(ident) +} + +pub(crate) fn parse_select_expr(expr: &str) -> ParsedSelectExpr { let trimmed = expr.trim(); - if trimmed.contains('.') { - return Err(crate::error::SqlcxError::ParseError { - file: source_file.to_string(), - message: format!( - "qualified select expressions are not supported yet: `{}`", - trimmed - ), - }); + let lower = trimmed.to_lowercase(); + + if let Some(idx) = lower.rfind(" as ") { + let source = trimmed[..idx].trim(); + let alias = trimmed[idx + 4..].trim(); + return ParsedSelectExpr { + source_name: source.to_string(), + alias: Some(strip_identifier_quotes(alias).to_lowercase()), + }; } - Ok(()) + + ParsedSelectExpr { + source_name: trimmed.to_string(), + alias: None, + } +} + +pub(crate) fn resolve_single_table_select_column( + expr: &str, + allowed_prefixes: &[&str], + table: &TableDef, + source_file: &str, +) -> Result { + let parsed = parse_select_expr(expr); + let source = parsed.source_name.trim(); + let parts: Vec<&str> = source.split('.').collect(); + + let column_name = match parts.as_slice() { + [column] => strip_identifier_quotes(column).to_lowercase(), + [prefix, column] => { + let prefix = strip_identifier_quotes(prefix).to_lowercase(); + if !allowed_prefixes.iter().any(|allowed| *allowed == prefix) { + return Err(crate::error::SqlcxError::ParseError { + file: source_file.to_string(), + message: format!( + "multi-table or unsupported qualified select expression: `{}`", + expr.trim() + ), + }); + } + strip_identifier_quotes(column).to_lowercase() + } + _ => { + return Err(crate::error::SqlcxError::ParseError { + file: source_file.to_string(), + message: format!("unsupported select expression: `{}`", expr.trim()), + }); + } + }; + + let mut col = table + .columns + .iter() + .find(|c| c.name == column_name) + .cloned() + .unwrap_or_else(|| make_unknown_column(&column_name)); + col.alias = parsed.alias; + Ok(col) } // ── Shared regex for split_query_blocks ────────────────────────────────────── diff --git a/crates/sqlcx-core/src/parser/mysql.rs b/crates/sqlcx-core/src/parser/mysql.rs index 016a892..d89bf28 100644 --- a/crates/sqlcx-core/src/parser/mysql.rs +++ b/crates/sqlcx-core/src/parser/mysql.rs @@ -8,8 +8,8 @@ use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::joins::{has_outer_join, resolve_multi_table_columns}; use crate::parser::{ - DatabaseParser, build_params, ensure_supported_select_expr, make_unknown_column, - split_column_defs, split_query_blocks, + DatabaseParser, build_params, resolve_single_table_select_column, split_column_defs, + split_query_blocks, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -90,9 +90,6 @@ static SELECT_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\s*SELECT static SELECT_COLS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)SELECT\s+([\s\S]+?)\s+FROM\b").unwrap()); -static ALIAS_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)^`?(\w+)`?\s+as\s+`?(\w+)`?$").unwrap()); - // ── Type mapping ───────────────────────────────────────────────────────────── fn type_category(normalized: &str) -> Option { @@ -440,6 +437,45 @@ fn find_from_table<'a>(sql: &str, tables: &'a [TableDef]) -> Option<&'a TableDef tables.iter().find(|t| t.name == table_name) } +fn extract_table_alias<'a>(sql: &'a str, table: &TableDef) -> Option<&'a str> { + let lower = sql.to_lowercase(); + let table_name = &table.name; + let patterns = [ + format!("from `{}` as ", table_name), + format!("from `{}` ", table_name), + format!("from {} as ", table_name), + format!("from {} ", table_name), + format!("into `{}` as ", table_name), + format!("into `{}` ", table_name), + format!("into {} as ", table_name), + format!("into {} ", table_name), + format!("update `{}` as ", table_name), + format!("update `{}` ", table_name), + format!("update {} as ", table_name), + format!("update {} ", table_name), + ]; + + for pattern in patterns { + if let Some(idx) = lower.find(&pattern) { + let remainder = sql[idx + pattern.len()..].trim_start(); + let alias = remainder + .split(|ch: char| ch.is_whitespace() || ch == ';' || ch == ',') + .next() + .unwrap_or(""); + if !alias.is_empty() + && !matches!( + alias.to_lowercase().as_str(), + "where" | "join" | "order" | "group" | "limit" + ) + { + return Some(alias); + } + } + } + + None +} + fn resolve_return_columns( sql: &str, table: Option<&TableDef>, @@ -469,36 +505,18 @@ fn resolve_return_columns( let Some(table) = table else { return Ok(Vec::new()); }; + let alias = extract_table_alias(sql, table); + let mut allowed_prefixes = vec![table.name.as_str()]; + if let Some(alias) = alias { + allowed_prefixes.push(alias); + } let col_names: Vec<&str> = cols_part.split(',').map(|s| s.trim()).collect(); col_names .iter() .map(|&col_expr| -> Result { - ensure_supported_select_expr(col_expr, source_file)?; - let expr_lower = col_expr.to_lowercase(); - if let Some(alias_cap) = ALIAS_RE.captures(&expr_lower) { - let actual = &alias_cap[1]; - let alias = alias_cap[2].to_string(); - Ok(table - .columns - .iter() - .find(|c| c.name == actual) - .map(|c| { - let mut col = c.clone(); - col.alias = Some(alias); - col - }) - .unwrap_or_else(|| make_unknown_column(actual))) - } else { - let name = expr_lower.trim_matches('`'); - Ok(table - .columns - .iter() - .find(|c| c.name == name) - .cloned() - .unwrap_or_else(|| make_unknown_column(name))) - } + resolve_single_table_select_column(col_expr, &allowed_prefixes, table, source_file) }) .collect() } @@ -742,4 +760,21 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("v1.1 supports INNER JOIN only")); } + + #[test] + fn parses_qualified_single_table_select() { + let parser = MySqlParser::new(); + let (tables, enums) = parser.parse_schema(SCHEMA_SQL).unwrap(); + let sql = "-- name: ListUsersQualified :many\nSELECT users.id, users.name AS user_name FROM users;"; + let queries = parser + .parse_queries(sql, &tables, &enums, "mysql_queries/users.sql") + .unwrap(); + let query = queries + .iter() + .find(|q| q.name == "ListUsersQualified") + .unwrap(); + assert_eq!(query.returns.len(), 2); + assert_eq!(query.returns[0].name, "id"); + assert_eq!(query.returns[1].alias.as_deref(), Some("user_name")); + } } diff --git a/crates/sqlcx-core/src/parser/postgres.rs b/crates/sqlcx-core/src/parser/postgres.rs index 95ffc4c..01adf7c 100644 --- a/crates/sqlcx-core/src/parser/postgres.rs +++ b/crates/sqlcx-core/src/parser/postgres.rs @@ -8,8 +8,8 @@ use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::joins::{has_outer_join, resolve_multi_table_columns}; use crate::parser::{ - DatabaseParser, build_params, ensure_supported_select_expr, make_unknown_column, - split_column_defs, split_query_blocks, + DatabaseParser, build_params, resolve_single_table_select_column, split_column_defs, + split_query_blocks, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -73,9 +73,6 @@ static SELECT_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\s*SELECT static SELECT_COLS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)SELECT\s+([\s\S]+?)\s+FROM\b").unwrap()); -static ALIAS_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)^(\w+)\s+as\s+(\w+)$").unwrap()); - // ── Type mapping ───────────────────────────────────────────────────────────── fn type_category(normalized: &str) -> Option { @@ -418,29 +415,67 @@ fn find_from_table<'a>(sql: &str, tables: &'a [TableDef]) -> Option<&'a TableDef tables.iter().find(|t| t.name == table_name) } -fn resolve_returning_columns(sql: &str, table: Option<&TableDef>) -> Option> { - let cap = RETURNING_RE.captures(sql)?; +fn extract_table_alias<'a>(sql: &'a str, table: &TableDef) -> Option<&'a str> { + let lower = sql.to_lowercase(); + let table_name = &table.name; + let patterns = [ + format!("from {} as ", table_name), + format!("into {} as ", table_name), + format!("update {} as ", table_name), + format!("from {} ", table_name), + format!("into {} ", table_name), + format!("update {} ", table_name), + ]; + + for pattern in patterns { + if let Some(idx) = lower.find(&pattern) { + let remainder = sql[idx + pattern.len()..].trim_start(); + let alias = remainder + .split(|ch: char| ch.is_whitespace() || ch == ';' || ch == ',') + .next() + .unwrap_or(""); + if !alias.is_empty() + && !matches!( + alias.to_lowercase().as_str(), + "where" | "join" | "order" | "group" | "limit" | "returning" + ) + { + return Some(alias); + } + } + } + + None +} + +fn resolve_returning_columns( + sql: &str, + table: Option<&TableDef>, + source_file: &str, +) -> Result>> { + let Some(cap) = RETURNING_RE.captures(sql) else { + return Ok(None); + }; let cols_part = cap[1].trim(); if cols_part == "*" { - return Some(table.map(|t| t.columns.clone()).unwrap_or_default()); + return Ok(Some(table.map(|t| t.columns.clone()).unwrap_or_default())); } - let table = table?; - Some( + let Some(table) = table else { + return Ok(None); + }; + let alias = extract_table_alias(sql, table); + let mut allowed_prefixes = vec![table.name.as_str()]; + if let Some(alias) = alias { + allowed_prefixes.push(alias); + } + Ok(Some( cols_part .split(',') - .map(|s| { - let name = s.trim().to_lowercase(); - table - .columns - .iter() - .find(|c| c.name == name) - .cloned() - .unwrap_or_else(|| make_unknown_column(&name)) - }) - .collect(), - ) + .map(|s| resolve_single_table_select_column(s, &allowed_prefixes, table, source_file)) + .collect::>>()?, + )) } fn resolve_return_columns( @@ -450,7 +485,7 @@ fn resolve_return_columns( source_file: &str, ) -> Result> { // Check RETURNING clause first - if let Some(returning) = resolve_returning_columns(sql, table) { + if let Some(returning) = resolve_returning_columns(sql, table, source_file)? { return Ok(returning); } @@ -479,35 +514,18 @@ fn resolve_return_columns( let Some(table) = table else { return Ok(Vec::new()); }; + let alias = extract_table_alias(sql, table); + let mut allowed_prefixes = vec![table.name.as_str()]; + if let Some(alias) = alias { + allowed_prefixes.push(alias); + } let col_names: Vec<&str> = cols_part.split(',').map(|s| s.trim()).collect(); col_names .iter() .map(|&col_expr| -> Result { - ensure_supported_select_expr(col_expr, source_file)?; - let expr_lower = col_expr.to_lowercase(); - if let Some(alias_cap) = ALIAS_RE.captures(&expr_lower) { - let actual = &alias_cap[1]; - let alias = alias_cap[2].to_string(); - Ok(table - .columns - .iter() - .find(|c| c.name == actual) - .map(|c| { - let mut col = c.clone(); - col.alias = Some(alias); - col - }) - .unwrap_or_else(|| make_unknown_column(actual))) - } else { - Ok(table - .columns - .iter() - .find(|c| c.name == expr_lower) - .cloned() - .unwrap_or_else(|| make_unknown_column(&expr_lower))) - } + resolve_single_table_select_column(col_expr, &allowed_prefixes, table, source_file) }) .collect() } @@ -650,6 +668,25 @@ mod tests { assert_eq!(list_users.returns.len(), 3); // SELECT id, name, email } + #[test] + fn parses_qualified_single_table_select() { + let parser = PostgresParser::new(); + let (tables, enums) = parser.parse_schema(SCHEMA_SQL).unwrap(); + let sql = "-- name: ListUsersQualified :many\nSELECT users.id, users.name AS user_name FROM users;"; + let queries = parser + .parse_queries(sql, &tables, &enums, "queries/users.sql") + .unwrap(); + let query = queries + .iter() + .find(|q| q.name == "ListUsersQualified") + .unwrap(); + assert_eq!(query.returns.len(), 2); + assert_eq!(query.returns[0].name, "id"); + assert_eq!(query.returns[0].alias, None); + assert_eq!(query.returns[1].name, "name"); + assert_eq!(query.returns[1].alias.as_deref(), Some("user_name")); + } + #[test] fn parses_create_user_exec() { let parser = PostgresParser::new(); @@ -854,20 +891,16 @@ mod tests { } #[test] - fn single_table_path_still_rejects_qualified_selects() { - // Queries without JOIN go through the existing single-table path, - // which still rejects qualified selects via ensure_supported_select_expr. - // (PR #32 is the separate effort that relaxes this for single-table queries.) + fn single_table_path_accepts_qualified_selects() { + // The single-table path now resolves `table.column` against the base + // table via resolve_single_table_select_column. (Prior to PR #32 this + // returned a parse error.) let parser = PostgresParser::new(); let (tables, enums) = parser.parse_schema(join_schema()).unwrap(); - let sql = "-- name: Bad :one\nSELECT users.id FROM users WHERE users.id = $1;"; - let err = parser - .parse_queries(sql, &tables, &enums, "q.sql") - .unwrap_err(); - assert!( - err.to_string() - .contains("qualified select expressions are not supported") - ); + let sql = "-- name: GetUserId :one\nSELECT users.id FROM users WHERE users.id = $1;"; + let queries = parser.parse_queries(sql, &tables, &enums, "q.sql").unwrap(); + assert_eq!(queries[0].returns.len(), 1); + assert_eq!(queries[0].returns[0].name, "id"); } #[test] diff --git a/crates/sqlcx-core/src/parser/sqlite.rs b/crates/sqlcx-core/src/parser/sqlite.rs index 7559e28..bce55b9 100644 --- a/crates/sqlcx-core/src/parser/sqlite.rs +++ b/crates/sqlcx-core/src/parser/sqlite.rs @@ -8,8 +8,8 @@ use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::joins::{has_outer_join, resolve_multi_table_columns}; use crate::parser::{ - DatabaseParser, build_params, ensure_supported_select_expr, make_unknown_column, - split_column_defs, split_query_blocks, + DatabaseParser, build_params, resolve_single_table_select_column, split_column_defs, + split_query_blocks, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -68,9 +68,6 @@ static SELECT_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\s*SELECT static SELECT_COLS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)SELECT\s+([\s\S]+?)\s+FROM\b").unwrap()); -static ALIAS_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)^`?(\w+)`?\s+as\s+`?(\w+)`?$").unwrap()); - // -- Type mapping -------------------------------------------------------- fn type_category(normalized: &str) -> SqlTypeCategory { @@ -351,6 +348,45 @@ fn find_from_table<'a>(sql: &str, tables: &'a [TableDef]) -> Option<&'a TableDef tables.iter().find(|t| t.name == table_name) } +fn extract_table_alias<'a>(sql: &'a str, table: &TableDef) -> Option<&'a str> { + let lower = sql.to_lowercase(); + let table_name = &table.name; + let patterns = [ + format!("from `{}` as ", table_name), + format!("from `{}` ", table_name), + format!("from {} as ", table_name), + format!("from {} ", table_name), + format!("into `{}` as ", table_name), + format!("into `{}` ", table_name), + format!("into {} as ", table_name), + format!("into {} ", table_name), + format!("update `{}` as ", table_name), + format!("update `{}` ", table_name), + format!("update {} as ", table_name), + format!("update {} ", table_name), + ]; + + for pattern in patterns { + if let Some(idx) = lower.find(&pattern) { + let remainder = sql[idx + pattern.len()..].trim_start(); + let alias = remainder + .split(|ch: char| ch.is_whitespace() || ch == ';' || ch == ',') + .next() + .unwrap_or(""); + if !alias.is_empty() + && !matches!( + alias.to_lowercase().as_str(), + "where" | "join" | "order" | "group" | "limit" + ) + { + return Some(alias); + } + } + } + + None +} + fn resolve_return_columns( sql: &str, table: Option<&TableDef>, @@ -380,36 +416,18 @@ fn resolve_return_columns( let Some(table) = table else { return Ok(Vec::new()); }; + let alias = extract_table_alias(sql, table); + let mut allowed_prefixes = vec![table.name.as_str()]; + if let Some(alias) = alias { + allowed_prefixes.push(alias); + } let col_names: Vec<&str> = cols_part.split(',').map(|s| s.trim()).collect(); col_names .iter() .map(|&col_expr| -> Result { - ensure_supported_select_expr(col_expr, source_file)?; - let expr_lower = col_expr.to_lowercase(); - if let Some(alias_cap) = ALIAS_RE.captures(&expr_lower) { - let actual = &alias_cap[1]; - let alias = alias_cap[2].to_string(); - Ok(table - .columns - .iter() - .find(|c| c.name == actual) - .map(|c| { - let mut col = c.clone(); - col.alias = Some(alias); - col - }) - .unwrap_or_else(|| make_unknown_column(actual))) - } else { - let name = expr_lower.trim_matches('`'); - Ok(table - .columns - .iter() - .find(|c| c.name == name) - .cloned() - .unwrap_or_else(|| make_unknown_column(name))) - } + resolve_single_table_select_column(col_expr, &allowed_prefixes, table, source_file) }) .collect() } @@ -653,4 +671,21 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("v1.1 supports INNER JOIN only")); } + + #[test] + fn parses_qualified_single_table_select() { + let parser = SqliteParser::new(); + let (tables, enums) = parser.parse_schema(SCHEMA_SQL).unwrap(); + let sql = "-- name: ListUsersQualified :many\nSELECT users.id, users.name AS user_name FROM users;"; + let queries = parser + .parse_queries(sql, &tables, &enums, "sqlite_queries/users.sql") + .unwrap(); + let query = queries + .iter() + .find(|q| q.name == "ListUsersQualified") + .unwrap(); + assert_eq!(query.returns.len(), 2); + assert_eq!(query.returns[0].name, "id"); + assert_eq!(query.returns[1].alias.as_deref(), Some("user_name")); + } } diff --git a/crates/sqlcx/tests/cli.rs b/crates/sqlcx/tests/cli.rs index b4d47e2..8e12f74 100644 --- a/crates/sqlcx/tests/cli.rs +++ b/crates/sqlcx/tests/cli.rs @@ -320,9 +320,9 @@ fn cli_generate_prunes_stale_query_files() { #[test] fn cli_generate_accepts_multi_table_inner_join() { - // JOIN queries with qualified columns now succeed via the multi-table - // resolver path. Single-table qualified selects are still rejected — - // that's a separate effort (PR #32). + // JOIN queries with qualified columns succeed via the multi-table + // resolver. Single-table qualified selects also succeed via the + // single-table path (see cli_generate_accepts_qualified_single_table_selects). let dir = tempfile::tempdir().unwrap(); let sql_dir = dir.path().join("sql"); let queries_dir = sql_dir.join("queries"); @@ -359,6 +359,51 @@ fn cli_generate_accepts_multi_table_inner_join() { ); } +#[test] +fn cli_generate_accepts_qualified_single_table_selects() { + let dir = tempfile::tempdir().unwrap(); + let sql_dir = dir.path().join("sql"); + let queries_dir = sql_dir.join("queries"); + std::fs::create_dir_all(&queries_dir).unwrap(); + + std::fs::copy( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/schema.sql" + ), + sql_dir.join("schema.sql"), + ) + .unwrap(); + + std::fs::write( + queries_dir.join("users.sql"), + "-- name: ListUsersQualified :many\nSELECT users.id, users.name AS user_name FROM users;\n", + ) + .unwrap(); + + std::fs::write( + dir.path().join("sqlcx.toml"), + "sql = \"./sql\"\nparser = \"postgres\"\n\n[[targets]]\nlanguage = \"typescript\"\nout = \"./src/db\"\nschema = \"typebox\"\ndriver = \"bun-sql\"\n", + ) + .unwrap(); + + let output = sqlcx_bin() + .arg("generate") + .current_dir(dir.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = std::fs::read_to_string(dir.path().join("src/db/users.queries.ts")).unwrap(); + assert!(content.contains("export interface ListUsersQualifiedRow")); + assert!(content.contains("id: number;")); + assert!(content.contains("user_name: string;")); +} + #[test] fn cli_generate_keeps_files_from_multiple_targets_in_same_out_dir() { let dir = tempfile::tempdir().unwrap();