Skip to content
Closed
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
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 @@ -34,18 +34,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) {
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
94 changes: 65 additions & 29 deletions crates/sqlcx-core/src/parser/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -89,9 +89,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 @@ -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>,
Expand All @@ -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<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 @@ -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"));
}
}
Loading