use std::sync::Arc;
use anyhow::{Context, Result};
use arrow::array::RecordBatch;
use async_trait::async_trait;
use datafusion::catalog::{SchemaProvider, TableProvider};
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::prelude::SessionContext;
use iceberg::{Catalog, NamespaceIdent, TableIdent};
use iceberg_datafusion::IcebergStaticTableProvider;
use skade_katalog::RedbCatalog;
use super::iceberg::IcebergWarehouse;
#[derive(Debug)]
struct WarehouseSchema {
catalog: Arc<RedbCatalog>,
namespace: NamespaceIdent,
table_names: Vec<String>,
}
#[async_trait]
impl SchemaProvider for WarehouseSchema {
fn table_names(&self) -> Vec<String> {
self.table_names.clone()
}
fn table_exist(&self, name: &str) -> bool {
self.table_names.iter().any(|t| t == name)
}
async fn table(&self, name: &str) -> DFResult<Option<Arc<dyn TableProvider>>> {
if !self.table_exist(name) {
return Ok(None);
}
let ident = TableIdent::new(self.namespace.clone(), name.to_string());
let table = self
.catalog
.load_table(&ident)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let provider = IcebergStaticTableProvider::try_new_from_table(table)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok(Some(Arc::new(provider)))
}
}
pub fn query(wh: &IcebergWarehouse, sql: &str) -> Result<Vec<RecordBatch>> {
let schema = Arc::new(WarehouseSchema {
catalog: wh.catalog().clone(),
namespace: wh.namespace().clone(),
table_names: wh.table_names()?,
});
wh.block_on(async move {
let ctx = SessionContext::new();
let catalog = ctx
.catalog("datafusion")
.context("datafusion default catalog missing")?;
catalog
.register_schema("public", schema)
.map_err(|e| anyhow::anyhow!("register warehouse schema: {e}"))?;
let df = ctx
.sql(sql)
.await
.with_context(|| format!("plan SQL: {sql}"))?;
let batches = df.collect().await.context("execute SQL")?;
Ok(batches)
})
}
pub fn format_table(batches: &[RecordBatch]) -> Result<String> {
Ok(arrow::util::pretty::pretty_format_batches(batches)
.context("format result table")?
.to_string())
}
pub fn format_json(batches: &[RecordBatch]) -> Result<String> {
let mut buf = Vec::new();
{
let mut writer = arrow::json::ArrayWriter::new(&mut buf);
for b in batches {
writer.write(b).context("write JSON batch")?;
}
writer.finish().context("finish JSON")?;
}
if buf.is_empty() {
return Ok("[]".to_string());
}
String::from_utf8(buf).context("JSON output was not valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow, SymbolScan};
use arrow::array::{Array, Int64Array, StringArray};
fn sym(crate_name: &str, item: &str) -> SymbolRow {
SymbolRow {
crate_name: crate_name.into(),
module_path: format!("{crate_name}::api"),
item_kind: "fn".into(),
item_name: item.into(),
visibility: "pub".into(),
file: format!("{crate_name}/src/api.rs"),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn call(crate_name: &str, caller: &str, callee: &str) -> CallEdgeRow {
CallEdgeRow {
crate_name: crate_name.into(),
caller_path: caller.into(),
callee_ident: callee.into(),
call_kind: "call".into(),
file: format!("{crate_name}/src/api.rs"),
line: 1,
}
}
fn seeded() -> (tempfile::TempDir, IcebergWarehouse) {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
wh.append_symbol_scan(&SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: "korp".into(),
symbols: vec![sym("korp", "draw"), sym("korp", "redraw")],
calls: vec![
call("korp", "korp::draw", "render"),
call("korp", "korp::redraw", "paint"),
],
features: vec![],
tests: vec![],
})
.unwrap();
wh.append_symbol_scan(&SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: "knut".into(),
symbols: vec![sym("knut", "paint")],
calls: vec![call("knut", "knut::paint", "render")],
features: vec![],
tests: vec![],
})
.unwrap();
(dir, wh)
}
fn str_col(b: &RecordBatch, col: usize) -> Vec<String> {
let a = b
.column(col)
.as_any()
.downcast_ref::<StringArray>()
.expect("string column");
(0..a.len()).map(|i| a.value(i).to_string()).collect()
}
#[test]
fn select_where_over_seeded_warehouse() {
let (_d, wh) = seeded();
let batches = query(
&wh,
"SELECT item_name FROM symbol_facts WHERE repo = 'korp' ORDER BY item_name",
)
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 2, "korp seeded two symbols");
let names: Vec<String> = batches.iter().flat_map(|b| str_col(b, 0)).collect();
assert_eq!(names, vec!["draw".to_string(), "redraw".to_string()]);
}
#[test]
fn join_across_two_warehouse_tables() {
let (_d, wh) = seeded();
let batches = query(
&wh,
"SELECT c.callee_ident \
FROM symbol_facts s \
JOIN call_edges c ON s.crate_name = c.crate_name \
WHERE s.item_name = 'draw' \
ORDER BY c.callee_ident",
)
.unwrap();
let callees: Vec<String> = batches.iter().flat_map(|b| str_col(b, 0)).collect();
assert_eq!(callees, vec!["paint".to_string(), "render".to_string()]);
}
#[test]
fn aggregate_group_by_over_call_edges() {
let (_d, wh) = seeded();
let batches = query(
&wh,
"SELECT repo, COUNT(*) AS n FROM call_edges GROUP BY repo ORDER BY repo",
)
.unwrap();
let mut pairs: Vec<(String, i64)> = Vec::new();
for b in &batches {
let repos = str_col(b, 0);
let counts = b
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("count column is i64");
for (i, repo) in repos.iter().enumerate() {
pairs.push((repo.clone(), counts.value(i)));
}
}
assert_eq!(
pairs,
vec![("knut".to_string(), 1), ("korp".to_string(), 2)],
"knut has 1 call edge, korp has 2"
);
}
#[test]
fn json_output_is_valid_json_array() {
let (_d, wh) = seeded();
let batches = query(
&wh,
"SELECT item_name FROM symbol_facts WHERE repo = 'knut'",
)
.unwrap();
let json = format_json(&batches).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.is_array(), "json output is an array: {json}");
assert_eq!(v.as_array().unwrap().len(), 1);
assert_eq!(v[0]["item_name"], "paint");
}
}