use lutra_bin::ir;
use crate::{Error, Runner};
#[allow(dead_code)]
mod lutra {
include!(concat!(env!("OUT_DIR"), "/lutra.rs"));
}
pub fn pull_interface(runner: &mut Runner) -> Result<String, Error> {
use lutra_runner::RunSync;
let program = lutra::pull_interface();
let mut schemas = runner.run_sync(&program, &())?.unwrap();
let mut output = String::new();
const DEFAULT_SCHEMA_NAME: &str = "main";
let is_default = |x: &lutra::PullInterfaceOutputItems| x.schema_name != DEFAULT_SCHEMA_NAME;
schemas.sort_by(|a, b| {
(is_default(a).cmp(&is_default(b))).then(a.schema_name.cmp(&b.schema_name))
});
for schema in schemas {
let mut indent = "";
if schema.schema_name != DEFAULT_SCHEMA_NAME {
output += "\n";
output += &format!("module {} {{\n", schema.schema_name);
indent = " ";
}
for table in schema.tables {
let t_name = &table.table_name;
let table_ty = tuple_from_columns(&table.columns);
let ty_name = table_type_name(t_name);
let snake = crate::case::to_snake_case(t_name);
output += "\n";
output += &format!("{indent}## Row of table {t_name}\n");
output += &format!(
"{indent}type {ty_name}: {}\n",
lutra_bin::ir::print_ty(&table_ty)
);
output += &format!("{indent}## Read from table {t_name}\n");
output += &format!(
"{indent}func from_{snake}(): [{ty_name}] -> std::sql::from(\"{t_name}\")\n",
);
output += &format!("{indent}## Write into table {t_name}\n");
output += &format!(
"{indent}func insert_{snake}(values: [{ty_name}]) -> std::sql::insert(values, \"{t_name}\")\n"
);
let mut lookups = Vec::new();
lookups.extend(collect_constraint_lookups(&table));
lookups.extend(collect_index_lookups(&table));
for lookup in lookups {
generate_lookup_function(&mut output, indent, &table, &ty_name, &snake, &lookup);
}
}
if schema.schema_name != DEFAULT_SCHEMA_NAME {
output += "}\n";
}
}
Ok(output)
}
fn tuple_from_columns(
columns: &[lutra::PullInterfaceOutputItemstablesItemscolumnsItems],
) -> ir::Ty {
let fields = columns
.iter()
.map(|c| ir::TyTupleField {
name: Some(c.name.clone()),
ty: ty_from_duckdb_type(&c.data_type, c.is_nullable),
})
.collect();
ir::Ty::new(ir::TyKind::Tuple(fields))
}
fn ty_from_duckdb_type(data_type: &str, is_nullable: bool) -> ir::Ty {
if data_type.starts_with("STRUCT(") && data_type.ends_with(")") {
let struct_ty = parse_struct_type(data_type);
return if is_nullable {
wrap_in_option(struct_ty)
} else {
struct_ty
};
}
if let Some(item_type_str) = data_type.strip_suffix("[]") {
let item_ty = ty_from_duckdb_type(item_type_str, false);
let array_ty = ir::Ty::new(ir::TyKind::Array(Box::new(item_ty)));
return if is_nullable {
wrap_in_option(array_ty)
} else {
array_ty
};
}
let base_ty = match data_type.to_uppercase().as_str() {
"BOOLEAN" | "BOOL" => ir::Ty::new(ir::TyPrimitive::bool),
"TINYINT" | "INT1" => ir::Ty::new(ir::TyPrimitive::int8),
"SMALLINT" | "INT2" => ir::Ty::new(ir::TyPrimitive::int16),
"INTEGER" | "INT4" | "INT" => ir::Ty::new(ir::TyPrimitive::int32),
"BIGINT" | "INT8" => ir::Ty::new(ir::TyPrimitive::int64),
"HUGEINT" | "INT128" => ir::Ty::new(ir::TyPrimitive::int64),
"UTINYINT" | "UINT8" => ir::Ty::new(ir::TyPrimitive::uint8),
"USMALLINT" | "UINT16" => ir::Ty::new(ir::TyPrimitive::uint16),
"UINTEGER" | "UINT32" => ir::Ty::new(ir::TyPrimitive::uint32),
"UBIGINT" | "UINT64" => ir::Ty::new(ir::TyPrimitive::uint64),
"UHUGEINT" | "UINT128" => ir::Ty::new(ir::TyPrimitive::uint64),
"FLOAT" | "FLOAT4" | "REAL" => ir::Ty::new(ir::TyPrimitive::float32),
"DOUBLE" | "FLOAT8" => ir::Ty::new(ir::TyPrimitive::float64),
"VARCHAR" | "TEXT" | "STRING" | "CHAR" | "BPCHAR" | "NAME" => {
ir::Ty::new(ir::TyPrimitive::text)
}
"DATE" => ir::Ty::new(ir::Path(vec!["std".into(), "Date".into()])),
"TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" | "DATETIME" => {
ir::Ty::new(ir::Path(vec!["std".into(), "Timestamp".into()]))
}
"TIME" | "TIME WITHOUT TIME ZONE" => {
ir::Ty::new(ir::Path(vec!["std".into(), "Time".into()]))
}
s if s.starts_with("DECIMAL") || s.starts_with("NUMERIC") => {
ir::Ty::new(ir::Path(vec!["std".into(), "Decimal".into()]))
}
s if s.starts_with("VARCHAR") || s.starts_with("CHAR") || s.starts_with("BPCHAR") => {
ir::Ty::new(ir::TyPrimitive::text)
}
_ => ir::Ty::new(ir::TyPrimitive::text),
};
if is_nullable {
wrap_in_option(base_ty)
} else {
base_ty
}
}
fn wrap_in_option(ty: ir::Ty) -> ir::Ty {
let variants = vec![
ir::TyEnumVariant {
name: "none".into(),
ty: ir::Ty::new_unit(),
},
ir::TyEnumVariant {
name: "some".into(),
ty,
},
];
ir::Ty::new(ir::TyKind::Enum(variants))
}
fn parse_struct_type(data_type: &str) -> ir::Ty {
let inner = &data_type[7..data_type.len() - 1];
let fields = parse_struct_fields(inner);
ir::Ty::new(ir::TyKind::Tuple(fields))
}
fn parse_struct_fields(input: &str) -> Vec<ir::TyTupleField> {
let mut fields = Vec::new();
let mut current_field = String::new();
let mut paren_depth = 0;
let mut in_quotes = false;
for ch in input.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
current_field.push(ch);
}
'(' if !in_quotes => {
paren_depth += 1;
current_field.push(ch);
}
')' if !in_quotes => {
paren_depth -= 1;
current_field.push(ch);
}
',' if !in_quotes && paren_depth == 0 => {
if let Some(field) = parse_single_struct_field(current_field.trim()) {
fields.push(field);
}
current_field.clear();
}
_ => {
current_field.push(ch);
}
}
}
if !current_field.is_empty()
&& let Some(field) = parse_single_struct_field(current_field.trim())
{
fields.push(field);
}
fields
}
fn parse_single_struct_field(field_str: &str) -> Option<ir::TyTupleField> {
let mut paren_depth = 0;
let mut last_space_idx = None;
for (i, ch) in field_str.char_indices() {
match ch {
'(' => paren_depth += 1,
')' => paren_depth -= 1,
' ' if paren_depth == 0 => last_space_idx = Some(i),
_ => {}
}
}
let space_idx = last_space_idx?;
let name_part = field_str[..space_idx].trim();
let type_part = field_str[space_idx + 1..].trim();
let field_name = if name_part.starts_with('"') && name_part.ends_with('"') {
&name_part[1..name_part.len() - 1]
} else {
name_part
};
Some(ir::TyTupleField {
name: Some(field_name.to_string()),
ty: ty_from_duckdb_type(type_part, false),
})
}
fn table_type_name(table_name: &str) -> String {
if let Some(n) = table_name.strip_suffix("s") {
crate::case::to_pascal_case(n)
} else {
format!("{}Row", crate::case::to_pascal_case(table_name))
}
}
struct Lookup {
name: String,
kind: &'static str,
columns: Vec<String>,
is_unique: bool,
}
fn get_column_type(table: &lutra::PullInterfaceOutputItemstablesItems, col_name: &str) -> ir::Ty {
table
.columns
.iter()
.find(|c| c.name == col_name)
.map(|c| ty_from_duckdb_type(&c.data_type, false))
.unwrap_or_else(|| ir::Ty::new(ir::TyPrimitive::text)) }
fn generate_lookup_function(
output: &mut String,
indent: &str,
table: &lutra::PullInterfaceOutputItemstablesItems,
ty_name: &str,
snake: &str,
lookup: &Lookup,
) {
let by = lookup.columns.join("_and_");
let params: Vec<String> = lookup
.columns
.iter()
.map(|col_name| {
let col_ty = get_column_type(table, col_name);
format!("{}: {}", col_name, ir::print_ty(&col_ty))
})
.collect();
let params = params.join(", ");
let conditions: Vec<String> = lookup
.columns
.iter()
.map(|col_name| format!("x.{} == {}", col_name, col_name))
.collect();
let cond = conditions.join(" && ");
*output += &format!(
"{indent}## Lookup in {} by {} {}\n",
table.table_name, lookup.kind, lookup.name
);
if lookup.is_unique {
*output += &format!(
"{indent}func from_{snake}_by_{by}({params}): enum {{none, some: {ty_name}}} -> (\n"
);
*output += &format!("{indent} from_{snake}() | std::find(x -> {cond})\n");
} else {
*output += &format!("{indent}func from_{snake}_by_{by}({params}): [{ty_name}] -> (\n");
*output += &format!("{indent} from_{snake}() | std::filter(x -> {cond})\n");
}
*output += &format!("{indent})\n");
}
fn collect_constraint_lookups(table: &lutra::PullInterfaceOutputItemstablesItems) -> Vec<Lookup> {
table
.constraints
.iter()
.filter(|c| !c.columns.is_empty())
.map(|constraint| Lookup {
name: constraint.constraint_name.clone(),
kind: "constraint",
columns: constraint.columns.clone(),
is_unique: constraint.constraint_type == "PRIMARY KEY"
|| constraint.constraint_type == "UNIQUE",
})
.collect()
}
fn collect_index_lookups(table: &lutra::PullInterfaceOutputItemstablesItems) -> Vec<Lookup> {
table
.indexes
.iter()
.filter_map(|index| {
let columns = parse_index_columns(&index.sql)?;
if columns.is_empty() {
return None;
}
Some(Lookup {
name: index.index_name.clone(),
kind: "index",
columns,
is_unique: index.is_unique,
})
})
.collect()
}
fn parse_index_columns(sql: &str) -> Option<Vec<String>> {
let on_pos = sql.find(" ON ")?;
let paren_start = sql[on_pos..].find('(')?;
let paren_end = sql[on_pos + paren_start..].find(')')?;
let start = on_pos + paren_start + 1;
let end = on_pos + paren_start + paren_end;
let columns_str = &sql[start..end];
let columns: Vec<String> = columns_str
.split(',')
.map(|col| {
let col = col.trim();
if (col.starts_with('"') && col.ends_with('"'))
|| (col.starts_with('\'') && col.ends_with('\''))
{
col[1..col.len() - 1].to_string()
} else {
col.to_string()
}
})
.collect();
Some(columns)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ty_from_duckdb_type() {
assert!(matches!(
ty_from_duckdb_type("INTEGER", false).kind,
ir::TyKind::Primitive(ir::TyPrimitive::int32)
));
assert!(matches!(
ty_from_duckdb_type("BIGINT", false).kind,
ir::TyKind::Primitive(ir::TyPrimitive::int64)
));
assert!(matches!(
ty_from_duckdb_type("VARCHAR", false).kind,
ir::TyKind::Primitive(ir::TyPrimitive::text)
));
assert!(matches!(
ty_from_duckdb_type("BOOLEAN", false).kind,
ir::TyKind::Primitive(ir::TyPrimitive::bool)
));
let nullable_int = ty_from_duckdb_type("INTEGER", true);
assert!(matches!(nullable_int.kind, ir::TyKind::Enum(_)));
}
#[test]
fn test_table_type_name() {
assert_eq!(table_type_name("users"), "User");
assert_eq!(table_type_name("movies"), "Movie");
assert_eq!(table_type_name("person"), "PersonRow");
assert_eq!(table_type_name("data"), "DataRow");
}
#[test]
fn test_parse_index_columns() {
assert_eq!(
parse_index_columns(r#"CREATE INDEX idx_name ON users("name");"#),
Some(vec!["name".to_string()])
);
assert_eq!(
parse_index_columns("CREATE INDEX idx_email ON users(email);"),
Some(vec!["email".to_string()])
);
assert_eq!(
parse_index_columns(r#"CREATE INDEX idx ON users("last_name", "first_name");"#),
Some(vec!["last_name".to_string(), "first_name".to_string()])
);
assert_eq!(
parse_index_columns("CREATE INDEX idx ON users(last_name, first_name);"),
Some(vec!["last_name".to_string(), "first_name".to_string()])
);
assert_eq!(
parse_index_columns(r#"CREATE UNIQUE INDEX users_email_key ON users(email);"#),
Some(vec!["email".to_string()])
);
assert_eq!(
parse_index_columns(r#"CREATE INDEX idx ON main.users(name);"#),
Some(vec!["name".to_string()])
);
}
}