blob: 1924ef6b9fe4e7fe1c1db5a534acccf04de8a498 [file]
//! DataFrame with Reader/Writer repesentation
use std::collections::HashMap;
use crate::execution;
use crate::plan::LogicalPlanBuilder;
use crate::spark;
use spark::expression::{ExprType, ExpressionString};
use spark::relation::RelType;
use spark::write_operation::SaveMode;
use spark::Expression;
use execution::context::SparkSession;
use arrow::error::ArrowError;
use arrow::record_batch::RecordBatch;
use arrow::util::pretty;
/// DataFrame is composed of a `spark_session` connecting to a remote
/// Spark Connect enabled cluster, and a `logical_plan` which represents
/// the `Plan` to be submitted to the cluster when an action is called
#[derive(Clone, Debug)]
pub struct DataFrame {
/// Global [SparkSession] connecting to the remote cluster
pub spark_session: SparkSession,
/// Logical Plan representing the unresolved Relation
/// which will be submitted to the remote cluster
pub logical_plan: LogicalPlanBuilder,
}
impl DataFrame {
/// create default DataFrame based on a spark session and initial logical plan
pub fn new(spark_session: SparkSession, logical_plan: LogicalPlanBuilder) -> DataFrame {
DataFrame {
spark_session,
logical_plan,
}
}
/// Projects a set of expressions and returns a new [DataFrame]
///
/// # Arguments:
///
/// * `cols` is a vector of `&str` which resolve to a specific column
///
/// # Example:
/// ```rust
/// async {
/// df.select(vec!["age", "name"]).collect().await?;
/// }
/// ```
pub fn select(&mut self, cols: Vec<&str>) -> DataFrame {
let expressions: Vec<spark::Expression> = cols
.iter()
.map(|&col| spark::Expression {
expr_type: Some(spark::expression::ExprType::UnresolvedAttribute(
spark::expression::UnresolvedAttribute {
unparsed_identifier: col.to_string(),
plan_id: None,
},
)),
})
.collect();
let rel_type = RelType::Project(Box::new(spark::Project {
expressions,
input: self.logical_plan.clone().relation_input(),
}));
let logical_plan = self.logical_plan.from(rel_type);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Project a set of SQL expressions and returns a new [DataFrame]
///
/// This is a variant of `select` that accepts SQL Expressions
///
/// # Example:
/// ```rust
/// async {
/// df.selectExpr(vec!["id * 2", "abs(id)"]).collect().await?;
/// }
/// ```
#[allow(non_snake_case)]
pub fn selectExpr(&mut self, cols: Vec<&str>) -> DataFrame {
let expressions: Vec<spark::Expression> = cols
.iter()
.map(|&col| spark::Expression {
expr_type: Some(spark::expression::ExprType::ExpressionString(
spark::expression::ExpressionString {
expression: col.to_string(),
},
)),
})
.collect();
let rel_type = RelType::Project(Box::new(spark::Project {
expressions,
input: self.logical_plan.clone().relation_input(),
}));
let logical_plan = self.logical_plan.from(rel_type);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Filters rows using a given conditions and returns a new [DataFrame]
///
/// # Example:
/// ```rust
/// async {
/// df.filter("salary > 4000").collect().await?;
/// }
/// ```
pub fn filter(&mut self, condition: &str) -> DataFrame {
let filter_expr = ExprType::ExpressionString(ExpressionString {
expression: condition.to_string(),
});
let rel_type = RelType::Filter(Box::new(spark::Filter {
input: self.logical_plan.clone().relation_input(),
condition: Some(Expression {
expr_type: Some(filter_expr),
}),
}));
let logical_plan = self.logical_plan.from(rel_type);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Limits the result count o thte number specified and returns a new [DataFrame]
///
/// # Example:
/// ```rust
/// async {
/// df.limit(10).collect().await?;
/// }
/// ```
pub fn limit(&mut self, limit: i32) -> DataFrame {
let limit_expr = RelType::Limit(Box::new(spark::Limit {
input: self.logical_plan.clone().relation_input(),
limit,
}));
let logical_plan = self.logical_plan.from(limit_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Return a new [DataFrame] with duplicate rows removed,
/// optionally only considering certain columns from a `Vec<String>`
///
/// If no columns are supplied then it all columns are used
///
/// Alias for `dropDuplciates`
///
pub fn drop_duplicates(&mut self, cols: Option<Vec<String>>) -> DataFrame {
let drop_expr = match cols {
Some(cols) => RelType::Deduplicate(Box::new(spark::Deduplicate {
input: self.logical_plan.clone().relation_input(),
column_names: cols,
all_columns_as_keys: Some(false),
within_watermark: Some(false),
})),
None => RelType::Deduplicate(Box::new(spark::Deduplicate {
input: self.logical_plan.clone().relation_input(),
column_names: vec![],
all_columns_as_keys: Some(true),
within_watermark: Some(false),
})),
};
let logical_plan = self.logical_plan.from(drop_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
#[allow(non_snake_case)]
pub fn dropDuplicates(&mut self, cols: Option<Vec<String>>) -> DataFrame {
self.drop_duplicates(cols)
}
/// Returns a new [DataFrame] by renaming multiple columns from a
/// `HashMap<String, String>` containing the `existing` as the key
/// and the `new` as the value.
///
#[allow(non_snake_case)]
pub fn withColumnsRenamed(&mut self, cols: HashMap<String, String>) -> DataFrame {
let rename_expr = RelType::WithColumnsRenamed(Box::new(spark::WithColumnsRenamed {
input: self.logical_plan.clone().relation_input(),
rename_columns_map: cols,
}));
let logical_plan = self.logical_plan.from(rename_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns a new [DataFrame] without the specified columns
pub fn drop(&mut self, cols: Vec<String>) -> DataFrame {
let drop_expr = RelType::Drop(Box::new(spark::Drop {
input: self.logical_plan.clone().relation_input(),
columns: vec![],
column_names: cols,
}));
let logical_plan = self.logical_plan.from(drop_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns a sampled subset of this [DataFrame]
pub fn sample(
&mut self,
lower_bound: f64,
upper_bound: f64,
with_replacement: Option<bool>,
seed: Option<i64>,
) -> DataFrame {
let sample_expr = RelType::Sample(Box::new(spark::Sample {
input: self.logical_plan.clone().relation_input(),
lower_bound,
upper_bound,
with_replacement,
seed,
deterministic_order: false,
}));
let logical_plan = self.logical_plan.from(sample_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns a new [DataFrame] partitioned by the given partition number and shuffle
/// option
///
/// # Arguments
///
/// * `num_partitions`: the target number of partitions
/// * (optional) `shuffle`: to induce a shuffle. Default is `false`
///
pub fn repartition(&mut self, num_partitions: i32, shuffle: Option<bool>) -> DataFrame {
let repart_expr = RelType::Repartition(Box::new(spark::Repartition {
input: self.logical_plan.clone().relation_input(),
num_partitions,
shuffle,
}));
let logical_plan = self.logical_plan.from(repart_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns a new [DataFrame] by skiping the first n rows
pub fn offset(&mut self, num: i32) -> DataFrame {
let offset_expr = RelType::Offset(Box::new(spark::Offset {
input: self.logical_plan.clone().relation_input(),
offset: num,
}));
let logical_plan = self.logical_plan.from(offset_expr);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns the schema of this DataFrame as a [spark::analyze_plan_response::Schema]
/// which contains the schema of a DataFrame
pub async fn schema(&mut self) -> spark::analyze_plan_response::Schema {
let analyze = Some(spark::analyze_plan_request::Analyze::Schema(
spark::analyze_plan_request::Schema {
plan: Some(self.logical_plan.clone().build_plan_root()),
},
));
let schema = self.spark_session.analyze_plan(analyze).await;
match schema {
spark::analyze_plan_response::Result::Schema(schema) => schema,
_ => panic!("Unexpected result"),
}
}
/// Prints the [spark::Plan] to the console
///
/// # Arguments:
/// * `mode`: &str. Defaults to `unspecified`
/// - `simple`
/// - `extended`
/// - `codegen`
/// - `cost`
/// - `formatted`
/// - `unspecified`
///
pub async fn explain(&mut self, mode: &str) {
let explain_mode = match mode {
"simple" => spark::analyze_plan_request::explain::ExplainMode::Simple,
"extended" => spark::analyze_plan_request::explain::ExplainMode::Extended,
"codegen" => spark::analyze_plan_request::explain::ExplainMode::Codegen,
"cost" => spark::analyze_plan_request::explain::ExplainMode::Cost,
"formatted" => spark::analyze_plan_request::explain::ExplainMode::Formatted,
_ => spark::analyze_plan_request::explain::ExplainMode::Unspecified,
};
let analyze = Some(spark::analyze_plan_request::Analyze::Explain(
spark::analyze_plan_request::Explain {
plan: Some(self.logical_plan.clone().build_plan_root()),
explain_mode: explain_mode.into(),
},
));
let explain = match self.spark_session.analyze_plan(analyze).await {
spark::analyze_plan_response::Result::Explain(explain) => explain,
_ => panic!("Unexpected result"),
};
println!("{}", explain.explain_string)
}
#[allow(non_snake_case, dead_code)]
async fn createTempView(&mut self, name: &str) {
self.create_view_cmd(name.to_string(), false, false)
.await
.unwrap()
}
#[allow(non_snake_case, dead_code)]
async fn createGlobalTempView(&mut self, name: &str) {
self.create_view_cmd(name.to_string(), true, false)
.await
.unwrap()
}
#[allow(non_snake_case, dead_code)]
async fn createOrReplaceGlobalTempView(&mut self, name: &str) {
self.create_view_cmd(name.to_string(), true, true)
.await
.unwrap()
}
#[allow(non_snake_case, dead_code)]
async fn createOrReplaceTempView(&mut self, name: &str) {
self.create_view_cmd(name.to_string(), false, true)
.await
.unwrap()
}
async fn create_view_cmd(
&mut self,
name: String,
is_global: bool,
replace: bool,
) -> Result<(), ArrowError> {
let command_type =
spark::command::CommandType::CreateDataframeView(spark::CreateDataFrameViewCommand {
input: Some(self.logical_plan.relation.clone()),
name,
is_global,
replace,
});
let plan = self.logical_plan.clone().build_plan_cmd(command_type);
self.spark_session.consume_plan(Some(plan)).await?;
Ok(())
}
/// Prints the first `n` rows to the console
///
/// # Arguments:
///
/// * `num_row`: (int, optional) number of rows to show (default 10)
/// * `truncate`: (int, optional) If set to 0, it truncates the string. Any other number will not truncate the strings
/// * `vertical`: (bool, optional) If set to true, prints output rows vertically (one line per column value).
///
pub async fn show(
&mut self,
num_rows: Option<i32>,
truncate: Option<i32>,
vertical: Option<bool>,
) -> Result<(), ArrowError> {
let show_expr = RelType::ShowString(Box::new(spark::ShowString {
input: self.logical_plan.clone().relation_input(),
num_rows: num_rows.unwrap_or(10),
truncate: truncate.unwrap_or(0),
vertical: vertical.unwrap_or(false),
}));
let plan = self.logical_plan.from(show_expr).build_plan_root();
let rows = self.spark_session.consume_plan(Some(plan)).await.unwrap();
let _ = pretty::print_batches(rows.as_slice());
Ok(())
}
/// Returns the last `n` rows as vector of [RecordBatch]
///
/// Running tail requires moving the data and results in an action
///
pub async fn tail(&mut self, limit: i32) -> Result<Vec<RecordBatch>, ArrowError> {
let limit_expr = RelType::Tail(Box::new(spark::Tail {
input: self.logical_plan.clone().relation_input(),
limit,
}));
let plan = self.logical_plan.from(limit_expr).build_plan_root();
let rows = self.spark_session.consume_plan(Some(plan)).await.unwrap();
Ok(rows)
}
/// Returns all records as a vector of [RecordBatch]
///
/// # Example:
///
/// ```rust
/// async {
/// df.collect().await?;
/// }
/// ```
pub async fn collect(&mut self) -> Result<Vec<RecordBatch>, ArrowError> {
let rows = self
.spark_session
.consume_plan(Some(self.logical_plan.clone().build_plan_root()))
.await
.unwrap();
Ok(rows)
}
/// Returns a [DataFrameWriter] struct based on the current [DataFrame]
pub fn write(self) -> DataFrameWriter {
DataFrameWriter::new(self)
}
}
/// DataFrameReader represents the entrypoint to create a DataFrame
/// from a specific file format.
#[derive(Clone, Debug)]
pub struct DataFrameReader {
spark_session: SparkSession,
format: Option<String>,
read_options: HashMap<String, String>,
}
impl DataFrameReader {
/// Create a new DataFraemReader with a [SparkSession]
pub fn new(spark_session: SparkSession) -> Self {
Self {
spark_session,
format: None,
read_options: HashMap::new(),
}
}
/// Specifies the input data source format
pub fn format(mut self, format: &str) -> Self {
self.format = Some(format.to_string());
self
}
/// Add an input option for the underlying data source
pub fn option(mut self, key: &str, value: &str) -> Self {
self.read_options.insert(key.to_string(), value.to_string());
self
}
/// Set many input options based on a [HashMap] for the underlying data source
pub fn options(mut self, options: HashMap<String, String>) -> Self {
self.read_options = options;
self
}
/// Loads data from a data source and returns it as a [DataFrame]
///
/// Example:
/// ```rust
/// let paths = vec!["some/dir/path/on/the/remote/cluster/".to_string()];
///
/// // returns a DataFrame from a csv file with a header from a the specific path
/// let mut df = spark.read().format("csv").option("header", "true").load(paths);
/// ```
pub fn load(&mut self, paths: Vec<String>) -> DataFrame {
let read_type = Some(spark::relation::RelType::Read(spark::Read {
is_streaming: false,
read_type: Some(spark::read::ReadType::DataSource(spark::read::DataSource {
format: self.format.clone(),
schema: None,
options: self.read_options.clone(),
paths,
predicates: vec![],
})),
}));
let relation = spark::Relation {
common: Some(spark::RelationCommon {
source_info: "NA".to_string(),
plan_id: Some(1),
}),
rel_type: read_type,
};
let logical_plan = LogicalPlanBuilder::new(relation);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
/// Returns the specific table as a [DataFrame]
///
/// # Arguments:
/// * `table_name`: &str of the table name
/// * `options`: (optional Hashmap) contains additional read options for a table
///
pub fn table(
&mut self,
table_name: &str,
options: Option<HashMap<String, String>>,
) -> DataFrame {
let read_type = Some(spark::relation::RelType::Read(spark::Read {
is_streaming: false,
read_type: Some(spark::read::ReadType::NamedTable(spark::read::NamedTable {
unparsed_identifier: table_name.to_string(),
options: options.unwrap_or(self.read_options.clone()),
})),
}));
let relation = spark::Relation {
common: Some(spark::RelationCommon {
source_info: "NA".to_string(),
plan_id: Some(1),
}),
rel_type: read_type,
};
let logical_plan = LogicalPlanBuilder::new(relation);
DataFrame::new(self.spark_session.clone(), logical_plan)
}
}
/// DataFrameWriter provides the ability to output a [DataFrame]
/// to a specific file format supported by Spark
pub struct DataFrameWriter {
dataframe: DataFrame,
format: Option<String>,
mode: SaveMode,
bucket_by: Option<spark::write_operation::BucketBy>,
partition_by: Vec<String>,
sort_by: Vec<String>,
write_options: HashMap<String, String>,
}
impl DataFrameWriter {
/// Create a new DataFrameWriter from a provided [DataFrame]
///
/// # Defaults
/// - `format`: None,
/// - `mode`: [SaveMode::Overwrite],
/// - `bucket_by`: None,
/// - `partition_by`: vec![],
/// - `sort_by`: vec![],
/// - `write_options`: HashMap::new()
///
pub fn new(dataframe: DataFrame) -> Self {
Self {
dataframe,
format: None,
mode: SaveMode::Overwrite,
bucket_by: None,
partition_by: vec![],
sort_by: vec![],
write_options: HashMap::new(),
}
}
/// Target format to output the [DataFrame]
pub fn format(mut self, format: &str) -> Self {
self.format = Some(format.to_string());
self
}
/// Specifies the behavior when data or table already exists
///
/// # Arguments:
/// - `mode`: (&str) translates to a specific [SaveMode] from the protobuf
///
pub fn mode(mut self, mode: &str) -> Self {
self.mode = match mode {
"append" => SaveMode::Append,
"overwrite" => SaveMode::Overwrite,
"error" | "errorifexists" => SaveMode::ErrorIfExists,
"ignore" => SaveMode::Ignore,
_ => SaveMode::Unspecified,
};
self
}
/// Buckets the output by the given columns.
/// If specified, the output is laid out on the file system
/// similar to Hive’s bucketing scheme.
#[allow(non_snake_case)]
pub fn bucketBy(mut self, num_buckets: i32, buckets: Vec<String>) -> Self {
self.bucket_by = Some(spark::write_operation::BucketBy {
bucket_column_names: buckets,
num_buckets,
});
self
}
/// Sorts the output in each bucket by the given columns on the file system
#[allow(non_snake_case)]
pub fn sortBy(mut self, cols: Vec<String>) -> Self {
self.sort_by = cols;
self
}
/// Partitions the output by the given columns on the file system
#[allow(non_snake_case)]
pub fn partitionBy(mut self, cols: Vec<String>) -> Self {
self.sort_by = cols;
self
}
/// Add an input option for the underlying data source
pub fn option(mut self, key: &str, value: &str) -> Self {
self.write_options
.insert(key.to_string(), value.to_string());
self
}
/// Set many input options based on a [HashMap] for the underlying data source
pub fn options(mut self, options: HashMap<String, String>) -> Self {
self.write_options = options;
self
}
/// Save the contents of the [DataFrame] to a data source.
///
/// The data source is specified by the `format` and a set of `options`.
pub async fn save(&mut self, path: &str) -> Result<(), ArrowError> {
let write_command = spark::command::CommandType::WriteOperation(spark::WriteOperation {
input: Some(self.dataframe.logical_plan.relation.clone()),
source: self.format.clone(),
mode: self.mode.into(),
sort_column_names: self.sort_by.clone(),
partitioning_columns: self.partition_by.clone(),
bucket_by: self.bucket_by.clone(),
options: self.write_options.clone(),
save_type: Some(spark::write_operation::SaveType::Path(path.to_string())),
});
let plan = self
.dataframe
.logical_plan
.clone()
.build_plan_cmd(write_command);
self.dataframe
.spark_session
.consume_plan(Some(plan))
.await
.unwrap();
Ok(())
}
async fn save_table(&mut self, table_name: &str, save_method: i32) -> Result<(), ArrowError> {
let write_command = spark::command::CommandType::WriteOperation(spark::WriteOperation {
input: Some(self.dataframe.logical_plan.relation.clone()),
source: self.format.clone(),
mode: self.mode.into(),
sort_column_names: self.sort_by.clone(),
partitioning_columns: self.partition_by.clone(),
bucket_by: self.bucket_by.clone(),
options: self.write_options.clone(),
save_type: Some(spark::write_operation::SaveType::Table(
spark::write_operation::SaveTable {
table_name: table_name.to_string(),
save_method,
},
)),
});
let plan = self
.dataframe
.logical_plan
.clone()
.build_plan_cmd(write_command);
self.dataframe
.spark_session
.consume_plan(Some(plan))
.await
.unwrap();
Ok(())
}
/// Saves the context of the [DataFrame] as the specified table.
#[allow(non_snake_case)]
pub async fn saveAsTable(&mut self, table_name: &str) -> Result<(), ArrowError> {
self.save_table(table_name, 1).await
}
/// Inserts the content of the [DataFrame] to the specified table.
///
/// It requires that the schema of the [DataFrame] is the same as the
/// schema of the target table.
///
/// Unlike `saveAsTable()`, this method ignores the column names and just uses
/// position-based resolution
#[allow(non_snake_case)]
pub async fn insertInto(&mut self, table_name: &str) -> Result<(), ArrowError> {
self.save_table(table_name, 2).await
}
}