Skip to main content

oxidelake_api/
frame.rs

1//! [`OxideFrame`]: the fluent DataFrame API over a session's DataFusion
2//! `DataFrame`, plus [`OxideSessionExt`] — the frame-producing entry points on
3//! [`OxideSession`].
4//!
5//! Every verb builds standard DataFusion logical plans, so the session's
6//! placement rule decides hardware exactly as it does for SQL:
7//! [`OxideFrame::vector_distance`] plans the same projection as
8//! `SELECT *, l2_distance(emb, …) AS d` and lowers to `GpuVectorDistanceExec`
9//! on a GPU target.
10
11use std::sync::Arc;
12
13use datafusion::arrow::array::RecordBatch;
14use datafusion::arrow::datatypes::SchemaRef;
15use datafusion::dataframe::DataFrame;
16use datafusion::logical_expr::JoinType;
17use datafusion::physical_plan::displayable;
18use datafusion::prelude::{Expr, ParquetReadOptions, lit};
19use oxidelake_core::EngineError;
20use oxidelake_core::params::DistanceMetric;
21use oxidelake_runtime::OxideSession;
22use oxidelake_runtime::udf::{cosine_distance_udf, l2_distance_udf, query_literal};
23
24/// A lazily built query over one session, collected with [`Self::collect`].
25#[derive(Debug, Clone)]
26pub struct OxideFrame {
27    inner: DataFrame,
28}
29
30impl OxideFrame {
31    /// Wraps a DataFusion [`DataFrame`].
32    pub fn new(inner: DataFrame) -> Self {
33        Self { inner }
34    }
35
36    /// The underlying DataFusion [`DataFrame`], for verbs this API does not wrap.
37    pub fn into_inner(self) -> DataFrame {
38        self.inner
39    }
40
41    /// The frame's Arrow schema.
42    pub fn schema(&self) -> SchemaRef {
43        Arc::new(self.inner.schema().as_arrow().clone())
44    }
45
46    /// Keeps rows matching `predicate` (e.g. `col("k").gt_eq(lit(2))`).
47    pub fn filter(self, predicate: Expr) -> Result<Self, EngineError> {
48        Ok(Self::new(self.inner.filter(predicate)?))
49    }
50
51    /// Keeps the named columns, in order.
52    pub fn select(self, columns: &[&str]) -> Result<Self, EngineError> {
53        Ok(Self::new(self.inner.select_columns(columns)?))
54    }
55
56    /// Renames the frame's table qualifier (required to self-join a table).
57    pub fn alias(self, name: &str) -> Result<Self, EngineError> {
58        Ok(Self::new(self.inner.alias(name)?))
59    }
60
61    /// Inner-joins `right` on `left_key = right_key`.
62    pub fn join(
63        self,
64        right: OxideFrame,
65        left_key: &str,
66        right_key: &str,
67    ) -> Result<Self, EngineError> {
68        Ok(Self::new(self.inner.join(
69            right.inner,
70            JoinType::Inner,
71            &[left_key],
72            &[right_key],
73            None,
74        )?))
75    }
76
77    /// Groups by `group_by` and computes `aggregates`
78    /// (e.g. `aggregate(vec![col("k")], vec![sum(col("v"))])`).
79    pub fn aggregate(
80        self,
81        group_by: Vec<Expr>,
82        aggregates: Vec<Expr>,
83    ) -> Result<Self, EngineError> {
84        Ok(Self::new(self.inner.aggregate(group_by, aggregates)?))
85    }
86
87    /// Sorts by `expr` (e.g. `col("d").sort(true, false)` for ascending).
88    pub fn sort(self, exprs: Vec<datafusion::logical_expr::SortExpr>) -> Result<Self, EngineError> {
89        Ok(Self::new(self.inner.sort(exprs)?))
90    }
91
92    /// Keeps at most `n` rows.
93    pub fn limit(self, n: usize) -> Result<Self, EngineError> {
94        Ok(Self::new(self.inner.limit(0, Some(n))?))
95    }
96
97    /// Appends `output` — the `metric` distance between the vector column
98    /// `column` (`FixedSizeList<Float32>`) and `query` — as a nullable
99    /// `Float32` column. Plans the `l2_distance` / `cosine_distance` UDF, so
100    /// a GPU-target session lowers it to `GpuVectorDistanceExec`.
101    pub fn vector_distance(
102        self,
103        column: &str,
104        query: &[f32],
105        metric: DistanceMetric,
106        output: &str,
107    ) -> Result<Self, EngineError> {
108        let udf = match metric {
109            DistanceMetric::L2 => l2_distance_udf(),
110            DistanceMetric::Cosine => cosine_distance_udf(),
111        };
112        let call = udf.call(vec![
113            datafusion::prelude::col(column),
114            lit(query_literal(query)),
115        ]);
116        Ok(Self::new(self.inner.with_column(output, call)?))
117    }
118
119    /// The indented physical plan, with placement tags on `Gpu*Exec` nodes.
120    pub async fn explain(&self) -> Result<String, EngineError> {
121        let plan = self.inner.clone().create_physical_plan().await?;
122        Ok(displayable(plan.as_ref()).indent(true).to_string())
123    }
124
125    /// Executes the frame and returns every batch.
126    pub async fn collect(self) -> Result<Vec<RecordBatch>, EngineError> {
127        Ok(self.inner.collect().await?)
128    }
129
130    /// Executes the frame and prints it as a table.
131    pub async fn show(self) -> Result<(), EngineError> {
132        Ok(self.inner.show().await?)
133    }
134}
135
136impl From<DataFrame> for OxideFrame {
137    fn from(inner: DataFrame) -> Self {
138        Self::new(inner)
139    }
140}
141
142/// Frame-producing entry points on [`OxideSession`].
143pub trait OxideSessionExt {
144    /// Reads a Parquet file or directory as a frame.
145    fn read_parquet(
146        &self,
147        path: &str,
148    ) -> impl Future<Output = Result<OxideFrame, EngineError>> + Send;
149
150    /// A frame over a registered table.
151    fn table(&self, name: &str) -> impl Future<Output = Result<OxideFrame, EngineError>> + Send;
152
153    /// Plans a SQL statement as a frame.
154    fn sql_frame(
155        &self,
156        query: &str,
157    ) -> impl Future<Output = Result<OxideFrame, EngineError>> + Send;
158}
159
160impl OxideSessionExt for OxideSession {
161    async fn read_parquet(&self, path: &str) -> Result<OxideFrame, EngineError> {
162        Ok(OxideFrame::new(
163            self.ctx()
164                .read_parquet(path, ParquetReadOptions::default())
165                .await?,
166        ))
167    }
168
169    async fn table(&self, name: &str) -> Result<OxideFrame, EngineError> {
170        Ok(OxideFrame::new(self.ctx().table(name).await?))
171    }
172
173    async fn sql_frame(&self, query: &str) -> Result<OxideFrame, EngineError> {
174        Ok(OxideFrame::new(self.sql(query).await?))
175    }
176}