mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-08-18 12:08:22 +00:00
448f973593
* fix: sandbox SQL local filesystem access Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: address local file sandbox review findings Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: support Windows local copy paths Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: improve sandbox path errors Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: simplify local path error context Signed-off-by: jeremyhi <fengjiachun@gmail.com> * perf: stream secure filesystem listings Signed-off-by: jeremyhi <fengjiachun@gmail.com> * style: derive local file access default Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: improve local file access errors Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: address local file access review findings Signed-off-by: jeremyhi <fengjiachun@gmail.com> * test: simplify local file access coverage Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: harden sandboxed local file backends Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: reject directory copy targets before creation Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: avoid implicit string clone in file table listing Signed-off-by: jeremyhi <fengjiachun@gmail.com> --------- Signed-off-by: jeremyhi <fengjiachun@gmail.com>
1357 lines
46 KiB
Rust
1357 lines
46 KiB
Rust
// Copyright 2023 Greptime Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::task::{Context, Poll};
|
|
|
|
use client::{Output, OutputData, OutputMeta};
|
|
use common_base::readable_size::ReadableSize;
|
|
use common_datasource::file_format::csv::{
|
|
CsvFormat, is_skippable_arrow_error, tolerant_csv_stream,
|
|
};
|
|
use common_datasource::file_format::json::JsonFormat;
|
|
use common_datasource::file_format::orc::{ReaderAdapter, infer_orc_schema, new_orc_stream_reader};
|
|
use common_datasource::file_format::{FileFormat, Format, file_to_stream};
|
|
use common_datasource::lister::{Lister, Source};
|
|
use common_datasource::object_store::build_backend_with_path;
|
|
use common_query::{OutputCost, OutputRows};
|
|
use common_recordbatch::DfSendableRecordBatchStream;
|
|
use common_recordbatch::adapter::RecordBatchStreamTypeAdapter;
|
|
use common_telemetry::{debug, tracing};
|
|
use datafusion::datasource::physical_plan::{CsvSource, FileSource, JsonSource};
|
|
use datafusion::parquet::arrow::ParquetRecordBatchStreamBuilder;
|
|
use datafusion::parquet::arrow::arrow_reader::ArrowReaderMetadata;
|
|
use datafusion_common::DataFusionError;
|
|
use datafusion_common::arrow::error::ArrowError;
|
|
use datafusion_common::config::CsvOptions;
|
|
use datafusion_expr::Expr;
|
|
use datatypes::arrow::compute::can_cast_types;
|
|
use datatypes::arrow::datatypes::{DataType as ArrowDataType, Schema, SchemaRef};
|
|
use datatypes::arrow::record_batch::RecordBatch;
|
|
use datatypes::vectors::Helper;
|
|
use futures_util::StreamExt;
|
|
use object_store::{Entry, EntryMode, ObjectStore};
|
|
use regex::Regex;
|
|
use session::context::QueryContextRef;
|
|
use snafu::{ResultExt, ensure};
|
|
use table::requests::{CopyTableRequest, InsertRequest};
|
|
use table::table_reference::TableReference;
|
|
use tokio_util::compat::FuturesAsyncReadCompatExt;
|
|
|
|
use crate::error::{self, IntoVectorsSnafu, Result};
|
|
use crate::statement::StatementExecutor;
|
|
|
|
const DEFAULT_BATCH_SIZE: usize = 8192;
|
|
const DEFAULT_READ_BUFFER: usize = 256 * 1024;
|
|
|
|
enum FileMetadata {
|
|
Parquet {
|
|
schema: SchemaRef,
|
|
metadata: ArrowReaderMetadata,
|
|
path: String,
|
|
},
|
|
Orc {
|
|
schema: SchemaRef,
|
|
path: String,
|
|
},
|
|
Json {
|
|
schema: SchemaRef,
|
|
format: JsonFormat,
|
|
path: String,
|
|
},
|
|
Csv {
|
|
schema: SchemaRef,
|
|
format: CsvFormat,
|
|
path: String,
|
|
},
|
|
}
|
|
|
|
impl FileMetadata {
|
|
/// Returns the [SchemaRef]
|
|
pub fn schema(&self) -> &SchemaRef {
|
|
match self {
|
|
FileMetadata::Parquet { schema, .. } => schema,
|
|
FileMetadata::Orc { schema, .. } => schema,
|
|
FileMetadata::Json { schema, .. } => schema,
|
|
FileMetadata::Csv { schema, .. } => schema,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl StatementExecutor {
|
|
async fn list_copy_from_entries(
|
|
&self,
|
|
req: &CopyTableRequest,
|
|
) -> Result<(ObjectStore, Vec<Entry>)> {
|
|
let backend =
|
|
build_backend_with_path(&req.location, &req.connection, &self.local_file_access)
|
|
.await
|
|
.context(error::BuildBackendSnafu)?;
|
|
let regex = req
|
|
.pattern
|
|
.as_ref()
|
|
.map(|x| Regex::new(x))
|
|
.transpose()
|
|
.context(error::BuildRegexSnafu)?;
|
|
|
|
let source = if let Some(filename) = backend.object_path {
|
|
Source::Filename(filename)
|
|
} else {
|
|
Source::Dir
|
|
};
|
|
|
|
let lister = Lister::new(
|
|
backend.object_store.clone(),
|
|
source.clone(),
|
|
req.location.clone(),
|
|
regex,
|
|
);
|
|
|
|
let entries = lister.list().await.context(error::ListObjectsSnafu)?;
|
|
debug!(
|
|
"Copy from location: {:?}, {source:?}, entries: {entries:?}",
|
|
req.location
|
|
);
|
|
Ok((backend.object_store, entries))
|
|
}
|
|
|
|
async fn collect_metadata(
|
|
&self,
|
|
object_store: &ObjectStore,
|
|
format: Format,
|
|
path: String,
|
|
) -> Result<FileMetadata> {
|
|
match format {
|
|
Format::Csv(format) => Ok(FileMetadata::Csv {
|
|
schema: Arc::new(
|
|
format
|
|
.infer_schema(object_store, &path)
|
|
.await
|
|
.context(error::InferSchemaSnafu { path: &path })?,
|
|
),
|
|
format,
|
|
path,
|
|
}),
|
|
Format::Json(format) => Ok(FileMetadata::Json {
|
|
schema: Arc::new(
|
|
format
|
|
.infer_schema(object_store, &path)
|
|
.await
|
|
.context(error::InferSchemaSnafu { path: &path })?,
|
|
),
|
|
format,
|
|
path,
|
|
}),
|
|
Format::Parquet(_) => {
|
|
let meta = object_store
|
|
.stat(&path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path: &path })?;
|
|
let mut reader = object_store
|
|
.reader(&path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path: &path })?
|
|
.into_futures_async_read(0..meta.content_length())
|
|
.await
|
|
.context(error::ReadObjectSnafu { path: &path })?
|
|
.compat();
|
|
let metadata = ArrowReaderMetadata::load_async(&mut reader, Default::default())
|
|
.await
|
|
.context(error::ReadParquetMetadataSnafu)?;
|
|
|
|
Ok(FileMetadata::Parquet {
|
|
schema: metadata.schema().clone(),
|
|
metadata,
|
|
path,
|
|
})
|
|
}
|
|
Format::Orc(_) => {
|
|
let meta = object_store
|
|
.stat(&path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path: &path })?;
|
|
|
|
let reader = object_store
|
|
.reader(&path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path: &path })?;
|
|
|
|
let schema = infer_orc_schema(ReaderAdapter::new(reader, meta.content_length()))
|
|
.await
|
|
.context(error::ReadOrcSnafu)?;
|
|
|
|
Ok(FileMetadata::Orc {
|
|
schema: Arc::new(schema),
|
|
path,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn build_read_stream(
|
|
&self,
|
|
compat_schema: SchemaRef,
|
|
object_store: &ObjectStore,
|
|
file_metadata: &FileMetadata,
|
|
projection: Vec<usize>,
|
|
filters: Vec<Expr>,
|
|
) -> Result<DfSendableRecordBatchStream> {
|
|
match file_metadata {
|
|
FileMetadata::Csv {
|
|
format,
|
|
path,
|
|
schema,
|
|
} => {
|
|
let output_schema = Arc::new(
|
|
compat_schema
|
|
.project(&projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
|
|
let options = CsvOptions::default()
|
|
.with_has_header(format.has_header)
|
|
.with_delimiter(format.delimiter);
|
|
let csv_source = CsvSource::new(schema.clone())
|
|
.with_csv_options(options)
|
|
.with_batch_size(DEFAULT_BATCH_SIZE);
|
|
let stream = if format.skip_bad_records {
|
|
let reader_schema =
|
|
csv_reader_schema_for_skip_bad_records(schema, &compat_schema);
|
|
tolerant_csv_stream(
|
|
object_store,
|
|
path,
|
|
Arc::new(reader_schema),
|
|
projection.clone(),
|
|
format,
|
|
)
|
|
.await
|
|
.context(error::BuildFileStreamSnafu)?
|
|
} else {
|
|
file_to_stream(
|
|
object_store,
|
|
path,
|
|
csv_source,
|
|
Some(projection),
|
|
format.compression_type,
|
|
)
|
|
.await
|
|
.context(error::BuildFileStreamSnafu)?
|
|
};
|
|
|
|
let stream = Box::pin(
|
|
// The projection is already applied in the CSV reader when we created the stream,
|
|
// so we pass None here to avoid double projection which would cause schema mismatch errors.
|
|
RecordBatchStreamTypeAdapter::new(output_schema, stream, None)
|
|
.with_filter(filters)
|
|
.context(error::PhysicalExprSnafu)?,
|
|
);
|
|
if format.skip_bad_records {
|
|
Ok(Box::pin(SkipBadRecordsStream::new(stream, path)))
|
|
} else {
|
|
Ok(stream)
|
|
}
|
|
}
|
|
FileMetadata::Json {
|
|
path,
|
|
format,
|
|
schema,
|
|
} => {
|
|
let output_schema = Arc::new(
|
|
compat_schema
|
|
.project(&projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
|
|
let json_source =
|
|
JsonSource::new(schema.clone()).with_batch_size(DEFAULT_BATCH_SIZE);
|
|
let stream = file_to_stream(
|
|
object_store,
|
|
path,
|
|
json_source,
|
|
Some(projection),
|
|
format.compression_type,
|
|
)
|
|
.await
|
|
.context(error::BuildFileStreamSnafu)?;
|
|
|
|
Ok(Box::pin(
|
|
// The projection is already applied in the JSON reader when we created the stream,
|
|
// so we pass None here to avoid double projection which would cause schema mismatch errors.
|
|
RecordBatchStreamTypeAdapter::new(output_schema, stream, None)
|
|
.with_filter(filters)
|
|
.context(error::PhysicalExprSnafu)?,
|
|
))
|
|
}
|
|
FileMetadata::Parquet { metadata, path, .. } => {
|
|
let meta = object_store
|
|
.stat(path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path })?;
|
|
let reader = object_store
|
|
.reader_with(path)
|
|
.chunk(DEFAULT_READ_BUFFER)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path })?
|
|
.into_futures_async_read(0..meta.content_length())
|
|
.await
|
|
.context(error::ReadObjectSnafu { path })?
|
|
.compat();
|
|
let builder =
|
|
ParquetRecordBatchStreamBuilder::new_with_metadata(reader, metadata.clone());
|
|
let stream = builder
|
|
.build()
|
|
.context(error::BuildParquetRecordBatchStreamSnafu)?;
|
|
|
|
let output_schema = Arc::new(
|
|
compat_schema
|
|
.project(&projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
Ok(Box::pin(
|
|
RecordBatchStreamTypeAdapter::new(output_schema, stream, Some(projection))
|
|
.with_filter(filters)
|
|
.context(error::PhysicalExprSnafu)?,
|
|
))
|
|
}
|
|
FileMetadata::Orc { path, .. } => {
|
|
let meta = object_store
|
|
.stat(path)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path })?;
|
|
|
|
let reader = object_store
|
|
.reader_with(path)
|
|
.chunk(DEFAULT_READ_BUFFER)
|
|
.await
|
|
.context(error::ReadObjectSnafu { path })?;
|
|
let stream =
|
|
new_orc_stream_reader(ReaderAdapter::new(reader, meta.content_length()))
|
|
.await
|
|
.context(error::ReadOrcSnafu)?;
|
|
|
|
let output_schema = Arc::new(
|
|
compat_schema
|
|
.project(&projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
|
|
Ok(Box::pin(
|
|
RecordBatchStreamTypeAdapter::new(output_schema, stream, Some(projection))
|
|
.with_filter(filters)
|
|
.context(error::PhysicalExprSnafu)?,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn copy_table_from(
|
|
&self,
|
|
req: CopyTableRequest,
|
|
query_ctx: QueryContextRef,
|
|
) -> Result<Output> {
|
|
let table_ref = TableReference {
|
|
catalog: &req.catalog_name,
|
|
schema: &req.schema_name,
|
|
table: &req.table_name,
|
|
};
|
|
let table = self.get_table(&table_ref).await?;
|
|
let format = Format::try_from(&req.with).context(error::ParseFileFormatSnafu)?;
|
|
let (object_store, entries) = self.list_copy_from_entries(&req).await?;
|
|
let mut files = Vec::with_capacity(entries.len());
|
|
let table_schema = table.schema().arrow_schema().clone();
|
|
let filters = table
|
|
.schema()
|
|
.timestamp_column()
|
|
.and_then(|c| {
|
|
common_query::logical_plan::build_same_type_ts_filter(c, req.timestamp_range)
|
|
})
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
|
|
for entry in entries.iter() {
|
|
if entry.metadata().mode() != EntryMode::FILE {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
let file_metadata = self
|
|
.collect_metadata(&object_store, format.clone(), path.to_string())
|
|
.await?;
|
|
|
|
validate_csv_headers_if_required(&file_metadata, &table_schema)?;
|
|
let schema_mapping = copy_from_schema_mapping(&file_metadata, &table_schema);
|
|
let projected_file_schema = Arc::new(
|
|
file_metadata
|
|
.schema()
|
|
.project(&schema_mapping.file_projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
let projected_table_schema = Arc::new(
|
|
table_schema
|
|
.project(&schema_mapping.table_projection)
|
|
.context(error::ProjectSchemaSnafu)?,
|
|
);
|
|
ensure_schema_compatible(&projected_file_schema, &projected_table_schema)?;
|
|
|
|
files.push((
|
|
Arc::new(schema_mapping.compat_file_schema),
|
|
schema_mapping.file_projection,
|
|
projected_table_schema,
|
|
file_metadata,
|
|
))
|
|
}
|
|
|
|
let mut rows_inserted = 0;
|
|
let mut insert_cost = 0;
|
|
let max_insert_rows = req
|
|
.limit
|
|
.map(|n| {
|
|
usize::try_from(n).map_err(|_| {
|
|
error::InvalidCopyParameterSnafu {
|
|
key: "limit".to_string(),
|
|
value: n.to_string(),
|
|
}
|
|
.build()
|
|
})
|
|
})
|
|
.transpose()?;
|
|
if max_insert_rows == Some(0) {
|
|
return Ok(gen_insert_output(rows_inserted, insert_cost));
|
|
}
|
|
|
|
let mut accepted_rows = 0;
|
|
for (compat_schema, file_schema_projection, projected_table_schema, file_metadata) in files
|
|
{
|
|
let mut stream = self
|
|
.build_read_stream(
|
|
compat_schema,
|
|
&object_store,
|
|
&file_metadata,
|
|
file_schema_projection,
|
|
filters.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let fields = projected_table_schema
|
|
.fields()
|
|
.iter()
|
|
.map(|f| f.name().clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
// TODO(hl): make this configurable through options.
|
|
let pending_mem_threshold = ReadableSize::mb(32).as_bytes();
|
|
let mut pending_mem_size = 0;
|
|
let mut pending = vec![];
|
|
|
|
while let Some(r) = stream.next().await {
|
|
let record_batch = r.context(error::ReadDfRecordBatchSnafu)?;
|
|
let record_batch = if let Some(max_insert_rows) = max_insert_rows {
|
|
let remaining_rows = max_insert_rows - accepted_rows;
|
|
if record_batch.num_rows() > remaining_rows {
|
|
record_batch.slice(0, remaining_rows)
|
|
} else {
|
|
record_batch
|
|
}
|
|
} else {
|
|
record_batch
|
|
};
|
|
let record_batch_rows = record_batch.num_rows();
|
|
let vectors =
|
|
Helper::try_into_vectors(record_batch.columns()).context(IntoVectorsSnafu)?;
|
|
|
|
pending_mem_size += vectors.iter().map(|v| v.memory_size()).sum::<usize>();
|
|
|
|
let columns_values = fields
|
|
.iter()
|
|
.cloned()
|
|
.zip(vectors)
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
pending.push(self.inserter.handle_table_insert(
|
|
InsertRequest {
|
|
catalog_name: req.catalog_name.clone(),
|
|
schema_name: req.schema_name.clone(),
|
|
table_name: req.table_name.clone(),
|
|
columns_values,
|
|
},
|
|
query_ctx.clone(),
|
|
));
|
|
accepted_rows += record_batch_rows;
|
|
|
|
if pending_mem_size as u64 >= pending_mem_threshold {
|
|
let (rows, cost) = batch_insert(&mut pending, &mut pending_mem_size).await?;
|
|
rows_inserted += rows;
|
|
insert_cost += cost;
|
|
}
|
|
|
|
if let Some(max_insert_rows) = max_insert_rows
|
|
&& accepted_rows == max_insert_rows
|
|
{
|
|
if !pending.is_empty() {
|
|
let (rows, cost) =
|
|
batch_insert(&mut pending, &mut pending_mem_size).await?;
|
|
rows_inserted += rows;
|
|
insert_cost += cost;
|
|
}
|
|
return Ok(gen_insert_output(rows_inserted, insert_cost));
|
|
}
|
|
}
|
|
|
|
if !pending.is_empty() {
|
|
let (rows, cost) = batch_insert(&mut pending, &mut pending_mem_size).await?;
|
|
rows_inserted += rows;
|
|
insert_cost += cost;
|
|
}
|
|
}
|
|
|
|
Ok(gen_insert_output(rows_inserted, insert_cost))
|
|
}
|
|
}
|
|
|
|
fn gen_insert_output(rows_inserted: usize, insert_cost: usize) -> Output {
|
|
Output::new(
|
|
OutputData::AffectedRows(rows_inserted),
|
|
OutputMeta::new_with_cost(insert_cost),
|
|
)
|
|
}
|
|
|
|
struct SkipBadRecordsStream {
|
|
inner: DfSendableRecordBatchStream,
|
|
path: String,
|
|
}
|
|
|
|
impl SkipBadRecordsStream {
|
|
fn new(inner: DfSendableRecordBatchStream, path: impl Into<String>) -> Self {
|
|
Self {
|
|
inner,
|
|
path: path.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl datafusion::physical_plan::RecordBatchStream for SkipBadRecordsStream {
|
|
fn schema(&self) -> SchemaRef {
|
|
self.inner.schema()
|
|
}
|
|
}
|
|
|
|
impl futures::Stream for SkipBadRecordsStream {
|
|
type Item = datafusion_common::Result<RecordBatch>;
|
|
|
|
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
let this = self.get_mut();
|
|
loop {
|
|
match this.inner.as_mut().poll_next(cx) {
|
|
Poll::Ready(Some(Err(error))) if is_skippable_record_error(&error) => {
|
|
common_telemetry::warn!(
|
|
"Skipping bad record while copying from {}: {}",
|
|
this.path,
|
|
error
|
|
);
|
|
continue;
|
|
}
|
|
other => return other,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_skippable_record_error(error: &DataFusionError) -> bool {
|
|
match error {
|
|
DataFusionError::ArrowError(error, _) => is_skippable_arrow_error(error),
|
|
DataFusionError::External(error) => error
|
|
.downcast_ref::<ArrowError>()
|
|
.is_some_and(is_skippable_arrow_error),
|
|
DataFusionError::Context(_, error) => is_skippable_record_error(error),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Executes all pending inserts all at once, drain pending requests and reset pending bytes.
|
|
async fn batch_insert(
|
|
pending: &mut Vec<impl Future<Output = Result<Output>>>,
|
|
pending_bytes: &mut usize,
|
|
) -> Result<(OutputRows, OutputCost)> {
|
|
let batch = pending.drain(..);
|
|
let result = futures::future::try_join_all(batch)
|
|
.await?
|
|
.iter()
|
|
.map(|o| o.extract_rows_and_cost())
|
|
.reduce(|(a, b), (c, d)| (a + c, b + d))
|
|
.unwrap_or((0, 0));
|
|
*pending_bytes = 0;
|
|
Ok(result)
|
|
}
|
|
|
|
/// Custom type compatibility check for GreptimeDB that handles Map -> Binary (JSON) conversion
|
|
fn can_cast_types_for_greptime(from: &ArrowDataType, to: &ArrowDataType) -> bool {
|
|
// Handle Map -> Binary conversion for JSON types
|
|
if let ArrowDataType::Map(_, _) = from
|
|
&& let ArrowDataType::Binary = to
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// For all other cases, use Arrow's built-in can_cast_types
|
|
can_cast_types(from, to)
|
|
}
|
|
|
|
fn csv_reader_schema_for_skip_bad_records(file: &SchemaRef, compat: &SchemaRef) -> Schema {
|
|
let fields = file
|
|
.fields()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, file_field)| match compat.fields().get(idx) {
|
|
Some(compat_field) if can_csv_reader_parse_type(compat_field.data_type()) => {
|
|
compat_field.clone()
|
|
}
|
|
_ => file_field.clone(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
Schema::new_with_metadata(fields, file.metadata().clone())
|
|
}
|
|
|
|
fn can_csv_reader_parse_type(data_type: &ArrowDataType) -> bool {
|
|
match data_type {
|
|
ArrowDataType::Boolean
|
|
| ArrowDataType::Decimal32(_, _)
|
|
| ArrowDataType::Decimal64(_, _)
|
|
| ArrowDataType::Decimal128(_, _)
|
|
| ArrowDataType::Decimal256(_, _)
|
|
| ArrowDataType::Int8
|
|
| ArrowDataType::Int16
|
|
| ArrowDataType::Int32
|
|
| ArrowDataType::Int64
|
|
| ArrowDataType::UInt8
|
|
| ArrowDataType::UInt16
|
|
| ArrowDataType::UInt32
|
|
| ArrowDataType::UInt64
|
|
| ArrowDataType::Float32
|
|
| ArrowDataType::Float64
|
|
| ArrowDataType::Date32
|
|
| ArrowDataType::Date64
|
|
| ArrowDataType::Time32(_)
|
|
| ArrowDataType::Time64(_)
|
|
| ArrowDataType::Timestamp(_, _)
|
|
| ArrowDataType::Null
|
|
| ArrowDataType::Utf8
|
|
| ArrowDataType::Utf8View => true,
|
|
ArrowDataType::Dictionary(_, value_type) => value_type.as_ref() == &ArrowDataType::Utf8,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn ensure_schema_compatible(from: &SchemaRef, to: &SchemaRef) -> Result<()> {
|
|
let not_match = from
|
|
.fields
|
|
.iter()
|
|
.zip(to.fields.iter())
|
|
.map(|(l, r)| (l.data_type(), r.data_type()))
|
|
.enumerate()
|
|
.find(|(_, (l, r))| !can_cast_types_for_greptime(l, r));
|
|
|
|
if let Some((index, _)) = not_match {
|
|
error::InvalidSchemaSnafu {
|
|
index,
|
|
table_schema: to.to_string(),
|
|
file_schema: from.to_string(),
|
|
}
|
|
.fail()
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn validate_csv_headers_if_required(file_metadata: &FileMetadata, table: &SchemaRef) -> Result<()> {
|
|
let FileMetadata::Csv {
|
|
schema,
|
|
format,
|
|
path,
|
|
} = file_metadata
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
if !format.strict_headers {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut seen_file_columns = HashSet::with_capacity(schema.fields().len());
|
|
let duplicate_columns = schema
|
|
.fields()
|
|
.iter()
|
|
.filter_map(|field| {
|
|
if seen_file_columns.insert(field.name().clone()) {
|
|
None
|
|
} else {
|
|
Some(field.name().clone())
|
|
}
|
|
})
|
|
.collect::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
let file_columns = seen_file_columns.into_iter().collect::<BTreeSet<_>>();
|
|
let table_columns = table
|
|
.fields()
|
|
.iter()
|
|
.map(|field| field.name().clone())
|
|
.collect::<BTreeSet<_>>();
|
|
let unknown_columns = file_columns
|
|
.difference(&table_columns)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let missing_columns = table_columns
|
|
.difference(&file_columns)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
ensure!(
|
|
unknown_columns.is_empty() && missing_columns.is_empty() && duplicate_columns.is_empty(),
|
|
error::CsvHeaderMismatchSnafu {
|
|
path,
|
|
unknown_columns,
|
|
missing_columns,
|
|
duplicate_columns,
|
|
}
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Generates a maybe compatible schema of the file schema.
|
|
///
|
|
/// If there is a field is found in table schema,
|
|
/// copy the field data type to maybe compatible schema(`compatible_fields`).
|
|
fn generated_schema_projection_and_compatible_file_schema(
|
|
file: &SchemaRef,
|
|
table: &SchemaRef,
|
|
) -> (Vec<usize>, Vec<usize>, Schema) {
|
|
let mut file_projection = Vec::with_capacity(file.fields.len());
|
|
let mut table_projection = Vec::with_capacity(file.fields.len());
|
|
let mut compatible_fields = file.fields.iter().cloned().collect::<Vec<_>>();
|
|
for (file_idx, file_field) in file.fields.iter().enumerate() {
|
|
if let Some((table_idx, table_field)) = table.fields.find(file_field.name()) {
|
|
file_projection.push(file_idx);
|
|
table_projection.push(table_idx);
|
|
|
|
// Safety: the compatible_fields has same length as file schema
|
|
compatible_fields[file_idx] = table_field.clone();
|
|
}
|
|
}
|
|
|
|
(
|
|
file_projection,
|
|
table_projection,
|
|
Schema::new(compatible_fields),
|
|
)
|
|
}
|
|
|
|
struct CopyFromSchemaMapping {
|
|
file_projection: Vec<usize>,
|
|
table_projection: Vec<usize>,
|
|
compat_file_schema: Schema,
|
|
}
|
|
|
|
fn copy_from_schema_mapping(
|
|
file_metadata: &FileMetadata,
|
|
table: &SchemaRef,
|
|
) -> CopyFromSchemaMapping {
|
|
match file_metadata {
|
|
FileMetadata::Csv { schema, format, .. } if !format.has_header => {
|
|
generated_positional_schema_projection_and_compatible_file_schema(schema, table)
|
|
}
|
|
_ => {
|
|
let (file_projection, table_projection, compat_file_schema) =
|
|
generated_schema_projection_and_compatible_file_schema(
|
|
file_metadata.schema(),
|
|
table,
|
|
);
|
|
CopyFromSchemaMapping {
|
|
file_projection,
|
|
table_projection,
|
|
compat_file_schema,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn generated_positional_schema_projection_and_compatible_file_schema(
|
|
file: &SchemaRef,
|
|
table: &SchemaRef,
|
|
) -> CopyFromSchemaMapping {
|
|
let len = file.fields.len().min(table.fields.len());
|
|
let file_projection = (0..len).collect::<Vec<_>>();
|
|
let table_projection = (0..len).collect::<Vec<_>>();
|
|
let compatible_fields = file
|
|
.fields
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, file_field)| {
|
|
if idx < len {
|
|
table.fields[idx].clone()
|
|
} else {
|
|
file_field.clone()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
CopyFromSchemaMapping {
|
|
file_projection,
|
|
table_projection,
|
|
compat_file_schema: Schema::new(compatible_fields),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use datatypes::arrow::datatypes::{DataType, Field, Schema};
|
|
|
|
use super::*;
|
|
|
|
fn test_schema_matches(from: (DataType, bool), to: (DataType, bool), matches: bool) {
|
|
let s1 = Arc::new(Schema::new(vec![Field::new("col", from.0.clone(), from.1)]));
|
|
let s2 = Arc::new(Schema::new(vec![Field::new("col", to.0.clone(), to.1)]));
|
|
let res = ensure_schema_compatible(&s1, &s2);
|
|
assert_eq!(
|
|
matches,
|
|
res.is_ok(),
|
|
"from data type: {}, to data type: {}, expected: {}, but got: {}",
|
|
from.0,
|
|
to.0,
|
|
matches,
|
|
res.is_ok()
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_datatype_matches_ignore_timezone() {
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Second, None),
|
|
true,
|
|
),
|
|
(
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Second, None),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Second,
|
|
Some("UTC".into()),
|
|
),
|
|
true,
|
|
),
|
|
(
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Second, None),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Second,
|
|
Some("UTC".into()),
|
|
),
|
|
true,
|
|
),
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Second,
|
|
Some("PDT".into()),
|
|
),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Second,
|
|
Some("UTC".into()),
|
|
),
|
|
true,
|
|
),
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Millisecond,
|
|
Some("UTC".into()),
|
|
),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches((DataType::Int8, true), (DataType::Int8, true), true);
|
|
|
|
test_schema_matches((DataType::Int8, true), (DataType::Int16, true), true);
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_type_equals_ignore_timezone_with_options() {
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Microsecond,
|
|
Some("UTC".into()),
|
|
),
|
|
true,
|
|
),
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Millisecond,
|
|
Some("PDT".into()),
|
|
),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches(
|
|
(DataType::Utf8, true),
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Millisecond,
|
|
Some("PDT".into()),
|
|
),
|
|
true,
|
|
),
|
|
true,
|
|
);
|
|
|
|
test_schema_matches(
|
|
(
|
|
DataType::Timestamp(
|
|
datatypes::arrow::datatypes::TimeUnit::Millisecond,
|
|
Some("PDT".into()),
|
|
),
|
|
true,
|
|
),
|
|
(DataType::Utf8, true),
|
|
true,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_to_binary_json_compatibility() {
|
|
// Test Map -> Binary conversion for JSON types
|
|
let map_type = DataType::Map(
|
|
Arc::new(Field::new(
|
|
"key_value",
|
|
DataType::Struct(
|
|
vec![
|
|
Field::new("key", DataType::Utf8, false),
|
|
Field::new("value", DataType::Utf8, false),
|
|
]
|
|
.into(),
|
|
),
|
|
false,
|
|
)),
|
|
false,
|
|
);
|
|
|
|
test_schema_matches((map_type, false), (DataType::Binary, true), true);
|
|
|
|
test_schema_matches((DataType::Int8, true), (DataType::Int16, true), true);
|
|
test_schema_matches((DataType::Utf8, true), (DataType::Binary, true), true);
|
|
}
|
|
|
|
fn make_test_schema(v: &[Field]) -> Arc<Schema> {
|
|
Arc::new(Schema::new(v.to_vec()))
|
|
}
|
|
|
|
#[test]
|
|
fn test_compatible_file_schema() {
|
|
let file_schema0 = make_test_schema(&[
|
|
Field::new("c1", DataType::UInt8, true),
|
|
Field::new("c2", DataType::UInt8, true),
|
|
]);
|
|
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("c1", DataType::Int16, true),
|
|
Field::new("c2", DataType::Int16, true),
|
|
Field::new("c3", DataType::Int16, true),
|
|
]);
|
|
|
|
let compat_schema = make_test_schema(&[
|
|
Field::new("c1", DataType::Int16, true),
|
|
Field::new("c2", DataType::Int16, true),
|
|
]);
|
|
|
|
let (_, tp, _) =
|
|
generated_schema_projection_and_compatible_file_schema(&file_schema0, &table_schema);
|
|
|
|
assert_eq!(table_schema.project(&tp).unwrap(), *compat_schema);
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_projection() {
|
|
let file_schema0 = make_test_schema(&[
|
|
Field::new("c1", DataType::UInt8, true),
|
|
Field::new("c2", DataType::UInt8, true),
|
|
Field::new("c3", DataType::UInt8, true),
|
|
]);
|
|
|
|
let file_schema1 = make_test_schema(&[
|
|
Field::new("c3", DataType::UInt8, true),
|
|
Field::new("c4", DataType::UInt8, true),
|
|
]);
|
|
|
|
let file_schema2 = make_test_schema(&[
|
|
Field::new("c3", DataType::UInt8, true),
|
|
Field::new("c4", DataType::UInt8, true),
|
|
Field::new("c5", DataType::UInt8, true),
|
|
]);
|
|
|
|
let file_schema3 = make_test_schema(&[
|
|
Field::new("c1", DataType::UInt8, true),
|
|
Field::new("c2", DataType::UInt8, true),
|
|
]);
|
|
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("c3", DataType::UInt8, true),
|
|
Field::new("c4", DataType::UInt8, true),
|
|
Field::new("c5", DataType::UInt8, true),
|
|
]);
|
|
|
|
let tests = [
|
|
(&file_schema0, &table_schema, true), // intersection
|
|
(&file_schema1, &table_schema, true), // subset
|
|
(&file_schema2, &table_schema, true), // full-eq
|
|
(&file_schema3, &table_schema, true), // non-intersection
|
|
];
|
|
|
|
for test in tests {
|
|
let (fp, tp, _) =
|
|
generated_schema_projection_and_compatible_file_schema(test.0, test.1);
|
|
assert_eq!(test.0.project(&fp).unwrap(), test.1.project(&tp).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_csv_reader_schema_for_skip_bad_records() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("id", DataType::Utf8, true),
|
|
Field::new("jsons", DataType::Utf8, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
let compat_schema = make_test_schema(&[
|
|
Field::new("id", DataType::UInt32, true),
|
|
Field::new("jsons", DataType::Binary, true),
|
|
Field::new(
|
|
"ts",
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Millisecond, None),
|
|
true,
|
|
),
|
|
]);
|
|
|
|
let reader_schema = csv_reader_schema_for_skip_bad_records(&file_schema, &compat_schema);
|
|
|
|
assert_eq!(reader_schema.field(0).data_type(), &DataType::UInt32);
|
|
assert_eq!(reader_schema.field(1).data_type(), &DataType::Utf8);
|
|
assert_eq!(
|
|
reader_schema.field(2).data_type(),
|
|
compat_schema.field(2).data_type()
|
|
);
|
|
}
|
|
|
|
fn make_csv_metadata(schema: Arc<Schema>, has_header: bool) -> FileMetadata {
|
|
FileMetadata::Csv {
|
|
schema,
|
|
format: CsvFormat {
|
|
has_header,
|
|
..CsvFormat::default()
|
|
},
|
|
path: "test.csv".to_string(),
|
|
}
|
|
}
|
|
|
|
fn make_strict_csv_metadata(schema: Arc<Schema>) -> FileMetadata {
|
|
FileMetadata::Csv {
|
|
schema,
|
|
format: CsvFormat {
|
|
strict_headers: true,
|
|
..CsvFormat::default()
|
|
},
|
|
path: "test.csv".to_string(),
|
|
}
|
|
}
|
|
|
|
fn assert_field(schema: &Schema, idx: usize, name: &str, data_type: &DataType) {
|
|
let field = schema.field(idx);
|
|
assert_eq!(field.name(), name);
|
|
assert_eq!(field.data_type(), data_type);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strict_csv_headers_allows_reordered_columns() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("ts", DataType::Utf8, true),
|
|
Field::new("host_id", DataType::UInt8, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
validate_csv_headers_if_required(&make_strict_csv_metadata(file_schema), &table_schema)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_strict_csv_headers_rejects_unknown_columns() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt8, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
Field::new("extra", DataType::Utf8, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
let err =
|
|
validate_csv_headers_if_required(&make_strict_csv_metadata(file_schema), &table_schema)
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
error::Error::CsvHeaderMismatch {
|
|
unknown_columns,
|
|
missing_columns,
|
|
duplicate_columns,
|
|
..
|
|
} if unknown_columns == vec!["extra".to_string()]
|
|
&& missing_columns.is_empty()
|
|
&& duplicate_columns.is_empty()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_strict_csv_headers_rejects_missing_columns() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt8, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
let err =
|
|
validate_csv_headers_if_required(&make_strict_csv_metadata(file_schema), &table_schema)
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
error::Error::CsvHeaderMismatch {
|
|
unknown_columns,
|
|
missing_columns,
|
|
duplicate_columns,
|
|
..
|
|
} if unknown_columns.is_empty()
|
|
&& missing_columns == vec!["reading_value".to_string()]
|
|
&& duplicate_columns.is_empty()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_strict_csv_headers_rejects_duplicate_columns() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt8, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
Field::new("host_id", DataType::UInt16, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
let err =
|
|
validate_csv_headers_if_required(&make_strict_csv_metadata(file_schema), &table_schema)
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
error::Error::CsvHeaderMismatch {
|
|
unknown_columns,
|
|
missing_columns,
|
|
duplicate_columns,
|
|
..
|
|
} if unknown_columns.is_empty()
|
|
&& missing_columns.is_empty()
|
|
&& duplicate_columns == vec!["host_id".to_string()]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_headerless_csv_schema_projection_is_positional() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("column_1", DataType::UInt8, true),
|
|
Field::new("column_2", DataType::Float64, true),
|
|
Field::new("column_3", DataType::Utf8, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new(
|
|
"ts",
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Millisecond, None),
|
|
true,
|
|
),
|
|
]);
|
|
|
|
let mapping =
|
|
copy_from_schema_mapping(&make_csv_metadata(file_schema, false), &table_schema);
|
|
|
|
assert_eq!(mapping.file_projection, vec![0, 1, 2]);
|
|
assert_eq!(mapping.table_projection, vec![0, 1, 2]);
|
|
assert_field(&mapping.compat_file_schema, 0, "host_id", &DataType::UInt32);
|
|
assert_field(
|
|
&mapping.compat_file_schema,
|
|
1,
|
|
"reading_value",
|
|
&DataType::Float64,
|
|
);
|
|
assert_field(
|
|
&mapping.compat_file_schema,
|
|
2,
|
|
"ts",
|
|
table_schema.field(2).data_type(),
|
|
);
|
|
assert_eq!(
|
|
mapping
|
|
.compat_file_schema
|
|
.project(&mapping.file_projection)
|
|
.unwrap(),
|
|
table_schema.project(&mapping.table_projection).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_headerless_csv_schema_projection_ignores_extra_file_columns() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("column_1", DataType::UInt8, true),
|
|
Field::new("column_2", DataType::Float64, true),
|
|
Field::new("column_3", DataType::Utf8, true),
|
|
Field::new("column_4", DataType::Utf8, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
let mapping =
|
|
copy_from_schema_mapping(&make_csv_metadata(file_schema, false), &table_schema);
|
|
|
|
assert_eq!(mapping.file_projection, vec![0, 1, 2]);
|
|
assert_eq!(mapping.table_projection, vec![0, 1, 2]);
|
|
assert_eq!(mapping.compat_file_schema.fields().len(), 4);
|
|
assert_field(&mapping.compat_file_schema, 0, "host_id", &DataType::UInt32);
|
|
assert_field(
|
|
&mapping.compat_file_schema,
|
|
1,
|
|
"reading_value",
|
|
&DataType::Float64,
|
|
);
|
|
assert_field(&mapping.compat_file_schema, 2, "ts", &DataType::Utf8);
|
|
assert_field(&mapping.compat_file_schema, 3, "column_4", &DataType::Utf8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_headerless_csv_schema_projection_supports_prefix_import() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("column_1", DataType::UInt8, true),
|
|
Field::new("column_2", DataType::Float64, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("reading_value", DataType::Float64, true),
|
|
Field::new("ts", DataType::Utf8, true),
|
|
]);
|
|
|
|
let mapping =
|
|
copy_from_schema_mapping(&make_csv_metadata(file_schema, false), &table_schema);
|
|
|
|
assert_eq!(mapping.file_projection, vec![0, 1]);
|
|
assert_eq!(mapping.table_projection, vec![0, 1]);
|
|
assert_field(&mapping.compat_file_schema, 0, "host_id", &DataType::UInt32);
|
|
assert_field(
|
|
&mapping.compat_file_schema,
|
|
1,
|
|
"reading_value",
|
|
&DataType::Float64,
|
|
);
|
|
assert_eq!(
|
|
mapping
|
|
.compat_file_schema
|
|
.project(&mapping.file_projection)
|
|
.unwrap(),
|
|
table_schema.project(&mapping.table_projection).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_csv_reader_schema_for_skip_bad_records_uses_positional_mapping() {
|
|
let file_schema = make_test_schema(&[
|
|
Field::new("column_1", DataType::Utf8, true),
|
|
Field::new("column_2", DataType::Utf8, true),
|
|
Field::new("column_3", DataType::Utf8, true),
|
|
]);
|
|
let table_schema = make_test_schema(&[
|
|
Field::new("host_id", DataType::UInt32, true),
|
|
Field::new("jsons", DataType::Binary, true),
|
|
Field::new(
|
|
"ts",
|
|
DataType::Timestamp(datatypes::arrow::datatypes::TimeUnit::Millisecond, None),
|
|
true,
|
|
),
|
|
]);
|
|
let mapping = copy_from_schema_mapping(
|
|
&make_csv_metadata(file_schema.clone(), false),
|
|
&table_schema,
|
|
);
|
|
let compat_schema = Arc::new(mapping.compat_file_schema);
|
|
|
|
let reader_schema = csv_reader_schema_for_skip_bad_records(&file_schema, &compat_schema);
|
|
|
|
assert_eq!(reader_schema.field(0).data_type(), &DataType::UInt32);
|
|
assert_eq!(reader_schema.field(1).data_type(), &DataType::Utf8);
|
|
assert_eq!(
|
|
reader_schema.field(2).data_type(),
|
|
table_schema.field(2).data_type()
|
|
);
|
|
}
|
|
}
|