use std::sync::Arc;
use arrow::compute::{cast, take};
use arrow_array::{ArrayRef, RecordBatch, UInt32Array};
use arrow_schema::{DataType, Field, Schema};
use datafusion::datasource::MemTable;
use datafusion::prelude::{SessionConfig, SessionContext};
use datafusion::sql::sqlparser::ast::{Ident, ObjectName, ObjectNamePart, Query, SetExpr, Statement, TableFactor};
use datafusion::sql::sqlparser::dialect::GenericDialect;
use datafusion::sql::sqlparser::parser::Parser;
use crate::error::{BifrostError, Result};
use crate::mapping::{MappingSpec, PipelineStep, SyncMode, TableMapping};
use crate::mutation::{EdgeSet, GraphMutation, VertexSet};
use crate::transform::{TableContext, Transform};
pub const LABEL: &str = "_label";
pub const ID: &str = "_id";
pub const TYPE: &str = "_type";
pub const FROM_LABEL: &str = "_from_label";
pub const FROM: &str = "_from";
pub const TO_LABEL: &str = "_to_label";
pub const TO: &str = "_to";
pub const EDGE_ID: &str = "_edge_id";
pub const ID_PROPERTY: &str = "id";
const ROWS: &str = "src";
#[derive(Debug, Clone)]
pub struct SqlTransform {
query: String,
deletes_query: String,
table: String,
}
impl SqlTransform {
pub fn new(sql: &str) -> Result<Self> {
let (mut query, table) = parse_select(sql)?;
let full = query.to_string();
if let SetExpr::Select(select) = query.body.as_mut() {
select.selection = None;
}
Ok(Self { query: full, deletes_query: query.to_string(), table })
}
pub fn table(&self) -> &str {
&self.table
}
fn run(&self, sql: &str, batches: &[RecordBatch]) -> Result<GraphMutation> {
let Some(first) = batches.first() else { return Ok(GraphMutation::default()) };
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let rows = MemTable::try_new(first.schema(), vec![batches.to_vec()]).map_err(df)?;
ctx.register_table(ROWS, Arc::new(rows)).map_err(df)?;
let out = futures::executor::block_on(async { ctx.sql(sql).await?.collect().await }).map_err(df)?;
to_mutation(&out)
}
}
impl Transform for SqlTransform {
fn apply(&self, _ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
self.run(&self.query, batches)
}
fn apply_deletes(&self, _ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
self.run(&self.deletes_query, batches)
}
}
pub fn mapping_from_sql(text: &str, mode: SyncMode) -> Result<MappingSpec> {
let statements = Parser::parse_sql(&GenericDialect {}, text).map_err(parse_err)?;
if statements.is_empty() {
return Err(BifrostError::Other("the SQL mapping holds no statement".into()));
}
let mut tables: Vec<TableMapping> = Vec::new();
for statement in statements {
let sql = statement.to_string();
let (_, table) = parse_select(&sql)?;
let step = PipelineStep::Sql { sql };
match tables.iter_mut().find(|t| t.table == table) {
Some(t) => t.pipeline.push(step),
None => tables.push(TableMapping { table, mode, pipeline: vec![step], columns: Vec::new(), filter: None }),
}
}
Ok(MappingSpec { version: 1, tables })
}
fn parse_select(sql: &str) -> Result<(Query, String)> {
let bad = |why: &str| BifrostError::Other(format!("sql step: {why}: {sql}"));
let mut statements = Parser::parse_sql(&GenericDialect {}, sql).map_err(parse_err)?;
if statements.len() != 1 {
return Err(bad("one statement per step"));
}
let Statement::Query(mut query) = statements.remove(0) else {
return Err(bad("only SELECT"));
};
if query.with.is_some() {
return Err(bad("no WITH - one table, row by row"));
}
let SetExpr::Select(select) = query.body.as_mut() else {
return Err(bad("one SELECT - no UNION/INTERSECT/EXCEPT/VALUES"));
};
if select.from.len() != 1 || !select.from[0].joins.is_empty() {
return Err(bad("exactly one table in FROM, no joins - a round carries one table's rows"));
}
let TableFactor::Table { name, .. } = &mut select.from[0].relation else {
return Err(bad("FROM must name a table"));
};
let parts: Vec<String> = name
.0
.iter()
.map(|p| match p {
ObjectNamePart::Identifier(i) => Ok(i.value.clone()),
_ => Err(bad("FROM must name a table")),
})
.collect::<Result<_>>()?;
*name = ObjectName::from(vec![Ident::new(ROWS)]);
Ok((*query, parts.join(".")))
}
fn parse_err(e: datafusion::sql::sqlparser::parser::ParserError) -> BifrostError {
BifrostError::Other(format!("sql: {e}"))
}
fn df(e: datafusion::error::DataFusionError) -> BifrostError {
BifrostError::Other(format!("sql: {e}"))
}
fn to_mutation(out: &[RecordBatch]) -> Result<GraphMutation> {
let mut m = GraphMutation::default();
for batch in out.iter().filter(|b| b.num_rows() > 0) {
let has = |c: &str| batch.schema().column_with_name(c).is_some();
if has(LABEL) && has(ID) {
m.vertices.extend(vertices(batch)?);
} else if [TYPE, FROM_LABEL, FROM, TO_LABEL, TO].iter().all(|c| has(c)) {
m.edges.extend(edges(batch)?);
} else {
return Err(BifrostError::Other(format!(
"sql step: the SELECT must return `{LABEL}`, `{ID}` (nodes) or `{TYPE}`, `{FROM_LABEL}`, `{FROM}`, \
`{TO_LABEL}`, `{TO}` (edges); it returned {:?}",
batch.schema().fields().iter().map(|f| f.name()).collect::<Vec<_>>()
)));
}
}
Ok(m)
}
fn vertices(batch: &RecordBatch) -> Result<Vec<VertexSet>> {
let label = text_column(batch, LABEL)?;
let id = column(batch, ID)?;
let keys: Vec<Option<String>> = (0..batch.num_rows())
.map(|r| if label.is_null(r) || id.is_null(r) { None } else { Some(text_at(&label, r)) })
.collect();
let props = properties(batch, &[LABEL, ID])?;
group(&keys)
.into_iter()
.map(|(label, rows)| {
let mut cols = vec![(ID_PROPERTY.to_string(), id.clone())];
cols.extend(props.iter().cloned());
Ok(VertexSet { label, id_column: ID_PROPERTY.into(), batch: taken(&cols, &rows)? })
})
.collect()
}
fn edges(batch: &RecordBatch) -> Result<Vec<EdgeSet>> {
let (ty, from_label, to_label) = (text_column(batch, TYPE)?, text_column(batch, FROM_LABEL)?, text_column(batch, TO_LABEL)?);
let (from, to) = (column(batch, FROM)?, column(batch, TO)?);
let edge_id = batch.schema().column_with_name(EDGE_ID).map(|(i, _)| batch.column(i).clone());
let keys: Vec<Option<String>> = (0..batch.num_rows())
.map(|r| {
if [&ty, &from_label, &to_label, &from, &to].iter().any(|c| c.is_null(r)) {
return None;
}
Some(format!("{}\u{1f}{}\u{1f}{}", text_at(&ty, r), text_at(&from_label, r), text_at(&to_label, r)))
})
.collect();
let props = properties(batch, &[TYPE, FROM_LABEL, FROM, TO_LABEL, TO, EDGE_ID])?;
group(&keys)
.into_iter()
.map(|(key, rows)| {
let mut parts = key.split('\u{1f}').map(str::to_string);
let (edge_type, src_label, dst_label) = (parts.next().unwrap(), parts.next().unwrap(), parts.next().unwrap());
let mut cols = vec![("src".to_string(), from.clone()), ("dst".to_string(), to.clone())];
if let Some(e) = &edge_id {
cols.push((ID_PROPERTY.to_string(), e.clone()));
}
cols.extend(props.iter().cloned());
Ok(EdgeSet {
src_label,
edge_type,
dst_label,
src_id_column: ID_PROPERTY.into(),
dst_id_column: ID_PROPERTY.into(),
edge_key_props: if edge_id.is_some() { vec![ID_PROPERTY.into()] } else { Vec::new() },
replace_outgoing: false,
batch: taken(&cols, &rows)?,
})
})
.collect()
}
fn properties(batch: &RecordBatch, reserved: &[&str]) -> Result<Vec<(String, ArrayRef)>> {
let schema = batch.schema();
let mut out = Vec::new();
for (i, f) in schema.fields().iter().enumerate() {
let name = f.name().as_str();
if reserved.contains(&name) {
continue;
}
if [ID_PROPERTY, "src", "dst"].contains(&name) {
return Err(BifrostError::DuplicateOutputColumn(format!(
"{name} (a sql step writes `{ID}`/`{EDGE_ID}` as `{ID_PROPERTY}`; alias this column)"
)));
}
out.push((name.to_string(), batch.column(i).clone()));
}
Ok(out)
}
fn column(batch: &RecordBatch, name: &str) -> Result<ArrayRef> {
let (i, _) = batch.schema().column_with_name(name).ok_or_else(|| BifrostError::ColumnNotFound(name.into()))?;
Ok(batch.column(i).clone())
}
fn text_column(batch: &RecordBatch, name: &str) -> Result<ArrayRef> {
cast(&column(batch, name)?, &DataType::Utf8).map_err(|e| BifrostError::Other(format!("sql: `{name}` as text: {e}")))
}
fn text_at(col: &ArrayRef, row: usize) -> String {
use arrow_array::cast::AsArray;
col.as_string::<i32>().value(row).to_string()
}
fn group(keys: &[Option<String>]) -> Vec<(String, Vec<u32>)> {
let mut out: Vec<(String, Vec<u32>)> = Vec::new();
for (row, key) in keys.iter().enumerate() {
let Some(key) = key else { continue };
match out.iter_mut().find(|(k, _)| k == key) {
Some((_, rows)) => rows.push(row as u32),
None => out.push((key.clone(), vec![row as u32])),
}
}
out
}
fn taken(cols: &[(String, ArrayRef)], rows: &[u32]) -> Result<RecordBatch> {
let idx = UInt32Array::from(rows.to_vec());
let mut fields = Vec::with_capacity(cols.len());
let mut arrays = Vec::with_capacity(cols.len());
for (name, col) in cols {
let a = take(col.as_ref(), &idx, None)?;
fields.push(Field::new(name, a.data_type().clone(), true));
arrays.push(a);
}
Ok(RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)?)
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::{BooleanArray, Int64Array, StringArray};
fn users(ids: &[i64], names: &[Option<&str>], company: &[Option<i64>], active: &[Option<bool>]) -> RecordBatch {
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, true),
Field::new("company_id", DataType::Int64, true),
Field::new("active", DataType::Boolean, true),
])),
vec![
Arc::new(Int64Array::from(ids.to_vec())),
Arc::new(StringArray::from(names.to_vec())),
Arc::new(Int64Array::from(company.to_vec())),
Arc::new(BooleanArray::from(active.to_vec())),
],
)
.unwrap()
}
fn ctx() -> TableContext {
TableContext { table: "social.users".into() }
}
#[test]
fn a_vertex_statement_filters_computes_and_splits_labels() {
let t = SqlTransform::new(
"SELECT CASE WHEN user_id < 3 THEN 'Staff' ELSE 'Person' END AS _label, user_id AS _id, \
upper(name) AS name FROM social.users WHERE active",
)
.unwrap();
assert_eq!(t.table(), "social.users");
let b = users(&[1, 2, 3, 4], &[Some("a"), Some("b"), Some("c"), Some("d")], &[None; 4], &[Some(true), Some(false), Some(true), Some(true)]);
let m = t.apply(&ctx(), &[b]).unwrap();
let got: Vec<(String, usize)> = m.vertices.iter().map(|v| (v.label.clone(), v.batch.num_rows())).collect();
assert_eq!(got, vec![("Staff".into(), 1), ("Person".into(), 2)], "user 2 is filtered out");
let person = &m.vertices[1];
assert_eq!(person.id_column, "id");
let names: Vec<&str> = person.batch.column_by_name("name").unwrap().as_any().downcast_ref::<StringArray>().unwrap().iter().flatten().collect();
assert_eq!(names, vec!["C", "D"]);
}
#[test]
fn deletes_ignore_the_where_so_a_key_only_row_still_deletes() {
let t = SqlTransform::new("SELECT 'Person' AS _label, user_id AS _id, name FROM users WHERE active").unwrap();
let b = users(&[7], &[None], &[None], &[None]);
assert!(t.apply(&ctx(), std::slice::from_ref(&b)).unwrap().vertices.is_empty(), "the WHERE drops it as an upsert");
let d = t.apply_deletes(&ctx(), &[b]).unwrap();
assert_eq!(d.vertices.len(), 1);
assert_eq!(d.vertices[0].batch.num_rows(), 1);
}
#[test]
fn an_edge_statement_names_its_endpoints_and_skips_null_ones() {
let t = SqlTransform::new(
"SELECT 'WORKS_AT' AS _type, 'Person' AS _from_label, user_id AS _from, 'Company' AS _to_label, \
company_id AS _to, user_id * 10 AS _edge_id, name AS who FROM users",
)
.unwrap();
let b = users(&[1, 2], &[Some("a"), Some("b")], &[Some(9), None], &[Some(true), Some(true)]);
let m = t.apply(&ctx(), &[b]).unwrap();
assert_eq!(m.edges.len(), 1);
let e = &m.edges[0];
assert_eq!((e.src_label.as_str(), e.edge_type.as_str(), e.dst_label.as_str()), ("Person", "WORKS_AT", "Company"));
assert_eq!(e.edge_key_props, vec!["id".to_string()]);
assert_eq!(e.batch.num_rows(), 1, "the null company writes no edge");
let names: Vec<String> = e.batch.schema().fields().iter().map(|f| f.name().clone()).collect();
assert_eq!(names, vec!["src", "dst", "id", "who"]);
}
#[test]
fn one_table_row_by_row_is_enforced() {
for sql in [
"SELECT 1 AS _label, 1 AS _id FROM a JOIN b ON a.x = b.x",
"SELECT 1 AS _label, 1 AS _id FROM a UNION SELECT 1, 1 FROM b",
"WITH c AS (SELECT * FROM a) SELECT 1 AS _label, 1 AS _id FROM c",
"DELETE FROM a",
] {
assert!(SqlTransform::new(sql).is_err(), "{sql}");
}
}
#[test]
fn a_select_without_reserved_columns_says_what_it_needs() {
let t = SqlTransform::new("SELECT user_id, name FROM users").unwrap();
let b = users(&[1], &[Some("a")], &[None], &[Some(true)]);
let e = t.apply(&ctx(), &[b]).unwrap_err().to_string();
assert!(e.contains("_label") && e.contains("_from_label"), "{e}");
}
#[test]
fn a_sql_file_groups_statements_by_table() {
let m = mapping_from_sql(
"SELECT 'Person' AS _label, user_id AS _id FROM social.users;\n\
SELECT 'Company' AS _label, id AS _id FROM social.companies;\n\
SELECT 'WORKS_AT' AS _type, 'Person' AS _from_label, user_id AS _from, 'Company' AS _to_label, company_id AS _to FROM social.users;",
SyncMode::Cdc,
)
.unwrap();
let got: Vec<(&str, usize)> = m.tables.iter().map(|t| (t.table.as_str(), t.pipeline.len())).collect();
assert_eq!(got, vec![("social.users", 2), ("social.companies", 1)]);
assert!(m.tables.iter().all(|t| t.mode == SyncMode::Cdc));
}
}