use crate::schema::parser::{parse_select_columns_with_expressions, split_by_top_level_comma};
use std::collections::HashMap;
const FROM_KEYWORDS: &[&str] = &[
"where", "join", "left", "right", "inner", "outer", "full", "cross", "group", "order", "on",
"union", "limit", "offset", "having", "using", "natural", "as", "and", "or",
];
#[must_use]
pub fn extract_direct_column_map(select_sql: &str, base_table: &str) -> Vec<(String, String)> {
let Ok(columns) = parse_select_columns_with_expressions(select_sql) else {
return Vec::new();
};
let Some((_, data_expr)) = columns
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("data"))
else {
return Vec::new();
};
let Some(args_str) = jsonb_build_object_args(data_expr) else {
return Vec::new();
};
let parts = split_by_top_level_comma(args_str);
if parts.is_empty() || !parts.len().is_multiple_of(2) {
return Vec::new();
}
let alias = resolve_base_alias(select_sql, base_table);
let mut pairs: Vec<(String, String)> = Vec::new();
for pair in parts.chunks_exact(2) {
let (Some(key), Some(col)) = (
parse_key_literal(&pair[0]),
classify_direct_value(&pair[1], base_table, alias.as_deref()),
) else {
continue;
};
pairs.push((col, key));
}
drop_ambiguous(&pairs)
}
fn jsonb_build_object_args(expr: &str) -> Option<&str> {
const FN_NAME: &str = "jsonb_build_object";
let trimmed = expr.trim_start();
let prefix = trimmed.get(..FN_NAME.len())?;
if !prefix.eq_ignore_ascii_case(FN_NAME) {
return None;
}
let after = trimmed[FN_NAME.len()..].trim_start();
if !after.starts_with('(') {
return None;
}
let open_idx = trimmed.len() - after.len();
let bytes = trimmed.as_bytes();
let mut depth: i32 = 0;
let mut in_squote = false;
let mut in_dquote = false;
let mut prev = 0u8;
let mut i = open_idx;
while i < bytes.len() {
match bytes[i] {
b'\'' if !in_dquote && prev != b'\\' => in_squote = !in_squote,
b'"' if !in_squote && prev != b'\\' => in_dquote = !in_dquote,
b'(' if !in_squote && !in_dquote => depth += 1,
b')' if !in_squote && !in_dquote => {
depth -= 1;
if depth == 0 {
return Some(trimmed[open_idx + 1..i].trim());
}
}
_ => {}
}
prev = bytes[i];
i += 1;
}
None
}
fn parse_key_literal(s: &str) -> Option<String> {
let inner = s.trim().strip_prefix('\'')?.strip_suffix('\'')?;
if inner.is_empty() || !is_simple_ident_chars(inner) {
return None;
}
Some(inner.to_string())
}
fn classify_direct_value(value: &str, base_table: &str, alias: Option<&str>) -> Option<String> {
let v = value.trim();
if v.is_empty() {
return None;
}
let mut segs = v.split('.');
let first = segs.next()?;
let (qualifier, col) = match segs.next() {
None => (None, first),
Some(second) => {
if segs.next().is_some() {
return None; }
(Some(first), second)
}
};
if !is_simple_ident(col) {
return None;
}
if let Some(q) = qualifier {
if !is_simple_ident(q) {
return None;
}
let matches_base =
q.eq_ignore_ascii_case(base_table) || alias.is_some_and(|a| q.eq_ignore_ascii_case(a));
if !matches_base {
return None;
}
}
Some(col.to_string())
}
fn resolve_base_alias(select_sql: &str, base_table: &str) -> Option<String> {
let lower = select_sql.to_lowercase();
let base_lower = base_table.to_lowercase();
let from_pos = find_word(&lower, "from", 0)?;
let rel_pos = find_word(&lower, &base_lower, from_pos + "from".len())?;
let after_rel = rel_pos + base_lower.len();
let tail = select_sql.get(after_rel..)?.trim_start();
let tail = match tail.get(..2) {
Some(kw)
if kw.eq_ignore_ascii_case("as")
&& tail[2..].starts_with(|c: char| c.is_whitespace()) =>
{
tail[2..].trim_start()
}
_ => tail,
};
let alias: String = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if alias.is_empty() {
return None;
}
if FROM_KEYWORDS.contains(&alias.to_lowercase().as_str()) {
return None;
}
Some(alias)
}
fn drop_ambiguous(pairs: &[(String, String)]) -> Vec<(String, String)> {
let mut key_counts: HashMap<&str, u32> = HashMap::new();
let mut col_counts: HashMap<&str, u32> = HashMap::new();
for (col, key) in pairs {
*key_counts.entry(key.as_str()).or_insert(0) += 1;
*col_counts.entry(col.as_str()).or_insert(0) += 1;
}
pairs
.iter()
.filter(|(col, key)| key_counts[key.as_str()] == 1 && col_counts[col.as_str()] == 1)
.cloned()
.collect()
}
fn find_word(haystack_lower: &str, needle_lower: &str, start: usize) -> Option<usize> {
if needle_lower.is_empty() {
return None;
}
let hay = haystack_lower.as_bytes();
let needle = needle_lower.as_bytes();
let nlen = needle.len();
let mut i = start;
while i + nlen <= hay.len() {
if &hay[i..i + nlen] == needle {
let before = i == 0 || !is_ident_byte(hay[i - 1]);
let after = i + nlen >= hay.len() || !is_ident_byte(hay[i + nlen]);
if before && after {
return Some(i);
}
}
i += 1;
}
None
}
fn is_simple_ident(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
is_simple_ident_chars(s)
}
fn is_simple_ident_chars(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
const fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(sql: &str, base: &str) -> Vec<(String, String)> {
extract_direct_column_map(sql, base)
}
#[test]
fn extracts_bare_column_and_omits_nested_object() {
let sql = "SELECT pk_post, fk_user, \
jsonb_build_object('title', title, 'author', v_user.data) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn extracts_alias_qualified_base_column() {
let sql = "SELECT pk_post, \
jsonb_build_object('title', p.title) AS data \
FROM tb_post p";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn extracts_alias_qualified_with_as_keyword() {
let sql = "SELECT pk_post, \
jsonb_build_object('title', p.title) AS data \
FROM tb_post AS p";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn extracts_base_table_qualified_column() {
let sql = "SELECT pk_post, \
jsonb_build_object('title', tb_post.title) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn key_differs_from_column_name() {
let sql = "SELECT pk_post, \
jsonb_build_object('headline', title) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "headline".to_string())]
);
}
#[test]
fn extracts_multiple_bare_columns_in_order() {
let sql = "SELECT pk_user, \
jsonb_build_object('name', name, 'bio', bio, 'age', age) AS data \
FROM tb_user";
assert_eq!(
extract(sql, "tb_user"),
vec![
("name".to_string(), "name".to_string()),
("bio".to_string(), "bio".to_string()),
("age".to_string(), "age".to_string()),
]
);
}
#[test]
fn rejects_concatenation_expression() {
let sql = "SELECT pk_user, \
jsonb_build_object('full_name', first_name || ' ' || last_name, 'bio', bio) AS data \
FROM tb_user";
assert_eq!(
extract(sql, "tb_user"),
vec![("bio".to_string(), "bio".to_string())]
);
}
#[test]
fn rejects_cast_expression() {
let sql = "SELECT pk_user, \
jsonb_build_object('n', n::text, 'bio', bio) AS data \
FROM tb_user";
assert_eq!(
extract(sql, "tb_user"),
vec![("bio".to_string(), "bio".to_string())]
);
}
#[test]
fn rejects_other_relation_qualifier() {
let sql = "SELECT pk_post, \
jsonb_build_object('uname', u.name, 'title', title) AS data \
FROM tb_post p JOIN tb_user u ON u.pk_user = p.fk_user";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn rejects_jsonb_agg_value() {
let sql = "SELECT pk_user, \
jsonb_build_object('name', name, 'posts', jsonb_agg(v_post.data)) AS data \
FROM tb_user LEFT JOIN v_post ON v_post.fk_user = pk_user GROUP BY pk_user, name";
assert_eq!(
extract(sql, "tb_user"),
vec![("name".to_string(), "name".to_string())]
);
}
#[test]
fn rejects_nested_jsonb_build_object_value() {
let sql = "SELECT pk_post, \
jsonb_build_object('title', title, 'meta', jsonb_build_object('x', 1)) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn duplicate_key_drops_both() {
let sql = "SELECT pk_post, \
jsonb_build_object('x', a, 'x', b, 'title', title) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
#[test]
fn duplicate_column_drops_both() {
let sql = "SELECT pk_post, \
jsonb_build_object('a', title, 'b', title, 'body', body) AS data \
FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("body".to_string(), "body".to_string())]
);
}
#[test]
fn no_jsonb_build_object_yields_empty_map() {
let sql = "SELECT pk_post, sub.data AS data FROM tb_post \
JOIN (SELECT pk_post, data FROM other) sub USING (pk_post)";
assert!(extract(sql, "tb_post").is_empty());
}
#[test]
fn coalesce_wrapped_data_yields_empty_map() {
let sql = "SELECT pk_post, \
COALESCE(jsonb_build_object('title', title), '{}'::jsonb) AS data \
FROM tb_post";
assert!(extract(sql, "tb_post").is_empty());
}
#[test]
fn missing_data_column_yields_empty_map() {
let sql = "SELECT pk_post, title FROM tb_post";
assert!(extract(sql, "tb_post").is_empty());
}
#[test]
fn rejects_quoted_weird_key() {
let sql = "SELECT pk_post, \
jsonb_build_object('title', title) AS data FROM tb_post";
assert_eq!(
extract(sql, "tb_post"),
vec![("title".to_string(), "title".to_string())]
);
}
}