use std::collections::HashMap;
use spark_connect_core::error::Result;
use spark_connect_proto as proto;
use crate::column::Column;
use crate::dataframe::{build_input_relation, execute_command, DataFrame};
use crate::plan::LogicalPlan;
use crate::session::SparkSession;
pub struct DataFrameReader {
session: SparkSession,
format: Option<String>,
schema: String,
options: HashMap<String, String>,
}
impl DataFrameReader {
pub(crate) fn new(session: SparkSession) -> Self {
DataFrameReader {
session,
format: None,
schema: String::new(),
options: HashMap::new(),
}
}
pub fn format(mut self, source: &str) -> Self {
self.format = Some(source.to_string());
self
}
pub fn schema(mut self, schema: String) -> Self {
self.schema = schema;
self
}
pub fn option(mut self, key: &str, value: &str) -> Self {
self.options.insert(key.to_string(), value.to_string());
self
}
pub fn options(mut self, options: HashMap<String, String>) -> Self {
self.options.extend(options);
self
}
pub fn load(self, path: Option<&str>) -> DataFrame {
let paths = path.map(|p| vec![p.to_string()]);
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths: paths.unwrap_or_default(),
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn changes(self, table_name: &str) -> DataFrame {
let plan = LogicalPlan::RelationChanges {
table_name: table_name.to_string(),
options: self.options.clone(),
is_streaming: None,
};
DataFrame::new(self.session, plan)
}
pub fn table(self, table_name: &str) -> DataFrame {
let plan = LogicalPlan::Read {
read_type: ReadType::NamedTable {
table_name: table_name.to_string(),
options: self.options.clone(),
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn json(mut self, path: &str) -> DataFrame {
self.format = Some("json".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn parquet(mut self, path: &str) -> DataFrame {
self.format = Some("parquet".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn csv(mut self, path: &str) -> DataFrame {
self.format = Some("csv".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn orc(mut self, path: &str) -> DataFrame {
self.format = Some("orc".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn text(mut self, path: &str) -> DataFrame {
self.format = Some("text".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn xml(mut self, path: &str) -> DataFrame {
self.format = Some("xml".to_string());
let paths = vec![path.to_string()];
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths,
predicates: vec![],
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
pub fn jdbc(mut self, url: &str, table: &str, predicates: Option<Vec<String>>) -> DataFrame {
self.format = Some("jdbc".to_string());
self.options.insert("url".to_string(), url.to_string());
self.options
.insert("dbtable".to_string(), table.to_string());
let plan = LogicalPlan::Read {
read_type: ReadType::DataSource {
format: self.format.clone(),
schema: if self.schema.is_empty() {
None
} else {
Some(self.schema.clone())
},
options: self.options.clone(),
paths: vec![],
predicates: predicates.unwrap_or_default(),
source_name: None,
},
is_streaming: false,
};
DataFrame::new(self.session, plan)
}
}
#[derive(Debug, Clone)]
pub enum ReadType {
DataSource {
format: Option<String>,
schema: Option<String>,
options: HashMap<String, String>,
paths: Vec<String>,
predicates: Vec<String>,
source_name: Option<String>,
},
NamedTable {
table_name: String,
options: HashMap<String, String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveMode {
Append,
Overwrite,
ErrorIfExists,
Ignore,
}
impl SaveMode {
pub fn to_proto(&self) -> i32 {
match self {
SaveMode::Append => 1i32,
SaveMode::Overwrite => 2i32,
SaveMode::ErrorIfExists => 3i32,
SaveMode::Ignore => 4i32,
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"append" => Some(SaveMode::Append),
"overwrite" => Some(SaveMode::Overwrite),
"error" | "errorifexists" => Some(SaveMode::ErrorIfExists),
"ignore" => Some(SaveMode::Ignore),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableSaveMethod {
SaveAsTable,
InsertInto,
}
impl TableSaveMethod {
pub fn to_proto(&self) -> i32 {
match self {
TableSaveMethod::SaveAsTable => 1i32,
TableSaveMethod::InsertInto => 2i32,
}
}
}
pub struct DataFrameWriter {
session: SparkSession,
input_plan: LogicalPlan,
format: Option<String>,
mode: SaveMode,
options: HashMap<String, String>,
partition_cols: Vec<String>,
cluster_cols: Vec<String>,
bucket_cols: Vec<String>,
sort_cols: Vec<String>,
num_buckets: Option<i32>,
}
impl DataFrameWriter {
pub(crate) fn new(session: SparkSession, input_plan: LogicalPlan) -> Self {
DataFrameWriter {
session,
input_plan,
format: None,
mode: SaveMode::ErrorIfExists,
options: HashMap::new(),
partition_cols: vec![],
cluster_cols: vec![],
bucket_cols: vec![],
sort_cols: vec![],
num_buckets: None,
}
}
pub fn cluster_by<S: Into<String>>(mut self, cols: impl IntoIterator<Item = S>) -> Self {
self.cluster_cols = cols.into_iter().map(Into::into).collect();
self
}
pub fn mode(mut self, mode: &str) -> Self {
if let Some(m) = SaveMode::from_str(mode) {
self.mode = m;
}
self
}
pub fn format(mut self, source: &str) -> Self {
self.format = Some(source.to_string());
self
}
pub fn option(mut self, key: &str, value: &str) -> Self {
self.options.insert(key.to_string(), value.to_string());
self
}
pub fn options(mut self, options: HashMap<String, String>) -> Self {
self.options.extend(options);
self
}
pub fn partition_by<S: Into<String>>(mut self, cols: impl IntoIterator<Item = S>) -> Self {
self.partition_cols = cols.into_iter().map(Into::into).collect();
self
}
pub fn bucket_by<S: Into<String>>(
mut self,
num_buckets: i32,
cols: impl IntoIterator<Item = S>,
) -> Self {
self.num_buckets = Some(num_buckets);
self.bucket_cols = cols.into_iter().map(Into::into).collect();
self
}
pub fn sort_by<S: Into<String>>(mut self, cols: impl IntoIterator<Item = S>) -> Self {
self.sort_cols = cols.into_iter().map(Into::into).collect();
self
}
pub(crate) fn build_write_operation(
&self,
save_type: Option<proto::write_operation::SaveType>,
) -> Result<proto::WriteOperation> {
let mut op = proto::WriteOperation::default();
op.input = Some(build_input_relation(&self.input_plan, &self.session)?);
op.source = self.format.clone();
op.mode = self.mode.to_proto();
op.sort_column_names = self.sort_cols.clone();
op.partitioning_columns = self.partition_cols.clone();
op.clustering_columns = self.cluster_cols.clone();
op.options = self.options.clone();
op.save_type = save_type;
if let Some(num_buckets) = self.num_buckets {
let mut bucket_by = proto::write_operation::BucketBy::default();
bucket_by.num_buckets = num_buckets;
bucket_by.bucket_column_names = self.bucket_cols.clone();
op.bucket_by = Some(bucket_by);
}
Ok(op)
}
fn save_table(self, table_name: &str, method: TableSaveMethod) -> Result<()> {
let mut table = proto::write_operation::SaveTable::default();
table.table_name = table_name.to_string();
table.save_method = method.to_proto();
let op =
self.build_write_operation(Some(proto::write_operation::SaveType::Table(table)))?;
execute_command(
&self.session,
proto::command::CommandType::WriteOperation(op),
)
}
pub fn save(self, path: Option<&str>) -> Result<()> {
let save_type = path.map(|p| proto::write_operation::SaveType::Path(p.to_string()));
let op = self.build_write_operation(save_type)?;
execute_command(
&self.session,
proto::command::CommandType::WriteOperation(op),
)
}
pub fn save_as_table(self, table_name: &str) -> Result<()> {
self.save_table(table_name, TableSaveMethod::SaveAsTable)
}
pub fn insert_into(self, table_name: &str) -> Result<()> {
self.save_table(table_name, TableSaveMethod::InsertInto)
}
pub fn json(mut self, path: &str) -> Result<()> {
self.format = Some("json".to_string());
self.save(Some(path))
}
pub fn parquet(mut self, path: &str) -> Result<()> {
self.format = Some("parquet".to_string());
self.save(Some(path))
}
pub fn csv(mut self, path: &str) -> Result<()> {
self.format = Some("csv".to_string());
self.save(Some(path))
}
pub fn orc(mut self, path: &str) -> Result<()> {
self.format = Some("orc".to_string());
self.save(Some(path))
}
pub fn text(mut self, path: &str) -> Result<()> {
self.format = Some("text".to_string());
self.save(Some(path))
}
pub fn xml(mut self, path: &str) -> Result<()> {
self.format = Some("xml".to_string());
self.save(Some(path))
}
}
pub struct DataFrameWriterV2 {
session: SparkSession,
input_plan: LogicalPlan,
table_name: String,
provider: Option<String>,
options: HashMap<String, String>,
table_properties: HashMap<String, String>,
partition_cols: Vec<Column>,
cluster_cols: Vec<String>,
}
impl DataFrameWriterV2 {
pub(crate) fn new(session: SparkSession, input_plan: LogicalPlan, table_name: &str) -> Self {
DataFrameWriterV2 {
session,
input_plan,
table_name: table_name.to_string(),
provider: None,
options: HashMap::new(),
table_properties: HashMap::new(),
partition_cols: vec![],
cluster_cols: vec![],
}
}
pub fn cluster_by<S: Into<String>>(mut self, cols: impl IntoIterator<Item = S>) -> Self {
self.cluster_cols = cols.into_iter().map(Into::into).collect();
self
}
pub fn using(mut self, provider: &str) -> Self {
self.provider = Some(provider.to_string());
self
}
pub fn option(mut self, key: &str, value: &str) -> Self {
self.options.insert(key.to_string(), value.to_string());
self
}
pub fn options(mut self, options: HashMap<String, String>) -> Self {
self.options.extend(options);
self
}
pub fn table_property(mut self, property: &str, value: &str) -> Self {
self.table_properties
.insert(property.to_string(), value.to_string());
self
}
pub fn partition_by<C: Into<Column>>(mut self, columns: impl IntoIterator<Item = C>) -> Self {
self.partition_cols = columns.into_iter().map(Into::into).collect();
self
}
pub(crate) fn build_operation(
&self,
mode: proto::write_operation_v2::Mode,
overwrite_condition: Option<proto::Expression>,
) -> Result<proto::WriteOperationV2> {
let mut op = proto::WriteOperationV2::default();
op.input = Some(build_input_relation(&self.input_plan, &self.session)?);
op.table_name = self.table_name.clone();
op.provider = self.provider.clone();
op.partitioning_columns = self.partition_cols.iter().map(|c| c.to_proto()).collect();
op.clustering_columns = self.cluster_cols.clone();
op.options = self.options.clone();
op.table_properties = self.table_properties.clone();
op.mode = mode as i32;
op.overwrite_condition = overwrite_condition;
Ok(op)
}
fn execute(self, mode: proto::write_operation_v2::Mode) -> Result<()> {
let op = self.build_operation(mode, None)?;
execute_command(
&self.session,
proto::command::CommandType::WriteOperationV2(op),
)
}
pub fn create(self) -> Result<()> {
self.execute(proto::write_operation_v2::Mode::Create)
}
pub fn replace(self) -> Result<()> {
self.execute(proto::write_operation_v2::Mode::Replace)
}
pub fn create_or_replace(self) -> Result<()> {
self.execute(proto::write_operation_v2::Mode::CreateOrReplace)
}
pub fn append(self) -> Result<()> {
self.execute(proto::write_operation_v2::Mode::Append)
}
pub fn overwrite(self, condition: Column) -> Result<()> {
let op = self.build_operation(
proto::write_operation_v2::Mode::Overwrite,
Some(condition.to_proto()),
)?;
execute_command(
&self.session,
proto::command::CommandType::WriteOperationV2(op),
)
}
pub fn overwrite_partitions(self) -> Result<()> {
self.execute(proto::write_operation_v2::Mode::OverwritePartitions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SparkSession;
fn session() -> SparkSession {
SparkSession::builder()
.remote("sc://localhost:15002")
.get_or_create()
.expect("failed to build session")
}
#[test]
fn v1_write_operation_to_path() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write()
.format("parquet")
.mode("overwrite")
.option("compression", "snappy")
.partition_by(vec!["a".to_string()])
.build_write_operation(Some(proto::write_operation::SaveType::Path(
"/tmp/out".to_string(),
)))
.unwrap();
assert!(op.input.is_some());
assert_eq!(op.source.as_deref(), Some("parquet"));
assert_eq!(op.mode, SaveMode::Overwrite.to_proto());
assert_eq!(
op.options.get("compression").map(String::as_str),
Some("snappy")
);
assert_eq!(op.partitioning_columns, vec!["a".to_string()]);
match op.save_type {
Some(proto::write_operation::SaveType::Path(p)) => assert_eq!(p, "/tmp/out"),
other => panic!("expected Path save_type, got {other:?}"),
}
}
#[test]
fn v1_write_operation_save_as_table() {
let spark = session();
let df = spark.range(3).unwrap();
let mut table = proto::write_operation::SaveTable::default();
table.table_name = "db.people".to_string();
table.save_method = TableSaveMethod::SaveAsTable.to_proto();
let op = df
.write()
.build_write_operation(Some(proto::write_operation::SaveType::Table(table)))
.unwrap();
match op.save_type {
Some(proto::write_operation::SaveType::Table(t)) => {
assert_eq!(t.table_name, "db.people");
assert_eq!(t.save_method, TableSaveMethod::SaveAsTable.to_proto());
}
other => panic!("expected Table save_type, got {other:?}"),
}
}
#[test]
fn v2_write_operation_fields_and_modes() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write_to("db.tbl")
.using("delta")
.option("mergeSchema", "true")
.table_property("owner", "eng")
.partition_by(vec![crate::column::col("a")])
.build_operation(proto::write_operation_v2::Mode::Append, None)
.unwrap();
assert!(op.input.is_some());
assert_eq!(op.table_name, "db.tbl");
assert_eq!(op.provider.as_deref(), Some("delta"));
assert_eq!(op.mode, proto::write_operation_v2::Mode::Append as i32);
assert_eq!(op.partitioning_columns.len(), 1);
assert_eq!(
op.options.get("mergeSchema").map(String::as_str),
Some("true")
);
assert_eq!(
op.table_properties.get("owner").map(String::as_str),
Some("eng")
);
let create = df
.write_to("t")
.build_operation(proto::write_operation_v2::Mode::Create, None)
.unwrap();
assert_eq!(create.mode, 1);
let cor = df
.write_to("t")
.build_operation(proto::write_operation_v2::Mode::CreateOrReplace, None)
.unwrap();
assert_eq!(cor.mode, 6);
}
#[test]
fn v2_overwrite_sets_condition() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write_to("t")
.build_operation(
proto::write_operation_v2::Mode::Overwrite,
Some(crate::column::col("id").to_proto()),
)
.unwrap();
assert_eq!(op.mode, proto::write_operation_v2::Mode::Overwrite as i32);
assert!(op.overwrite_condition.is_some());
}
#[test]
fn v1_write_operation_cluster_by() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write()
.format("parquet")
.cluster_by(vec!["col1".to_string(), "col2".to_string()])
.build_write_operation(Some(proto::write_operation::SaveType::Path(
"/tmp/out".to_string(),
)))
.unwrap();
assert_eq!(op.clustering_columns, vec!["col1", "col2"]);
}
#[test]
fn v2_write_operation_cluster_by() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write_to("t")
.cluster_by(vec!["col1".to_string(), "col2".to_string()])
.build_operation(proto::write_operation_v2::Mode::Create, None)
.unwrap();
assert_eq!(op.clustering_columns, vec!["col1", "col2"]);
}
#[test]
fn reader_jdbc_with_options() {
let spark = session();
let reader = spark.read();
let df = reader
.option("url", "jdbc:mysql://localhost:3306/db")
.option("user", "root")
.option("password", "secret")
.jdbc("jdbc:mysql://localhost:3306/db", "table_name", None);
match &df.plan {
LogicalPlan::Read {
read_type:
ReadType::DataSource {
options, format, ..
},
..
} => {
assert_eq!(format.as_deref(), Some("jdbc"));
assert_eq!(
options.get("url").map(String::as_str),
Some("jdbc:mysql://localhost:3306/db")
);
assert_eq!(options.get("user").map(String::as_str), Some("root"));
assert_eq!(options.get("password").map(String::as_str), Some("secret"));
}
_ => panic!("expected Read plan"),
}
}
#[test]
fn reader_jdbc_with_predicates() {
let spark = session();
let reader = spark.read();
let predicates = vec!["col1 > 10".to_string(), "col2 = 'value'".to_string()];
let df = reader.jdbc(
"jdbc:mysql://localhost/db",
"table",
Some(predicates.clone()),
);
match &df.plan {
LogicalPlan::Read {
read_type:
ReadType::DataSource {
predicates: preds, ..
},
..
} => {
assert_eq!(preds.len(), 2);
}
_ => panic!("expected Read plan with predicates"),
}
}
#[test]
fn v1_write_partition_and_cluster() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write()
.format("delta")
.partition_by(vec!["date".to_string()])
.cluster_by(vec!["user_id".to_string()])
.build_write_operation(Some(proto::write_operation::SaveType::Path(
"/tmp/data".to_string(),
)))
.unwrap();
assert_eq!(op.partitioning_columns, vec!["date"]);
assert_eq!(op.clustering_columns, vec!["user_id"]);
}
#[test]
fn v1_write_bucket_by() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write()
.format("parquet")
.bucket_by(10, vec!["col1".to_string()])
.build_write_operation(Some(proto::write_operation::SaveType::Path(
"/tmp/out".to_string(),
)))
.unwrap();
assert!(op.bucket_by.is_some());
let bucket_by = op.bucket_by.unwrap();
assert_eq!(bucket_by.num_buckets, 10);
assert_eq!(bucket_by.bucket_column_names, vec!["col1"]);
}
#[test]
fn v1_write_sort_by() {
let spark = session();
let df = spark.range(3).unwrap();
let op = df
.write()
.format("parquet")
.sort_by(vec!["col1".to_string()])
.build_write_operation(Some(proto::write_operation::SaveType::Path(
"/tmp/out".to_string(),
)))
.unwrap();
assert_eq!(op.sort_column_names, vec!["col1"]);
}
#[test]
fn reader_format_options() {
let spark = session();
let mut opts = std::collections::HashMap::new();
opts.insert("delimiter".to_string(), ";".to_string());
opts.insert("header".to_string(), "true".to_string());
let df = spark
.read()
.format("csv")
.options(opts)
.load(Some("/data.csv"));
match &df.plan {
LogicalPlan::Read {
read_type:
ReadType::DataSource {
options, format, ..
},
..
} => {
assert_eq!(format.as_deref(), Some("csv"));
assert_eq!(options.get("delimiter").map(String::as_str), Some(";"));
assert_eq!(options.get("header").map(String::as_str), Some("true"));
}
_ => panic!("expected Read plan"),
}
}
}