Skip to main content

readstat/
lib.rs

1//! Read SAS binary files (`.sas7bdat`) and convert them to other formats.
2//!
3//! This crate provides a library for parsing SAS binary data files using FFI
4//! bindings to the [ReadStat](https://github.com/WizardMac/ReadStat) C library,
5//! then converting the parsed data into Apache Arrow [`RecordBatch`](arrow_array::RecordBatch)
6//! format for output as CSV, Feather (Arrow IPC), NDJSON, or Parquet.
7//!
8//! **Note:** While the underlying [`readstat-sys`](https://docs.rs/readstat-sys) crate
9//! exposes bindings for all formats supported by `ReadStat` (SAS, SPSS, Stata),
10//! this crate only implements parsing and conversion for **SAS `.sas7bdat` files**.
11//! SPSS and Stata support is a possible future addition, but is **not planned at
12//! this time** — if you need those formats today, the `readstat-sys` bindings
13//! already expose the complete SPSS (`.sav`, `.zsav`, `.por`) and Stata (`.dta`)
14//! C API to build on.
15//!
16//! # Data Pipeline
17//!
18//! ```text
19//! .sas7bdat file
20//!     → ReadStat C library (FFI parsing via callbacks)
21//!         → Typed Arrow builders (StringBuilder, Float64Builder, etc.)
22//!             → Arrow RecordBatch
23//!                 → Output format (CSV / Feather / NDJSON / Parquet)
24//! ```
25//!
26//! # Examples
27//!
28//! ## Quick start
29//!
30//! [`ReadStatReader`] is the primary API. It reads paths, owned bytes, or memory
31//! maps and supports metadata, row/column selection, collection, and bounded
32//! chunk callbacks. The two free functions are shorthand for path defaults:
33//!
34//! ```no_run
35//! # fn main() -> Result<(), readstat::ReadStatError> {
36//! let md = readstat::read_metadata("data.sas7bdat")?;
37//! println!("{:?} rows x {} columns", md.row_count, md.var_count);
38//!
39//! let batch = readstat::read_to_batch("data.sas7bdat")?;
40//! println!("Schema: {:?}", batch.schema());
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! For bounded-memory streaming and selection, configure a reader:
46//!
47//! ```no_run
48//! # fn main() -> Result<(), readstat::ReadStatError> {
49//! let reader = readstat::ReadStatReader::from_path("data.sas7bdat")?
50//!     .columns(["name", "age"])
51//!     .rows(100, Some(1_000))
52//!     .chunk_rows(250);
53//! reader.visit(|batch| {
54//!     println!("received {} rows", batch.num_rows());
55//!     Ok(())
56//! })?;
57//! # Ok(())
58//! # }
59//! ```
60//!
61//! The lower-level metadata and data types remain public for compatibility and
62//! specialized integrations, but new callers should start with [`ReadStatReader`].
63//!
64//! ## Inspect file metadata
65//!
66//! Read metadata without loading any row data. Useful for discovering
67//! schema, row counts, variable types, and SAS format classifications.
68//!
69//! ```no_run
70//! use readstat::{ReadStatPath, ReadStatMetadata};
71//!
72//! # fn main() -> Result<(), readstat::ReadStatError> {
73//! let rsp = ReadStatPath::new("data.sas7bdat")?;
74//!
75//! let mut md = ReadStatMetadata::new();
76//! md.read_metadata(&rsp, false)?;
77//!
78//! println!("Rows: {:?}, Variables: {}", md.row_count, md.var_count);
79//! println!("Encoding: {}", md.file_encoding);
80//! println!("Compression: {:?}", md.compression);
81//!
82//! // Iterate over variable metadata
83//! for (idx, var) in &md.vars {
84//!     println!(
85//!         "  [{idx}] {} ({:?}, format: {})",
86//!         var.var_name, var.var_type_class, var.var_format
87//!     );
88//! }
89//!
90//! // The Arrow schema is also available
91//! println!("Schema: {:?}", md.schema);
92//! # Ok(())
93//! # }
94//! ```
95//!
96//! ## Read all data into Arrow `RecordBatch`
97//!
98//! Parse the entire file into a single Arrow [`RecordBatch`](arrow_array::RecordBatch).
99//! Best for smaller files that fit comfortably in memory.
100//!
101//! ```no_run
102//! use readstat::{ReadStatPath, ReadStatMetadata, ReadStatData};
103//!
104//! # fn main() -> Result<(), readstat::ReadStatError> {
105//! let rsp = ReadStatPath::new("data.sas7bdat")?;
106//!
107//! // Read metadata first
108//! let mut md = ReadStatMetadata::new();
109//! md.read_metadata(&rsp, false)?;
110//!
111//! // Read all rows into a single chunk
112//! let row_count = md.row_count.ok_or(readstat::ReadStatError::RowCountUnavailable)? as u32;
113//! let mut d = ReadStatData::new().init(md, 0, row_count);
114//! d.read_data(&rsp)?;
115//!
116//! // Access the Arrow RecordBatch
117//! if let Some(batch) = &d.batch {
118//!     println!("Got {} rows x {} columns", batch.num_rows(), batch.num_columns());
119//!     println!("Schema: {:?}", batch.schema());
120//! }
121//! # Ok(())
122//! # }
123//! ```
124//!
125//! ## Stream data in chunks and write to Parquet
126//!
127//! For large files, read in streaming chunks to control memory usage.
128//! Each chunk is parsed and written incrementally.
129//!
130//! ```no_run
131//! use readstat::{OutFormat, ReadStatReader, ReadStatWriter, WriteConfig};
132//!
133//! # fn main() -> Result<(), readstat::ReadStatError> {
134//! let reader = ReadStatReader::from_path("data.sas7bdat")?.chunk_rows(10_000);
135//! let schema = std::sync::Arc::new(reader.metadata()?.schema);
136//! let config = WriteConfig::new(OutFormat::Parquet)
137//!     .output("output.parquet")?
138//!     .overwrite(false);
139//! let mut writer = ReadStatWriter::new(config, schema)?;
140//! reader.visit(|batch| writer.write(&batch))?;
141//! println!("wrote {} rows", writer.finish()?);
142//! # Ok(())
143//! # }
144//! ```
145//!
146//! ## Read from in-memory bytes
147//!
148//! Parse a `.sas7bdat` file from a byte slice instead of the filesystem.
149//! Useful for cloud storage, HTTP uploads, WASM targets, and testing.
150//!
151//! ```no_run
152//! use readstat::{ReadStatMetadata, ReadStatData};
153//!
154//! # fn main() -> Result<(), readstat::ReadStatError> {
155//! # let sas_bytes: &[u8] = &[];
156//! // sas_bytes: &[u8] — obtained from S3, HTTP, etc.
157//! let mut md = ReadStatMetadata::new();
158//! md.read_metadata_from_bytes(sas_bytes, false)?;
159//!
160//! let row_count = md.row_count.ok_or(readstat::ReadStatError::RowCountUnavailable)? as u32;
161//! let mut d = ReadStatData::new().init(md, 0, row_count);
162//! d.read_data_from_bytes(sas_bytes)?;
163//!
164//! if let Some(batch) = &d.batch {
165//!     println!("Parsed {} rows from bytes", batch.num_rows());
166//! }
167//! # Ok(())
168//! # }
169//! ```
170//!
171//! ## Filter to specific columns
172//!
173//! Select only specific columns before reading data. Unselected columns
174//! are skipped during parsing, reducing both memory and CPU usage.
175//!
176//! ```no_run
177//! use readstat::{ReadStatPath, ReadStatMetadata, ReadStatData};
178//!
179//! # fn main() -> Result<(), readstat::ReadStatError> {
180//! let rsp = ReadStatPath::new("data.sas7bdat")?;
181//!
182//! let mut md = ReadStatMetadata::new();
183//! md.read_metadata(&rsp, false)?;
184//!
185//! // Select only these columns
186//! let columns = vec!["name".to_string(), "age".to_string()];
187//!
188//! if let Some(mapping) = md.resolve_selected_columns(Some(columns))? {
189//!     // `init_filtered` applies the filter and initializes in the correct
190//!     // order — pass the *original* metadata; filtering happens internally.
191//!     let row_count = u32::try_from(md.row_count.ok_or(readstat::ReadStatError::RowCountUnavailable)?)?;
192//!     let mut d = ReadStatData::new().init_filtered(md, &mapping, 0, row_count);
193//!     d.read_data(&rsp)?;
194//!
195//!     if let Some(batch) = &d.batch {
196//!         // batch only contains "name" and "age" columns
197//!         println!(
198//!             "Columns: {:?}",
199//!             batch.schema().fields().iter().map(|f| f.name()).collect::<Vec<_>>()
200//!         );
201//!     }
202//! }
203//! # Ok(())
204//! # }
205//! ```
206//!
207//! ## Convert `RecordBatch` to in-memory bytes
208//!
209//! Serialize a parsed [`RecordBatch`](arrow_array::RecordBatch) directly to
210//! in-memory bytes without writing to a file. Useful for HTTP responses,
211//! message queues, or piping to other Arrow-aware tools.
212//!
213//! ```no_run
214//! use readstat::{ReadStatPath, ReadStatMetadata, ReadStatData};
215//! # #[cfg(feature = "parquet")]
216//! use readstat::write_batch_to_parquet_bytes;
217//! # #[cfg(feature = "csv")]
218//! use readstat::write_batch_to_csv_bytes;
219//!
220//! # fn main() -> Result<(), readstat::ReadStatError> {
221//! let rsp = ReadStatPath::new("data.sas7bdat")?;
222//!
223//! let mut md = ReadStatMetadata::new();
224//! md.read_metadata(&rsp, false)?;
225//!
226//! let row_count = md.row_count.ok_or(readstat::ReadStatError::RowCountUnavailable)? as u32;
227//! let mut d = ReadStatData::new().init(md, 0, row_count);
228//! d.read_data(&rsp)?;
229//!
230//! if let Some(batch) = &d.batch {
231//!     // Get Parquet bytes (e.g. for an HTTP response)
232//!     # #[cfg(feature = "parquet")]
233//!     let parquet_bytes = write_batch_to_parquet_bytes(batch)?;
234//!
235//!     // Or CSV bytes
236//!     # #[cfg(feature = "csv")]
237//!     let csv_bytes = write_batch_to_csv_bytes(batch)?;
238//! }
239//! # Ok(())
240//! # }
241//! ```
242//!
243//! # Key Functions
244//!
245//! - [`read_metadata`] — Read file/variable metadata in one call
246//! - [`read_to_batch`] — Read an entire file into one Arrow [`RecordBatch`](arrow_array::RecordBatch)
247//!
248//! # Key Types
249//!
250//! - [`ReadStatPath`] — Validated input file path for SAS files
251//! - [`WriteConfig`] — Output configuration (path, format, compression)
252//! - [`ReadStatMetadata`] — File-level metadata (row/var counts, encoding, Arrow schema)
253//! - [`ReadStatData`] — Parsed row data, convertible to Arrow [`RecordBatch`](arrow_array::RecordBatch)
254//! - [`ReadStatVarFormatClass`] — SAS format classification (Date, `DateTime`, Time variants)
255//! - [`ReadStatWriter`] — Writes Arrow batches to the configured output format
256//!
257//! # Features
258//!
259//! Output format writers are feature-gated (all enabled by default):
260//!
261//! | Feature | Format | Notes |
262//! |---------|--------|-------|
263//! | `csv` | CSV | Comma-separated values via `arrow-csv` |
264//! | `parquet` | Parquet | Columnar format via `parquet` crate, 5 compression codecs |
265//! | `feather` | Feather | Arrow IPC format via `arrow-ipc` |
266//! | `ndjson` | NDJSON | Newline-delimited JSON via `arrow-json` |
267//! | `sql` | SQL | Query data with SQL via DataFusion (enabled by default) |
268//!
269//! # Arrow version policy
270//!
271//! This crate exposes Apache Arrow types ([`RecordBatch`](arrow_array::RecordBatch),
272//! [`Schema`](arrow_schema::Schema), …) in its public API. To avoid the
273//! "expected `RecordBatch`, found `RecordBatch`" mismatch that occurs when a
274//! consumer pins a different Arrow version, the [`arrow`], [`arrow_array`], and
275//! [`arrow_schema`] crates are re-exported here — prefer
276//! `readstat::arrow_array::RecordBatch` over a direct Arrow dependency.
277//!
278//! Arrow is currently pinned to the **v58** ecosystem. Because Arrow types
279//! appear in the public API, a major Arrow bump is a breaking change for this
280//! crate and will come with a corresponding semver-major release.
281
282#![cfg_attr(docsrs, feature(doc_cfg))]
283#![warn(missing_docs)]
284#![allow(clippy::module_name_repetitions)]
285#![allow(clippy::must_use_candidate)]
286#![allow(clippy::return_self_not_must_use)]
287
288// Re-export the Arrow ecosystem crates so consumers can name the types that
289// appear in this crate's public API without pinning Arrow themselves. See the
290// "Arrow version policy" section above.
291pub use api::{ReadStatReader, read_metadata, read_to_batch};
292pub use arrow;
293pub use arrow_array;
294pub use arrow_schema;
295pub use common::build_offsets;
296pub use err::{ReadStatCError, ReadStatError};
297pub use progress::ProgressCallback;
298pub use rs_data::ReadStatData;
299pub use rs_metadata::{ReadStatCompress, ReadStatEndian, ReadStatMetadata, ReadStatVarMetadata};
300pub use rs_path::ReadStatPath;
301#[cfg(feature = "sql")]
302#[cfg_attr(docsrs, doc(cfg(feature = "sql")))]
303pub use rs_query::{
304    AsyncRecordBatchReceiver, AsyncRecordBatchSender, RecordBatchReceiver, RecordBatchSender,
305    async_record_batch_channel, execute_sql, execute_sql_and_write_stream,
306    execute_sql_and_write_stream_async, execute_sql_async, execute_sql_stream,
307    execute_sql_stream_async, read_sql_file, record_batch_channel,
308};
309pub use rs_var::{ReadStatVarFormatClass, ReadStatVarType, ReadStatVarTypeClass};
310#[cfg(feature = "parquet")]
311pub use rs_write::ParallelParquetWriter;
312#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
313pub use rs_write::ParallelTextWriter;
314pub use rs_write::ReadStatWriter;
315#[cfg(feature = "csv")]
316#[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
317pub use rs_write::write_batch_to_csv_bytes;
318#[cfg(feature = "feather")]
319#[cfg_attr(docsrs, doc(cfg(feature = "feather")))]
320pub use rs_write::write_batch_to_feather_bytes;
321#[cfg(feature = "ndjson")]
322#[cfg_attr(docsrs, doc(cfg(feature = "ndjson")))]
323pub use rs_write::write_batch_to_ndjson_bytes;
324#[cfg(feature = "parquet")]
325#[cfg_attr(docsrs, doc(cfg(feature = "parquet")))]
326pub use rs_write::write_batch_to_parquet_bytes;
327pub use rs_write_config::{OutFormat, ParquetCompression, WriteConfig};
328
329mod api;
330mod cb;
331mod common;
332mod err;
333mod formats;
334mod progress;
335mod rs_buffer_io;
336mod rs_data;
337mod rs_metadata;
338mod rs_parser;
339mod rs_path;
340#[cfg(feature = "sql")]
341mod rs_query;
342mod rs_var;
343mod rs_write;
344mod rs_write_config;