1use std::error;
10#[cfg(feature = "parquet")]
11use std::marker::PhantomData;
12
13#[cfg(feature = "parquet")]
14use arrow::datatypes::FieldRef;
15#[cfg(feature = "parquet")]
16use parquet::arrow::ArrowWriter;
17#[cfg(feature = "parquet")]
18use parquet::basic::{Compression, ZstdLevel};
19#[cfg(feature = "parquet")]
20use parquet::errors::ParquetError;
21#[cfg(feature = "parquet")]
22use parquet::file::properties::WriterProperties;
23use serde::Serialize;
24use serde::de::DeserializeOwned;
25#[cfg(feature = "parquet")]
26use serde_arrow::schema::{SchemaLike, TracingOptions};
27
28pub trait Encoder: Send + Sync + 'static {
33 type Error: error::Error + Send + Sync + 'static;
35
36 const EXT: &'static str;
39
40 fn encode<R: Serialize + DeserializeOwned>(
46 &self,
47 records: &[R],
48 ) -> Result<Vec<u8>, Self::Error>;
49}
50
51#[derive(Debug, Clone, Copy, Default)]
53pub struct JsonEncoder;
54
55impl Encoder for JsonEncoder {
56 type Error = serde_json::Error;
57 const EXT: &'static str = "json";
58
59 fn encode<R: Serialize + DeserializeOwned>(
60 &self,
61 records: &[R],
62 ) -> Result<Vec<u8>, Self::Error> {
63 serde_json::to_vec(records)
64 }
65}
66
67#[cfg(feature = "parquet")]
69#[derive(Debug, thiserror::Error)]
70pub enum ParquetEncodeError {
71 #[error("failed to derive arrow schema from record type: {0}")]
72 Schema(#[source] serde_arrow::Error),
73 #[error("failed to build record batch: {0}")]
74 Batch(#[source] serde_arrow::Error),
75 #[error("failed to write parquet: {0}")]
76 Parquet(#[from] ParquetError),
77}
78
79#[cfg(feature = "parquet")]
80mod private {
81 pub trait Sealed {}
82}
83
84#[cfg(feature = "parquet")]
89pub trait ParquetCompression: private::Sealed + Send + Sync + 'static {
90 #[doc(hidden)]
91 fn parquet_compression() -> Result<Compression, ParquetError>;
92}
93
94#[cfg(feature = "parquet")]
96#[derive(Debug, Clone, Copy, Default)]
97pub struct Uncompressed;
98
99#[cfg(feature = "parquet")]
100impl private::Sealed for Uncompressed {}
101
102#[cfg(feature = "parquet")]
103impl ParquetCompression for Uncompressed {
104 fn parquet_compression() -> Result<Compression, ParquetError> {
105 Ok(Compression::UNCOMPRESSED)
106 }
107}
108
109#[cfg(feature = "parquet")]
121#[derive(Debug, Clone, Copy, Default)]
122pub struct Zstd<const LEVEL: u8 = 1>;
123
124#[cfg(feature = "parquet")]
125macro_rules! impl_zstd_levels {
126 ($($level:literal),* $(,)?) => {
127 $(
128 impl private::Sealed for Zstd<$level> {}
129
130 impl ParquetCompression for Zstd<$level> {
131 fn parquet_compression() -> Result<Compression, ParquetError> {
132 ZstdLevel::try_new($level).map(Compression::ZSTD)
133 }
134 }
135 )*
136 };
137}
138
139#[cfg(feature = "parquet")]
140impl_zstd_levels!(
141 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
142);
143
144#[cfg(feature = "parquet")]
161#[derive(Debug, Clone, Copy)]
162pub struct ParquetEncoder<C = Uncompressed> {
163 _compression: PhantomData<C>,
164}
165
166#[cfg(feature = "parquet")]
167impl<C: ParquetCompression> ParquetEncoder<C> {
168 #[must_use]
170 pub const fn new() -> Self {
171 Self {
172 _compression: PhantomData,
173 }
174 }
175
176 fn writer_properties() -> Result<WriterProperties, ParquetError> {
177 Ok(WriterProperties::builder()
178 .set_compression(C::parquet_compression()?)
179 .build())
180 }
181}
182
183#[cfg(feature = "parquet")]
184impl Default for ParquetEncoder {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190#[cfg(feature = "parquet")]
191impl<C: ParquetCompression> Encoder for ParquetEncoder<C> {
192 type Error = ParquetEncodeError;
193 const EXT: &'static str = "parquet";
194
195 fn encode<R: Serialize + DeserializeOwned>(
200 &self,
201 records: &[R],
202 ) -> Result<Vec<u8>, Self::Error> {
203 let fields = Vec::<FieldRef>::from_type::<R>(TracingOptions::default())
204 .map_err(ParquetEncodeError::Schema)?;
205 let batch =
206 serde_arrow::to_record_batch(&fields, &records).map_err(ParquetEncodeError::Batch)?;
207
208 let mut buf = vec![];
209 let mut writer =
210 ArrowWriter::try_new(&mut buf, batch.schema(), Some(Self::writer_properties()?))?;
211 writer.write(&batch)?;
212 writer.close()?;
213 Ok(buf)
214 }
215}
216
217#[cfg(feature = "csv")]
219#[derive(Debug, thiserror::Error)]
220pub enum CsvError {
221 #[error("failed to serialize record to csv: {0}")]
222 Serialize(#[from] csv::Error),
223 #[error("failed to flush csv writer: {0}")]
224 Flush(#[from] std::io::Error),
225}
226
227#[cfg(feature = "csv")]
234#[derive(Debug, Clone, Copy, Default)]
235pub struct CsvEncoder;
236
237#[cfg(feature = "csv")]
238impl Encoder for CsvEncoder {
239 type Error = CsvError;
240 const EXT: &'static str = "csv";
241
242 fn encode<R: Serialize + DeserializeOwned>(
243 &self,
244 records: &[R],
245 ) -> Result<Vec<u8>, Self::Error> {
246 let mut writer = csv::Writer::from_writer(Vec::new());
247 for record in records {
248 writer.serialize(record)?;
249 }
250 writer
251 .into_inner()
252 .map_err(|e| CsvError::Flush(e.into_error()))
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use serde::Deserialize;
259
260 use super::*;
261
262 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263 struct Sample {
264 station_id: String,
265 timestamp: String,
266 value: f64,
267 }
268
269 fn samples() -> Vec<Sample> {
270 vec![
271 Sample {
272 station_id: "S100".into(),
273 timestamp: "2026-06-12T08:00:00+08:00".into(),
274 value: 29.4,
275 },
276 Sample {
277 station_id: "S117".into(),
278 timestamp: "2026-06-12T08:00:00+08:00".into(),
279 value: 30.1,
280 },
281 ]
282 }
283
284 #[cfg(feature = "parquet")]
285 mod parquet_encoder {
286 use arrow::array::RecordBatch;
287 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
288 use parquet::basic::{Compression, ZstdLevel};
289 use parquet::file::metadata::{ColumnChunkMetaData, RowGroupMetaData};
290
291 use super::*;
292
293 fn compressions(bytes: Vec<u8>) -> Vec<Compression> {
294 let builder =
295 ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes)).unwrap();
296 builder
297 .metadata()
298 .row_groups()
299 .iter()
300 .flat_map(RowGroupMetaData::columns)
301 .map(ColumnChunkMetaData::compression)
302 .collect()
303 }
304
305 #[test]
306 fn parquet_round_trip() {
307 let records = samples();
308
309 let encoder = ParquetEncoder::default();
310 let bytes = encoder.encode(&records).unwrap();
311
312 assert!(
313 compressions(bytes.clone())
314 .iter()
315 .all(|compression| *compression == Compression::UNCOMPRESSED)
316 );
317
318 let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
319 .unwrap()
320 .build()
321 .unwrap();
322 let batches: Vec<_> = reader.collect::<Result<_, _>>().unwrap();
323 assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
324
325 let round_tripped = serde_arrow::from_record_batch::<Vec<Sample>>(&batches[0]).unwrap();
326
327 assert_eq!(round_tripped, records);
328 }
329
330 #[test]
331 fn default_policy_uses_uncompressed_codec() {
332 let bytes = ParquetEncoder::default().encode(&samples()).unwrap();
333
334 assert!(
335 compressions(bytes)
336 .iter()
337 .all(|compression| *compression == Compression::UNCOMPRESSED)
338 );
339 }
340
341 #[test]
342 fn empty_slice_encodes_zero_row_file() {
343 let encoded = [
344 ParquetEncoder::default().encode::<Sample>(&[]).unwrap(),
345 ParquetEncoder::<Zstd>::new().encode::<Sample>(&[]).unwrap(),
346 ];
347
348 for bytes in encoded {
349 let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
350 .unwrap()
351 .build()
352 .unwrap();
353 let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
354 assert_eq!(rows, 0);
355 }
356 }
357
358 #[test]
359 fn zstd_default_level_round_trips() {
360 let records = samples();
361 let bytes = ParquetEncoder::<Zstd>::new().encode(&records).unwrap();
362
363 assert!(
364 compressions(bytes.clone())
365 .iter()
366 .all(|compression| { *compression == Compression::ZSTD(ZstdLevel::default()) })
367 );
368
369 let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
370 .unwrap()
371 .build()
372 .unwrap();
373 let batches: Vec<_> = reader.collect::<Result<_, _>>().unwrap();
374 let round_tripped = serde_arrow::from_record_batch::<Vec<Sample>>(&batches[0]).unwrap();
375
376 assert_eq!(round_tripped, records);
377 }
378
379 #[test]
380 fn zstd_explicit_level_is_applied() {
381 let level = ZstdLevel::try_new(7).unwrap();
382 let encoder = ParquetEncoder::<Zstd<7>>::new();
383 assert_eq!(
384 Zstd::<7>::parquet_compression().unwrap(),
385 Compression::ZSTD(level)
386 );
387
388 let bytes = encoder.encode(&samples()).unwrap();
389
390 assert!(
391 compressions(bytes.clone())
392 .iter()
393 .all(|compression| matches!(compression, Compression::ZSTD(_)))
394 );
395 ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
396 .unwrap()
397 .build()
398 .unwrap()
399 .collect::<Result<Vec<_>, _>>()
400 .unwrap();
401 }
402
403 #[test]
404 fn zstd_boundary_levels_are_applied() {
405 let policies = [
406 Zstd::<1>::parquet_compression().unwrap(),
407 Zstd::<22>::parquet_compression().unwrap(),
408 ];
409
410 assert_eq!(
411 policies,
412 [
413 Compression::ZSTD(ZstdLevel::try_new(1).unwrap()),
414 Compression::ZSTD(ZstdLevel::try_new(22).unwrap()),
415 ]
416 );
417 }
418
419 #[test]
420 fn zstd_shrinks_compressible_records() {
421 let records: Vec<_> = (0..4_096)
422 .map(|index| Sample {
423 station_id: format!("weather-station-{index:08}"),
424 timestamp: format!("2026-07-13T12:{:02}:{:02}+08:00", index / 60, index % 60),
425 value: 29.0 + f64::from(index % 10) / 10.0,
426 })
427 .collect();
428
429 let uncompressed = ParquetEncoder::default().encode(&records).unwrap();
430 let compressed = ParquetEncoder::<Zstd>::new().encode(&records).unwrap();
431
432 assert!(compressed.len() < uncompressed.len());
433 }
434 }
435
436 mod json_encoder {
437 use super::*;
438
439 #[test]
440 fn json_round_trip() {
441 let records = samples();
442 let bytes = JsonEncoder.encode(&records).unwrap();
443 let round_tripped: Vec<Sample> = serde_json::from_slice(&bytes).unwrap();
444 assert_eq!(round_tripped, records);
445 }
446
447 #[test]
448 fn empty_slice_encodes_empty_array() {
449 assert_eq!(JsonEncoder.encode::<Sample>(&[]).unwrap(), b"[]");
450 }
451 }
452
453 #[cfg(feature = "csv")]
454 mod csv_encoder {
455 use super::*;
456
457 #[test]
458 fn csv_round_trip_with_headers() {
459 let records = samples();
460 let bytes = CsvEncoder.encode(&records).unwrap();
461 let round_tripped = ::csv::Reader::from_reader(bytes.as_slice())
462 .deserialize()
463 .collect::<Result<Vec<Sample>, _>>()
464 .unwrap();
465 assert_eq!(round_tripped, records);
466 }
467
468 #[test]
469 fn empty_slice_encodes_empty_output() {
470 assert!(CsvEncoder.encode::<Sample>(&[]).unwrap().is_empty());
471 }
472 }
473}