use crate::db::models::{CodeElement, Relationship};
use once_cell::sync::Lazy;
use regex::Regex;
static CREATE_TABLE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?is)\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|"|\[)?(\w+)(?:`|"|\])?\s*\((.*?)\)(?:\s*;|\s*$)"#,
)
.unwrap()
});
static FK_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?i)FOREIGN\s+KEY\s*\(\s*(?:`|"|\[)?(\w+)(?:`|"|\])?\s*\)\s*REFERENCES\s+(?:`|"|\[)?(\w+)(?:`|"|\])?"#,
)
.unwrap()
});
static PK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)PRIMARY\s+KEY"#).unwrap());
pub struct SqlExtractor<'a> {
source: &'a str,
file_path: &'a str,
}
impl<'a> SqlExtractor<'a> {
pub fn new(source: &'a [u8], file_path: &'a str) -> Self {
Self {
source: std::str::from_utf8(source).unwrap_or(""),
file_path,
}
}
pub fn extract(&self) -> (Vec<CodeElement>, Vec<Relationship>) {
let mut elements: Vec<CodeElement> = Vec::new();
let mut relationships: Vec<Relationship> = Vec::new();
elements.push(CodeElement {
qualified_name: self.file_path.to_string(),
element_type: "file".to_string(),
name: self
.file_path
.rsplit('/')
.next()
.unwrap_or(self.file_path)
.to_string(),
file_path: self.file_path.to_string(),
language: "sql".to_string(),
..Default::default()
});
for cap in CREATE_TABLE_RE.captures_iter(self.source) {
let table_name = cap[1].to_string();
let body = &cap[2];
let line_num = self.line_of(cap.get(0).unwrap().start());
let table_qn = format!("{}::{}", self.file_path, table_name);
elements.push(CodeElement {
qualified_name: table_qn.clone(),
element_type: "table".to_string(),
name: table_name.clone(),
file_path: self.file_path.to_string(),
line_start: line_num,
line_end: line_num,
language: "sql".to_string(),
..Default::default()
});
relationships.push(Relationship {
id: None,
source_qualified: self.file_path.to_string(),
target_qualified: table_qn.clone(),
rel_type: "contains".to_string(),
confidence: 1.0,
metadata: serde_json::json!({"resolution_method": "name"}),
..Default::default()
});
let pk_constraint = PK_RE
.captures_iter(body)
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
.collect::<Vec<_>>();
let pk_columns: Vec<String> = pk_constraint;
for raw in split_top_level(body) {
let trimmed = raw.trim().trim_end_matches(',').trim();
if trimmed.is_empty() {
continue;
}
if is_constraint_keyword(trimmed) {
continue;
}
let col_name = match column_name(trimmed) {
Some(n) => n,
None => continue,
};
let col_qn = format!("{}::{}", table_qn, col_name);
let inline_pk = trimmed.to_ascii_uppercase().contains("PRIMARY KEY");
let is_pk = inline_pk || pk_columns.iter().any(|p| p == &col_name);
elements.push(CodeElement {
qualified_name: col_qn.clone(),
element_type: "column".to_string(),
name: col_name,
file_path: self.file_path.to_string(),
line_start: line_num,
line_end: line_num,
language: "sql".to_string(),
parent_qualified: Some(table_qn.clone()),
metadata: serde_json::json!({
"primary_key": is_pk,
"raw": trimmed,
}),
..Default::default()
});
relationships.push(Relationship {
id: None,
source_qualified: table_qn.clone(),
target_qualified: col_qn,
rel_type: "defines".to_string(),
confidence: 1.0,
metadata: serde_json::json!({"resolution_method": "name"}),
..Default::default()
});
}
for fk in FK_RE.captures_iter(body) {
let fk_col = fk[1].to_string();
let target_table = fk[2].to_string();
let target_qn = format!("{}::{}", self.file_path, target_table);
let source_qn = format!("{}::{}", table_qn, fk_col);
relationships.push(Relationship {
id: None,
source_qualified: source_qn,
target_qualified: target_qn,
rel_type: "references".to_string(),
confidence: 0.95,
metadata: serde_json::json!({
"resolution_method": "name",
"fk_column": fk_col,
}),
..Default::default()
});
}
}
(elements, relationships)
}
fn line_of(&self, offset: usize) -> u32 {
self.source[..offset].matches('\n').count() as u32 + 1
}
}
fn split_top_level(body: &str) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
let mut start = 0usize;
let mut depth: i32 = 0;
let mut in_string: bool = false;
let mut in_line_comment: bool = false;
let mut in_block_comment: bool = false;
let bytes = body.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i] as char;
if in_line_comment {
if c == '\n' {
in_line_comment = false;
}
} else if in_block_comment {
if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '/' {
in_block_comment = false;
i += 1;
}
} else if in_string {
if c == '\'' {
if i + 1 < bytes.len() && bytes[i + 1] as char == '\'' {
i += 1;
} else {
in_string = false;
}
}
} else if c == '\'' {
in_string = true;
} else if c == '-' && i + 1 < bytes.len() && bytes[i + 1] as char == '-' {
in_line_comment = true;
i += 1;
} else if c == '/' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
in_block_comment = true;
i += 1;
} else if c == '(' {
depth += 1;
} else if c == ')' {
depth -= 1;
} else if c == ',' && depth == 0 {
out.push(&body[start..i]);
start = i + 1;
}
i += 1;
}
if start < body.len() {
out.push(&body[start..]);
}
out
}
fn is_constraint_keyword(s: &str) -> bool {
let up = s.to_ascii_uppercase();
up.starts_with("PRIMARY KEY")
|| up.starts_with("FOREIGN KEY")
|| up.starts_with("UNIQUE ")
|| up.starts_with("CHECK ")
|| up.starts_with("CONSTRAINT ")
|| up.starts_with("INDEX ")
|| up.starts_with("KEY ")
}
fn column_name(definition: &str) -> Option<String> {
let first = definition.split_whitespace().next()?;
if first.is_empty() {
return None;
}
let stripped = first
.trim_start_matches('"')
.trim_start_matches('`')
.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('"')
.trim_end_matches('`')
.to_string();
if stripped.is_empty() || !is_identifier(&stripped) {
None
} else {
Some(stripped)
}
}
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_create_table_with_columns_and_pk() {
let sql = r#"
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
name TEXT
);
"#;
let (elems, rels) = SqlExtractor::new(sql.as_bytes(), "schema.sql").extract();
assert!(elems
.iter()
.any(|e| e.element_type == "table" && e.name == "users"));
let id = elems
.iter()
.find(|e| e.element_type == "column" && e.name == "id");
assert!(id.is_some());
let id_meta = id.unwrap().metadata.clone();
assert_eq!(id_meta["primary_key"], serde_json::Value::Bool(true));
assert!(elems.iter().any(|e| e.name == "email"));
assert!(rels
.iter()
.any(|r| r.rel_type == "defines" && r.target_qualified.ends_with("::email")));
}
#[test]
fn extracts_foreign_key_references_relationship() {
let sql = r#"
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
"#;
let (elems, rels) = SqlExtractor::new(sql.as_bytes(), "orders.sql").extract();
assert!(elems
.iter()
.any(|e| e.element_type == "table" && e.name == "orders"));
assert!(rels
.iter()
.any(|r| r.rel_type == "references" && r.target_qualified.ends_with("::users")));
}
#[test]
fn split_top_level_respects_parens_and_strings() {
let body = "id INT DEFAULT nextval('seq'), name TEXT, age INT";
let parts: Vec<&str> = split_top_level(body);
assert_eq!(parts.len(), 3);
assert!(parts[0].contains("DEFAULT nextval('seq')"));
}
}