use crate::validator::Validator;
fn strip_schema_comments(line: &str) -> &str {
let line = line.split_once("--").map_or(line, |(left, _)| left);
line.split_once('#').map_or(line, |(left, _)| left).trim()
}
#[derive(Debug, Clone)]
pub struct Schema {
pub tables: Vec<TableDef>,
}
#[derive(Debug, Clone)]
pub struct TableDef {
pub name: String,
pub columns: Vec<ColumnDef>,
}
#[derive(Debug, Clone)]
pub struct ColumnDef {
pub name: String,
pub typ: String,
pub nullable: bool,
pub primary_key: bool,
}
impl Schema {
pub fn new() -> Self {
Self { tables: Vec::new() }
}
pub fn add_table(&mut self, table: TableDef) {
self.tables.push(table);
}
pub fn to_validator(&self) -> Validator {
let mut v = Validator::new();
for table in &self.tables {
let cols: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
v.add_table(&table.name, &cols);
}
v
}
pub fn from_qail_schema(input: &str) -> Result<Self, String> {
let mut schema = Schema::new();
let mut current_table: Option<TableDef> = None;
for raw_line in input.lines() {
let line = strip_schema_comments(raw_line);
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("table ") {
if let Some(t) = current_table.take() {
schema.tables.push(t);
}
let name = rest
.trim()
.trim_end_matches('{')
.trim_end_matches('(')
.trim();
if name.is_empty() {
return Err(format!("Invalid table line: {}", line));
}
current_table = Some(TableDef::new(name));
}
else if matches!(line.trim_end_matches(';'), ")" | "}") {
if let Some(t) = current_table.take() {
schema.tables.push(t);
}
}
else if let Some(ref mut table) = current_table {
let line = line.trim_end_matches(',');
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
return Err(format!(
"Invalid column line in table '{}': {}",
table.name, line
));
}
let col_name = parts[0];
let col_type = parts[1];
let not_null = parts.len() > 2
&& parts.iter().any(|&p| p.eq_ignore_ascii_case("not"))
&& parts.iter().any(|&p| p.eq_ignore_ascii_case("null"));
table.columns.push(ColumnDef {
name: col_name.to_string(),
typ: col_type.to_string(),
nullable: !not_null,
primary_key: false,
});
}
}
if let Some(t) = current_table {
schema.tables.push(t);
}
Ok(schema)
}
pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
let content = crate::schema_source::read_qail_schema_source(path)?;
Self::from_qail_schema(&content)
}
}
impl Default for Schema {
fn default() -> Self {
Self::new()
}
}
impl TableDef {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
columns: Vec::new(),
}
}
pub fn add_column(&mut self, col: ColumnDef) {
self.columns.push(col);
}
pub fn column(mut self, name: &str, typ: &str) -> Self {
self.columns.push(ColumnDef {
name: name.to_string(),
typ: typ.to_string(),
nullable: true,
primary_key: false,
});
self
}
pub fn pk(mut self, name: &str, typ: &str) -> Self {
self.columns.push(ColumnDef {
name: name.to_string(),
typ: typ.to_string(),
nullable: false,
primary_key: true,
});
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static RELATION_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_schema_from_qail_schema() {
let qail = r#"
table users (
id uuid not null,
email varchar not null
)
"#;
let schema = Schema::from_qail_schema(qail).unwrap();
assert_eq!(schema.tables.len(), 1);
assert_eq!(schema.tables[0].name, "users");
assert_eq!(schema.tables[0].columns.len(), 2);
}
#[test]
fn test_schema_to_validator() {
let schema = Schema {
tables: vec![
TableDef::new("users")
.pk("id", "uuid")
.column("email", "varchar"),
],
};
let validator = schema.to_validator();
assert!(validator.validate_table("users").is_ok());
assert!(validator.validate_column("users", "id").is_ok());
assert!(validator.validate_column("users", "email").is_ok());
}
#[test]
fn test_table_builder() {
let table = TableDef::new("orders")
.pk("id", "uuid")
.column("total", "decimal")
.column("status", "varchar");
assert_eq!(table.columns.len(), 3);
assert!(table.columns[0].primary_key);
}
#[test]
fn test_build_schema_parses_ref_syntax() {
let schema_content = r#"
table users {
id UUID primary_key
email TEXT
}
table posts {
id UUID primary_key
user_id UUID ref:users.id
title TEXT
}
"#;
let schema = crate::build::Schema::parse(schema_content).unwrap();
assert!(schema.has_table("users"));
assert!(schema.has_table("posts"));
let posts = schema.table("posts").unwrap();
assert_eq!(posts.foreign_keys.len(), 1);
let fk = &posts.foreign_keys[0];
assert_eq!(fk.column, "user_id");
assert_eq!(fk.ref_table, "users");
assert_eq!(fk.ref_column, "id");
}
#[test]
fn test_relation_registry_forward_lookup() {
let mut registry = RelationRegistry::new();
registry.register("posts", "user_id", "users", "id");
let result = registry.get("posts", "users");
assert!(result.is_some());
let (from_col, to_col) = result.unwrap();
assert_eq!(from_col, "user_id");
assert_eq!(to_col, "id");
}
#[test]
fn test_relation_registry_from_build_schema() {
let schema_content = r#"
table users {
id UUID
}
table posts {
user_id UUID ref:users.id
}
table comments {
post_id UUID ref:posts.id
user_id UUID ref:users.id
}
"#;
let schema = crate::build::Schema::parse(schema_content).unwrap();
let registry = RelationRegistry::from_build_schema(&schema);
assert!(registry.get("posts", "users").is_some());
assert!(registry.get("comments", "posts").is_some());
assert!(registry.get("comments", "users").is_some());
let referencing = registry.referencing("users");
assert!(referencing.contains(&"posts"));
assert!(referencing.contains(&"comments"));
}
#[test]
fn test_join_on_produces_correct_ast() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
reg.register("posts", "user_id", "users", "id");
}
let query = Qail::get("users").join_on("posts");
assert_eq!(query.joins.len(), 1);
let join = &query.joins[0];
assert_eq!(join.table, "posts");
let on = join.on.as_ref().expect("Should have ON conditions");
assert_eq!(on.len(), 1);
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
}
#[test]
fn test_join_on_optional_returns_self_when_no_relation() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
let query = Qail::get("users").join_on_optional("nonexistent");
assert!(query.joins.is_empty());
}
#[test]
fn test_join_on_panics_on_ambiguous_relation() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
reg.register("invoices", "buyer_id", "users", "id");
reg.register("invoices", "seller_id", "users", "id");
}
let result = std::panic::catch_unwind(|| {
let _ = Qail::get("invoices").join_on("users");
});
assert!(result.is_err(), "ambiguous relation should panic");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
}
#[test]
fn test_join_on_optional_returns_self_on_ambiguous_relation() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
reg.register("invoices", "buyer_id", "users", "id");
reg.register("invoices", "seller_id", "users", "id");
}
let query = Qail::get("invoices").join_on_optional("users");
assert!(query.joins.is_empty());
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
}
#[test]
fn test_join_on_returns_self_when_no_relation() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
let query = Qail::get("users").join_on("nonexistent");
assert!(query.joins.is_empty());
}
#[test]
fn test_try_join_on_returns_error_when_no_relation() {
use crate::Qail;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
*reg = RelationRegistry::new();
}
let err = Qail::get("users")
.try_join_on("nonexistent")
.expect_err("expected missing relation error");
assert!(err.contains("No relation found"));
}
#[test]
fn test_from_qail_schema_supports_brace_table_blocks() {
let qail = r#"
table users {
id uuid not null
email varchar
}
"#;
let schema = Schema::from_qail_schema(qail).expect("brace-style schema should parse");
assert_eq!(schema.tables.len(), 1);
assert_eq!(schema.tables[0].name, "users");
assert_eq!(schema.tables[0].columns.len(), 2);
}
#[test]
fn test_from_qail_schema_errors_on_malformed_column_line() {
let qail = r#"
table users (
id uuid not null,
email,
)
"#;
let err = Schema::from_qail_schema(qail).expect_err("malformed column should error");
assert!(err.contains("Invalid column line"));
assert!(err.contains("users"));
}
#[test]
fn test_from_qail_schema_ignores_hash_and_inline_comments() {
let qail = r#"
# top-level comment
table users { -- inline table comment
id uuid not null, # id comment
# line comment inside table
email varchar -- email comment
}
"#;
let schema = Schema::from_qail_schema(qail).expect("schema with comments should parse");
assert_eq!(schema.tables.len(), 1);
assert_eq!(schema.tables[0].name, "users");
assert_eq!(schema.tables[0].columns.len(), 2);
assert_eq!(schema.tables[0].columns[0].name, "id");
assert_eq!(schema.tables[0].columns[1].name, "email");
}
#[test]
fn test_replace_schema_relations_replaces_registry_state() {
use std::fs;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let base = std::env::temp_dir().join(format!(
"qail_schema_relations_reload_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&base).expect("mkdir temp");
let schema_with_fk = base.join("schema_with_fk.qail");
fs::write(
&schema_with_fk,
r#"
table users {
id UUID primary_key
}
table posts {
id UUID primary_key
user_id UUID ref:users.id
}
"#,
)
.expect("write schema 1");
let schema_without_fk = base.join("schema_without_fk.qail");
fs::write(
&schema_without_fk,
r#"
table users {
id UUID primary_key
}
table posts {
id UUID primary_key
}
"#,
)
.expect("write schema 2");
let count1 = replace_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count1, 1);
assert!(lookup_relation("posts", "users").is_some());
let count2 =
replace_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count2, 0);
assert!(lookup_relation("posts", "users").is_none());
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let _ = fs::remove_dir_all(base);
}
#[test]
fn test_load_schema_relations_merges_registry_state() {
use std::fs;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let base = std::env::temp_dir().join(format!(
"qail_schema_relations_merge_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&base).expect("mkdir temp");
let schema_with_fk = base.join("schema_with_fk.qail");
fs::write(
&schema_with_fk,
r#"
table users {
id UUID primary_key
}
table posts {
id UUID primary_key
user_id UUID ref:users.id
}
"#,
)
.expect("write schema 1");
let schema_without_fk = base.join("schema_without_fk.qail");
fs::write(
&schema_without_fk,
r#"
table invoices {
id UUID primary_key
user_id UUID ref:users.id
}
"#,
)
.expect("write schema 2");
let count1 = load_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count1, 1);
assert!(lookup_relation("posts", "users").is_some());
let count2 = load_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count2, 1);
assert!(lookup_relation("posts", "users").is_some());
assert!(lookup_relation("invoices", "users").is_some());
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let _ = fs::remove_dir_all(base);
}
#[test]
fn test_merge_schema_relations_merges_registry_state() {
use std::fs;
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let base = std::env::temp_dir().join(format!(
"qail_schema_relations_merge_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&base).expect("mkdir temp");
let schema_with_fk = base.join("schema_with_fk.qail");
fs::write(
&schema_with_fk,
r#"
table users {
id UUID primary_key
}
table posts {
id UUID primary_key
user_id UUID ref:users.id
}
"#,
)
.expect("write schema 1");
let schema_without_fk = base.join("schema_without_fk.qail");
fs::write(
&schema_without_fk,
r#"
table invoices {
id UUID primary_key
}
"#,
)
.expect("write schema 2");
let count1 = merge_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count1, 1);
assert!(lookup_relation("posts", "users").is_some());
let count2 =
merge_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
assert_eq!(count2, 0);
assert!(lookup_relation("posts", "users").is_some());
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
let _ = fs::remove_dir_all(base);
}
#[test]
fn test_lookup_relation_state_errors_on_ambiguous_multi_fk_pair() {
let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
let schema_content = r#"
table users {
id UUID primary_key
}
table invoices {
id UUID primary_key
buyer_id UUID ref:users.id
seller_id UUID ref:users.id
}
"#;
let schema = crate::build::Schema::parse(schema_content).expect("schema parse");
let registry = RelationRegistry::from_build_schema(&schema);
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = registry;
}
let err = lookup_relation_state("invoices", "users").expect_err("ambiguous relation");
assert!(err.contains("Ambiguous relation"));
{
let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
*reg = RelationRegistry::new();
}
}
}
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::RwLock;
#[derive(Debug, Default)]
pub struct RelationRegistry {
forward: HashMap<(String, String), Vec<(String, String)>>,
reverse: HashMap<String, Vec<String>>,
}
impl RelationRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, from_table: &str, from_col: &str, to_table: &str, to_col: &str) {
let entry = self
.forward
.entry((from_table.to_string(), to_table.to_string()))
.or_default();
let pair = (from_col.to_string(), to_col.to_string());
if !entry.iter().any(|existing| existing == &pair) {
entry.push(pair);
}
let entry = self.reverse.entry(to_table.to_string()).or_default();
if !entry.iter().any(|existing| existing == from_table) {
entry.push(from_table.to_string());
}
}
pub fn get(&self, from_table: &str, to_table: &str) -> Option<(&str, &str)> {
let options = self.get_all(from_table, to_table)?;
if options.len() != 1 {
return None;
}
let (a, b) = &options[0];
Some((a.as_str(), b.as_str()))
}
pub fn get_all(&self, from_table: &str, to_table: &str) -> Option<&[(String, String)]> {
self.forward
.get(&(from_table.to_string(), to_table.to_string()))
.map(|pairs| pairs.as_slice())
}
pub fn referencing(&self, table: &str) -> Vec<&str> {
self.reverse
.get(table)
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn from_build_schema(schema: &crate::build::Schema) -> Self {
let mut registry = Self::new();
for table in schema.tables.values() {
for fk in &table.foreign_keys {
registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
}
}
registry
}
}
pub static RUNTIME_RELATIONS: LazyLock<RwLock<RelationRegistry>> =
LazyLock::new(|| RwLock::new(RelationRegistry::new()));
pub fn load_schema_relations(path: &str) -> Result<usize, String> {
merge_schema_relations(path)
}
pub fn merge_schema_relations(path: &str) -> Result<usize, String> {
let schema = crate::build::Schema::parse_file(path)?;
let count: usize = schema
.tables
.values()
.map(|table| table.foreign_keys.len())
.sum();
let mut registry = RUNTIME_RELATIONS
.write()
.map_err(|e| format!("Lock error: {}", e))?;
for table in schema.tables.values() {
for fk in &table.foreign_keys {
registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
}
}
Ok(count)
}
pub fn replace_schema_relations(path: &str) -> Result<usize, String> {
let schema = crate::build::Schema::parse_file(path)?;
let replacement = RelationRegistry::from_build_schema(&schema);
let count: usize = schema
.tables
.values()
.map(|table| table.foreign_keys.len())
.sum();
let mut registry = RUNTIME_RELATIONS
.write()
.map_err(|e| format!("Lock error: {}", e))?;
*registry = replacement;
Ok(count)
}
pub fn lookup_relation(from_table: &str, to_table: &str) -> Option<(String, String)> {
lookup_relation_state(from_table, to_table).ok().flatten()
}
pub fn lookup_relation_state(
from_table: &str,
to_table: &str,
) -> Result<Option<(String, String)>, String> {
let registry = RUNTIME_RELATIONS
.read()
.map_err(|e| format!("Lock error: {}", e))?;
let Some(options) = registry.get_all(from_table, to_table) else {
return Ok(None);
};
if options.len() > 1 {
return Err(format!(
"Ambiguous relation between '{}' and '{}': {} foreign keys registered. Use an explicit join condition.",
from_table,
to_table,
options.len()
));
}
let (fc, tc) = options[0].clone();
Ok(Some((fc, tc)))
}