use super::format::QualifiedName;
use super::quoting::unquote_ident;
pub fn extract_source_table(sql: &str) -> Option<QualifiedName> {
let normalised = sql.trim().trim_end_matches(';').trim();
let lower = normalised.to_ascii_lowercase();
if !lower.starts_with("select") {
return None;
}
let from_pos = find_from_keyword(&lower)?;
let after_from = &normalised[from_pos + " from ".len()..].trim_start();
let (ident, rest) = extract_first_identifier(after_from);
if ident.is_empty() {
return None;
}
let rest_trimmed = rest.trim();
if rest_trimmed.is_empty() || is_clause_boundary(rest_trimmed) {
return Some(split_qualified(&ident));
}
if is_multi_table_indicator(rest_trimmed) {
return None;
}
if let Some(after_alias) = skip_alias(rest_trimmed) {
let after_trimmed = after_alias.trim();
if after_trimmed.is_empty() || is_clause_boundary(after_trimmed) {
return Some(split_qualified(&ident));
}
}
None
}
pub(super) fn find_from_keyword(lower: &str) -> Option<usize> {
let bytes = lower.as_bytes();
let len = bytes.len();
let keyword = b" from ";
let mut depth = 0usize;
let mut i = 0;
while i + keyword.len() <= len {
if bytes[i] == b'(' {
depth += 1;
i += 1;
continue;
}
if bytes[i] == b')' {
depth = depth.saturating_sub(1);
i += 1;
continue;
}
if depth == 0 && lower[i..].starts_with(" from ") {
return Some(i);
}
i += 1;
}
None
}
fn extract_first_identifier(input: &str) -> (String, &str) {
let bytes = input.as_bytes();
let len = bytes.len();
let end = extract_ident_segment(bytes, 0);
if end == 0 {
return (String::new(), input);
}
let mut final_end = end;
if final_end < len && bytes[final_end] == b'.' {
let seg = extract_ident_segment(bytes, final_end + 1);
if seg > final_end + 1 {
final_end = seg;
}
}
(input[..final_end].to_owned(), &input[final_end..])
}
fn extract_ident_segment(bytes: &[u8], start: usize) -> usize {
let len = bytes.len();
if start >= len {
return start;
}
if bytes[start] == b'"' {
let mut i = start + 1;
while i < len {
if bytes[i] == b'"' {
if i + 1 < len && bytes[i + 1] == b'"' {
i += 2; } else {
return i + 1; }
} else {
i += 1;
}
}
start } else if bytes[start].is_ascii_alphabetic() || bytes[start] == b'_' {
let mut i = start + 1;
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
i
} else {
start
}
}
fn is_clause_boundary(rest: &str) -> bool {
let lower = rest.to_ascii_lowercase();
let keywords = [
"where ",
"where\t",
"where\n",
"group ",
"having ",
"order ",
"limit ",
"offset ",
"union ",
"intersect ",
"except ",
"for ",
"lock ",
";",
];
keywords.iter().any(|kw| lower.starts_with(kw))
}
fn split_qualified(ident: &str) -> QualifiedName {
let bytes = ident.as_bytes();
let len = bytes.len();
let first_end = extract_ident_segment(bytes, 0);
if first_end < len && bytes[first_end] == b'.' {
let schema_raw = &ident[..first_end];
let table_raw = &ident[first_end + 1..];
let schema = unquote_ident(schema_raw);
let table = unquote_ident(table_raw);
if !schema.is_empty() && !table.is_empty() {
return QualifiedName {
schema: Some(schema),
table,
};
}
}
QualifiedName {
schema: None,
table: unquote_ident(ident),
}
}
fn is_multi_table_indicator(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
[
"join ", "inner ", "left ", "right ", "full ", "cross ", "natural ", ",",
]
.iter()
.any(|kw| lower.starts_with(kw))
}
fn skip_alias(s: &str) -> Option<&str> {
let lower = s.to_ascii_lowercase();
if lower.starts_with("as ") || lower.starts_with("as\t") || lower.starts_with("as\n") {
let after_as = s["as".len()..].trim_start();
let (_alias, rest) = extract_first_identifier(after_as);
if _alias.is_empty() {
return None;
}
return Some(rest);
}
let (candidate, rest) = extract_first_identifier(s);
if candidate.is_empty() {
return None;
}
let kw = candidate.to_ascii_lowercase();
let reserved = [
"where",
"group",
"having",
"order",
"limit",
"offset",
"union",
"intersect",
"except",
"for",
"lock",
"join",
"inner",
"left",
"right",
"full",
"cross",
"natural",
"on",
"using",
];
if reserved.contains(&kw.as_str()) {
return None;
}
Some(rest)
}