Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 10 additions & 10 deletions crates/sqlcx-core/src/parser/joins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 73 additions & 10 deletions crates/sqlcx-core/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,81 @@ pub fn resolve_parser(name: &str) -> Result<Box<dyn DatabaseParser>> {
}
}

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<String>,
}

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<ColumnDef> {
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Case-sensitive alias comparison causes parse errors for non-lowercase table aliases

resolve_single_table_select_column lowercases the prefix from the expression (let prefix = strip_identifier_quotes(prefix).to_lowercase() at crates/sqlcx-core/src/parser/mod.rs:85) but then compares it with exact equality against allowed_prefixes (*allowed == prefix). The aliases in allowed_prefixes come from extract_table_alias, which returns the alias preserving its original case from the SQL string (e.g., postgres.rs:443 returns Some(alias) without lowercasing). This means any query using a non-lowercase alias like SELECT U.id FROM users U will fail: the alias "U" is pushed into allowed_prefixes as-is, but the prefix from the expression is lowercased to "u", so "U" == "u" is false and the query is rejected.

This is inconsistent with the multi-table JOIN path, where AliasMap correctly lowercases on both insert (joins.rs:52) and lookup (joins.rs:56). The same bug affects all three dialect parsers (postgres, mysql, sqlite) since they all pass un-lowered aliases into the shared resolve_single_table_select_column.

Suggested change
if !allowed_prefixes.iter().any(|allowed| *allowed == prefix) {
if !allowed_prefixes.iter().any(|allowed| allowed.eq_ignore_ascii_case(&prefix)) {
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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 ──────────────────────────────────────
Expand Down
93 changes: 64 additions & 29 deletions crates/sqlcx-core/src/parser/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -90,9 +90,6 @@ static SELECT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*SELECT
static SELECT_COLS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)SELECT\s+([\s\S]+?)\s+FROM\b").unwrap());

static ALIAS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^`?(\w+)`?\s+as\s+`?(\w+)`?$").unwrap());

// ── Type mapping ─────────────────────────────────────────────────────────────

fn type_category(normalized: &str) -> Option<SqlTypeCategory> {
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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<ColumnDef> {
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()
}
Expand Down Expand Up @@ -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"));
}
}
Loading
Loading