From a569716957e5ab3c746ea9f722f1ab1ad3b2f5d4 Mon Sep 17 00:00:00 2001 From: JagritGumber Date: Thu, 16 Apr 2026 09:06:14 +0530 Subject: [PATCH] feat: support qualified single-table select columns --- README.md | 5 +- crates/sqlcx-core/src/parser/mod.rs | 83 +++++++++++++-- crates/sqlcx-core/src/parser/mysql.rs | 94 +++++++++++------ crates/sqlcx-core/src/parser/postgres.rs | 127 +++++++++++++++-------- crates/sqlcx-core/src/parser/sqlite.rs | 94 +++++++++++------ crates/sqlcx/tests/cli.rs | 49 ++++++++- 6 files changed, 335 insertions(+), 117 deletions(-) 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/mod.rs b/crates/sqlcx-core/src/parser/mod.rs index 72b32be..b2c2e7c 100644 --- a/crates/sqlcx-core/src/parser/mod.rs +++ b/crates/sqlcx-core/src/parser/mod.rs @@ -34,18 +34,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 7f3087a..9ab47b7 100644 --- a/crates/sqlcx-core/src/parser/mysql.rs +++ b/crates/sqlcx-core/src/parser/mysql.rs @@ -7,8 +7,8 @@ use crate::annotations::extract_annotations; use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::{ - 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, + DatabaseParser, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -89,9 +89,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 { @@ -439,6 +436,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>, @@ -460,36 +496,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() } @@ -689,4 +707,22 @@ mod tests { assert_eq!(dr.params[0].name, "start_date"); assert_eq!(dr.params[1].name, "end_date"); } + + #[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 d724b02..ab19102 100644 --- a/crates/sqlcx-core/src/parser/postgres.rs +++ b/crates/sqlcx-core/src/parser/postgres.rs @@ -7,8 +7,8 @@ use crate::annotations::extract_annotations; use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::{ - 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, + DatabaseParser, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -72,9 +72,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 { @@ -417,29 +414,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( @@ -448,7 +483,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); } @@ -468,35 +503,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() } @@ -639,6 +657,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(); diff --git a/crates/sqlcx-core/src/parser/sqlite.rs b/crates/sqlcx-core/src/parser/sqlite.rs index beeec5f..899e5d2 100644 --- a/crates/sqlcx-core/src/parser/sqlite.rs +++ b/crates/sqlcx-core/src/parser/sqlite.rs @@ -7,8 +7,8 @@ use crate::annotations::extract_annotations; use crate::error::Result; use crate::ir::{ColumnDef, EnumDef, QueryDef, SqlType, SqlTypeCategory, TableDef}; use crate::parser::{ - 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, + DatabaseParser, }; // ── Static regex patterns ──────────────────────────────────────────────────── @@ -67,9 +67,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 { @@ -350,6 +347,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>, @@ -371,36 +407,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() } @@ -600,4 +618,22 @@ mod tests { assert_eq!(dr.params[0].name, "start_date"); assert_eq!(dr.params[1].name, "end_date"); } + + #[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 779b368..fd2b2f4 100644 --- a/crates/sqlcx/tests/cli.rs +++ b/crates/sqlcx/tests/cli.rs @@ -320,7 +320,7 @@ fn cli_generate_prunes_stale_query_files() { } #[test] -fn cli_generate_rejects_qualified_selects() { +fn cli_generate_rejects_multi_table_qualified_selects() { let dir = tempfile::tempdir().unwrap(); let sql_dir = dir.path().join("sql"); let queries_dir = sql_dir.join("queries"); @@ -352,7 +352,52 @@ fn cli_generate_rejects_qualified_selects() { assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr) - .contains("qualified select expressions are not supported yet")); + .contains("multi-table or unsupported qualified select expression")); +} + +#[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]