blob: cbdcbc0134c0a53b98b35e162451521378a9d4e3 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 crate::TOKIO_RUNTIME;
use crate::*;
use arrow::array::RecordBatch as ArrowRecordBatch;
use arrow::record_batch::RecordBatchReader as _;
use arrow_pyarrow::{FromPyArrow, ToPyArrow};
use arrow_schema::SchemaRef;
use fcore::client::LimitBatchScanner;
use fcore::metadata::{DataField, DataType, MapType, RowType};
use fcore::row::binary_array::{FlussArray, FlussArrayWriter};
use fcore::row::binary_map::{FlussMap, FlussMapWriter};
use fcore::row::{Datum, F32, F64, GenericRow, InternalRow};
use fluss::record::to_arrow_schema;
use indexmap::IndexMap;
use pyo3::IntoPyObjectExt;
use pyo3::exceptions::{PyIndexError, PyRuntimeError, PyTypeError};
use pyo3::sync::PyOnceLock;
use pyo3::types::{
IntoPyDict, PyBool, PyByteArray, PyBytes, PyDate, PyDateAccess, PyDateTime, PyDelta,
PyDeltaAccess, PyDict, PyList, PySequence, PySlice, PyString, PyTime, PyTimeAccess, PyTuple,
PyType, PyTzInfo,
};
use pyo3_async_runtimes::tokio::future_into_py;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
// Time conversion constants
const MILLIS_PER_SECOND: i64 = 1_000;
const MILLIS_PER_MINUTE: i64 = 60_000;
const MILLIS_PER_HOUR: i64 = 3_600_000;
const MICROS_PER_MILLI: i64 = 1_000;
const MICROS_PER_SECOND: i64 = 1_000_000;
const MICROS_PER_DAY: i64 = 86_400_000_000;
const NANOS_PER_MILLI: i64 = 1_000_000;
const NANOS_PER_MICRO: i64 = 1_000;
const DEFAULT_POLL_INTERVAL_MS: i64 = 1000;
/// Represents a single scan record with metadata.
///
/// Matches Rust/Java: offset, timestamp, change_type, row.
/// The bucket is the key in ScanRecords, not on the individual record.
#[pyclass]
pub struct ScanRecord {
#[pyo3(get)]
offset: i64,
#[pyo3(get)]
timestamp: i64,
#[pyo3(get)]
change_type: ChangeType,
/// Store row as a Python dict directly
row_dict: Py<PyDict>,
}
#[pymethods]
impl ScanRecord {
/// Get the row data as a dictionary
#[getter]
pub fn row(&self, py: Python) -> Py<PyDict> {
self.row_dict.clone_ref(py)
}
fn __str__(&self) -> String {
format!(
"ScanRecord(offset={}, timestamp={}, change_type={})",
self.offset,
self.timestamp,
self.change_type.short_string()
)
}
fn __repr__(&self) -> String {
self.__str__()
}
}
impl ScanRecord {
/// Create a ScanRecord from core types
pub fn from_core(
py: Python,
record: &fcore::record::ScanRecord,
row_type: &fcore::metadata::RowType,
) -> PyResult<Self> {
let fields = row_type.fields();
let row = record.row();
let dict = PyDict::new(py);
for (pos, field) in fields.iter().enumerate() {
let value = datum_to_python_value(py, row, pos, field.data_type())?;
dict.set_item(field.name(), value)?;
}
Ok(ScanRecord {
offset: record.offset(),
timestamp: record.timestamp(),
change_type: ChangeType::from_core(*record.change_type()),
row_dict: dict.unbind(),
})
}
}
/// Represents a batch of records with metadata
#[pyclass]
pub struct RecordBatch {
batch: Arc<ArrowRecordBatch>,
#[pyo3(get)]
bucket: TableBucket,
#[pyo3(get)]
base_offset: i64,
#[pyo3(get)]
last_offset: i64,
}
#[pymethods]
impl RecordBatch {
/// Get the Arrow RecordBatch as PyArrow RecordBatch
#[getter]
pub fn batch(&self, py: Python) -> PyResult<Py<PyAny>> {
let pyarrow_batch = self
.batch
.as_ref()
.to_pyarrow(py)
.map_err(|e| FlussError::new_err(format!("Failed to convert batch: {e}")))?;
Ok(pyarrow_batch.unbind())
}
fn __str__(&self) -> String {
format!(
"RecordBatch(bucket={}, base_offset={}, last_offset={}, rows={})",
self.bucket.__str__(),
self.base_offset,
self.last_offset,
self.batch.num_rows()
)
}
fn __repr__(&self) -> String {
self.__str__()
}
}
impl RecordBatch {
/// Create a RecordBatch from core ScanBatch
pub fn from_scan_batch(scan_batch: fcore::record::ScanBatch) -> Self {
RecordBatch {
bucket: TableBucket::from_core(scan_batch.bucket().clone()),
base_offset: scan_batch.base_offset(),
last_offset: scan_batch.last_offset(),
batch: Arc::new(scan_batch.into_batch()),
}
}
}
/// A collection of scan records grouped by bucket.
///
/// Returned by `LogScanner.poll()`. Records are grouped by `TableBucket`.
#[pyclass]
pub struct ScanRecords {
records_by_bucket: IndexMap<TableBucket, Vec<Py<ScanRecord>>>,
total_count: usize,
}
#[pymethods]
impl ScanRecords {
/// List of distinct buckets that have records in this result.
pub fn buckets(&self) -> Vec<TableBucket> {
self.records_by_bucket.keys().cloned().collect()
}
/// Get records for a specific bucket.
///
/// Returns an empty list if the bucket is not present (matches Rust/Java behavior).
pub fn records(&self, py: Python, bucket: &TableBucket) -> Vec<Py<ScanRecord>> {
self.records_by_bucket
.get(bucket)
.map(|recs| recs.iter().map(|r| r.clone_ref(py)).collect())
.unwrap_or_default()
}
/// Total number of records across all buckets.
pub fn count(&self) -> usize {
self.total_count
}
/// Whether the result set is empty.
pub fn is_empty(&self) -> bool {
self.total_count == 0
}
fn __len__(&self) -> usize {
self.total_count
}
/// Type-dispatched indexing:
/// records[0] → ScanRecord (flat index)
/// records[-1] → ScanRecord (negative index)
/// records[1:3] → list[ScanRecord] (slice)
/// records[bucket] → list[ScanRecord] (by bucket)
fn __getitem__(&self, py: Python, key: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
// Try integer index first
if let Ok(mut idx) = key.extract::<isize>() {
let len = self.total_count as isize;
if idx < 0 {
idx += len;
}
if idx < 0 || idx >= len {
return Err(PyIndexError::new_err(format!(
"index {idx} out of range for ScanRecords of size {len}"
)));
}
let idx = idx as usize;
let mut offset = 0;
for recs in self.records_by_bucket.values() {
if idx < offset + recs.len() {
return Ok(recs[idx - offset].clone_ref(py).into_any());
}
offset += recs.len();
}
return Err(PyRuntimeError::new_err(
"internal error: total_count out of sync with records",
));
}
// Try slice
if let Ok(slice) = key.downcast::<PySlice>() {
let indices = slice.indices(self.total_count as isize)?;
let mut result: Vec<Py<ScanRecord>> = Vec::new();
let mut i = indices.start;
while (indices.step > 0 && i < indices.stop) || (indices.step < 0 && i > indices.stop) {
let idx = i as usize;
let mut offset = 0;
for recs in self.records_by_bucket.values() {
if idx < offset + recs.len() {
result.push(recs[idx - offset].clone_ref(py));
break;
}
offset += recs.len();
}
i += indices.step;
}
return Ok(result.into_pyobject(py).unwrap().into_any().unbind());
}
// Try TableBucket
if let Ok(bucket) = key.extract::<TableBucket>() {
let recs = self.records(py, &bucket);
return Ok(recs.into_pyobject(py).unwrap().into_any().unbind());
}
Err(PyTypeError::new_err(
"index must be int, slice, or TableBucket",
))
}
/// Support `bucket in records`.
fn __contains__(&self, bucket: &TableBucket) -> bool {
self.records_by_bucket.contains_key(bucket)
}
/// Mapping protocol: alias for `buckets()`.
pub fn keys(&self) -> Vec<TableBucket> {
self.buckets()
}
/// Mapping protocol: lazy iterator over record lists, one per bucket.
pub fn values(slf: Bound<'_, Self>) -> ScanRecordsBucketIter {
let this = slf.borrow();
let bucket_keys: Vec<TableBucket> = this.records_by_bucket.keys().cloned().collect();
drop(this);
ScanRecordsBucketIter {
owner: slf.unbind(),
bucket_keys,
bucket_idx: 0,
with_keys: false,
}
}
/// Mapping protocol: lazy iterator over `(TableBucket, list[ScanRecord])` pairs.
pub fn items(slf: Bound<'_, Self>) -> ScanRecordsBucketIter {
let this = slf.borrow();
let bucket_keys: Vec<TableBucket> = this.records_by_bucket.keys().cloned().collect();
drop(this);
ScanRecordsBucketIter {
owner: slf.unbind(),
bucket_keys,
bucket_idx: 0,
with_keys: true,
}
}
fn __str__(&self) -> String {
format!(
"ScanRecords(records={}, buckets={})",
self.total_count,
self.records_by_bucket.len()
)
}
fn __repr__(&self) -> String {
self.__str__()
}
/// Flat iterator over all records across all buckets (matches Java/Rust).
fn __iter__(slf: Bound<'_, Self>) -> ScanRecordsIter {
let this = slf.borrow();
let bucket_keys: Vec<TableBucket> = this.records_by_bucket.keys().cloned().collect();
drop(this);
ScanRecordsIter {
owner: slf.unbind(),
bucket_keys,
bucket_idx: 0,
rec_idx: 0,
}
}
}
#[pyclass]
struct ScanRecordsIter {
owner: Py<ScanRecords>,
bucket_keys: Vec<TableBucket>,
bucket_idx: usize,
rec_idx: usize,
}
#[pymethods]
impl ScanRecordsIter {
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}
fn __next__(&mut self, py: Python) -> Option<Py<ScanRecord>> {
let owner = self.owner.borrow(py);
loop {
if self.bucket_idx >= self.bucket_keys.len() {
return None;
}
let bucket = &self.bucket_keys[self.bucket_idx];
if let Some(recs) = owner.records_by_bucket.get(bucket) {
if self.rec_idx < recs.len() {
let rec = recs[self.rec_idx].clone_ref(py);
self.rec_idx += 1;
return Some(rec);
}
}
self.bucket_idx += 1;
self.rec_idx = 0;
}
}
}
/// Lazy iterator for `ScanRecords.items()` and `ScanRecords.values()`.
///
/// Yields one bucket at a time: `(TableBucket, list[ScanRecord])` for items,
/// or `list[ScanRecord]` for values. Only materializes records for the
/// current bucket on each `__next__` call.
#[pyclass]
pub struct ScanRecordsBucketIter {
owner: Py<ScanRecords>,
bucket_keys: Vec<TableBucket>,
bucket_idx: usize,
with_keys: bool,
}
#[pymethods]
impl ScanRecordsBucketIter {
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}
fn __next__(&mut self, py: Python) -> Option<Py<PyAny>> {
if self.bucket_idx >= self.bucket_keys.len() {
return None;
}
let bucket = &self.bucket_keys[self.bucket_idx];
let owner = self.owner.borrow(py);
let recs = owner
.records_by_bucket
.get(bucket)
.map(|recs| recs.iter().map(|r| r.clone_ref(py)).collect::<Vec<_>>())
.unwrap_or_default();
let bucket = bucket.clone();
self.bucket_idx += 1;
if self.with_keys {
Some(
(bucket, recs)
.into_pyobject(py)
.unwrap()
.into_any()
.unbind(),
)
} else {
Some(recs.into_pyobject(py).unwrap().into_any().unbind())
}
}
}
/// Represents a Fluss table for data operations
#[pyclass]
pub struct FlussTable {
connection: Arc<fcore::client::FlussConnection>,
metadata: Arc<fcore::client::Metadata>,
table_info: fcore::metadata::TableInfo,
table_path: fcore::metadata::TablePath,
has_primary_key: bool,
}
/// Builder for creating log scanners with flexible configuration.
///
/// Use this builder to configure projection, and in the future, filters
/// before creating a log scanner.
#[pyclass]
pub struct TableScan {
connection: Arc<fcore::client::FlussConnection>,
metadata: Arc<fcore::client::Metadata>,
table_info: fcore::metadata::TableInfo,
projection: Option<ProjectionType>,
limit: Option<i32>,
}
/// Scanner type for internal use
enum ScannerType {
Record,
Batch,
}
#[pymethods]
impl TableScan {
/// Project to specific columns by their indices.
///
/// Args:
/// indices: List of column indices (0-based) to include in the scan.
///
/// Returns:
/// Self for method chaining.
pub fn project(mut slf: PyRefMut<'_, Self>, indices: Vec<usize>) -> PyRefMut<'_, Self> {
slf.projection = Some(ProjectionType::Indices(indices));
slf
}
/// Project to specific columns by their names.
///
/// Args:
/// names: List of column names to include in the scan.
///
/// Returns:
/// Self for method chaining.
pub fn project_by_name(mut slf: PyRefMut<'_, Self>, names: Vec<String>) -> PyRefMut<'_, Self> {
slf.projection = Some(ProjectionType::Names(names));
slf
}
/// Set a positive row limit, enabling `create_bucket_batch_scanner()`.
///
/// Args:
/// n: Maximum number of rows to scan. Must be positive.
///
/// Returns:
/// Self for method chaining.
pub fn limit(mut slf: PyRefMut<'_, Self>, n: i32) -> PyResult<PyRefMut<'_, Self>> {
if n <= 0 {
return Err(FlussError::new_err(format!(
"Scan limit must be positive, got {n}"
)));
}
slf.limit = Some(n);
Ok(slf)
}
/// Create a one-shot bounded scanner over a single bucket.
///
/// Requires a limit set via `limit()`; the scan runs on the first
/// `next_batch()`.
///
/// Args:
/// bucket: Bucket to scan; must belong to this table.
///
/// Returns:
/// A BatchScanner for `bucket`.
pub fn create_bucket_batch_scanner(&self, bucket: &TableBucket) -> PyResult<BatchScanner> {
let limit = self.limit.ok_or_else(|| {
FlussError::new_err("create_bucket_batch_scanner requires a limit set via .limit(n)")
})?;
let conn = self.connection.clone();
let _guard = TOKIO_RUNTIME.enter();
let table =
fcore::client::FlussTable::new(&conn, self.metadata.clone(), self.table_info.clone());
let projection = self.projection.clone();
let projection_indices = resolve_projection_indices(&projection, &self.table_info)?;
let scan = apply_projection(table.new_scan(), projection)?
.limit(limit)
.map_err(|e| FlussError::from_core_error(&e))?;
let scanner = scan
.create_bucket_batch_scanner(bucket.to_core())
.map_err(|e| FlussError::from_core_error(&e))?;
let (projected_schema, _) =
calculate_projected_types(&self.table_info, projection_indices)?;
Ok(BatchScanner::new(scanner, bucket.clone(), projected_schema))
}
/// Create a record-based log scanner.
///
/// Use this scanner with `poll()` to get individual records with metadata
/// (offset, timestamp, change_type).
///
/// Returns:
/// LogScanner for record-by-record scanning with `poll()`
pub fn create_log_scanner<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.create_scanner_internal(py, ScannerType::Record)
}
/// Create a batch-based log scanner.
///
/// Use this scanner with `poll_arrow()` to get Arrow Tables, or with
/// `poll_record_batch()` to get individual batches with metadata.
///
/// Returns:
/// LogScanner for batch-based scanning with `poll_arrow()` or `poll_record_batch()`
pub fn create_record_batch_log_scanner<'py>(
&self,
py: Python<'py>,
) -> PyResult<Bound<'py, PyAny>> {
self.create_scanner_internal(py, ScannerType::Batch)
}
fn __repr__(&self) -> String {
format!(
"TableScan(table={}.{})",
self.table_info.table_path.database(),
self.table_info.table_path.table()
)
}
}
impl TableScan {
fn create_scanner_internal<'py>(
&self,
py: Python<'py>,
scanner_type: ScannerType,
) -> PyResult<Bound<'py, PyAny>> {
if let Some(limit) = self.limit {
return Err(FlussError::new_err(format!(
"Log scanners don't support limit pushdown (requested limit: {limit}). \
Use create_bucket_batch_scanner() for a bounded scan."
)));
}
let conn = self.connection.clone();
let metadata = self.metadata.clone();
let table_info = self.table_info.clone();
let projection = self.projection.clone();
future_into_py(py, async move {
let fluss_table = fcore::client::FlussTable::new(&conn, metadata, table_info.clone());
let projection_indices = resolve_projection_indices(&projection, &table_info)?;
let table_scan = apply_projection(fluss_table.new_scan(), projection)?;
let admin = conn
.get_admin()
.map_err(|e| FlussError::from_core_error(&e))?;
let (projected_schema, projected_row_type) =
calculate_projected_types(&table_info, projection_indices)?;
let scanner_kind = match scanner_type {
ScannerType::Record => {
let s = table_scan
.create_log_scanner()
.map_err(|e| FlussError::from_core_error(&e))?;
ScannerKind::Record(s)
}
ScannerType::Batch => {
let s = table_scan
.create_record_batch_log_scanner()
.map_err(|e| FlussError::from_core_error(&e))?;
ScannerKind::Batch(s)
}
};
let py_scanner = LogScanner::new(
scanner_kind,
admin,
table_info,
projected_schema,
Arc::new(projected_row_type),
);
Python::attach(|py| Py::new(py, py_scanner))
})
}
}
/// Internal enum to represent different projection types
#[derive(Clone)]
enum ProjectionType {
Indices(Vec<usize>),
Names(Vec<String>),
}
/// Resolve projection to column indices
fn resolve_projection_indices(
projection: &Option<ProjectionType>,
table_info: &fcore::metadata::TableInfo,
) -> PyResult<Option<Vec<usize>>> {
match projection {
Some(ProjectionType::Indices(indices)) => Ok(Some(indices.clone())),
Some(ProjectionType::Names(names)) => {
let schema = table_info.get_schema();
let columns = schema.columns();
let mut indices = Vec::with_capacity(names.len());
for name in names {
let idx = columns
.iter()
.position(|c| c.name() == name)
.ok_or_else(|| FlussError::new_err(format!("Column '{name}' not found")))?;
indices.push(idx);
}
Ok(Some(indices))
}
None => Ok(None),
}
}
/// Apply projection to table scan
fn apply_projection(
table_scan: fcore::client::TableScan,
projection: Option<ProjectionType>,
) -> PyResult<fcore::client::TableScan> {
match projection {
Some(ProjectionType::Indices(indices)) => table_scan
.project(&indices)
.map_err(|e| FlussError::from_core_error(&e)),
Some(ProjectionType::Names(names)) => {
let column_name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
table_scan
.project_by_name(&column_name_refs)
.map_err(|e| FlussError::from_core_error(&e))
}
None => Ok(table_scan),
}
}
/// Calculate projected schema and row type from projection indices
fn calculate_projected_types(
table_info: &fcore::metadata::TableInfo,
projection_indices: Option<Vec<usize>>,
) -> PyResult<(SchemaRef, fcore::metadata::RowType)> {
let full_schema =
to_arrow_schema(table_info.get_row_type()).map_err(|e| FlussError::from_core_error(&e))?;
let full_row_type = table_info.get_row_type();
match projection_indices {
Some(indices) => {
let arrow_fields: Vec<_> = indices
.iter()
.map(|&i| full_schema.field(i).clone())
.collect();
let row_fields: Vec<_> = indices
.iter()
.map(|&i| full_row_type.fields()[i].clone())
.collect();
Ok((
Arc::new(arrow_schema::Schema::new(arrow_fields)),
fcore::metadata::RowType::new(row_fields),
))
}
None => Ok((full_schema, full_row_type.clone())),
}
}
#[pymethods]
impl FlussTable {
/// Create a new table scan builder for configuring and creating log scanners.
///
/// Use this method to create scanners with the builder pattern:
/// Returns:
/// TableScan builder for configuring the scanner.
pub fn new_scan(&self) -> TableScan {
TableScan {
connection: self.connection.clone(),
metadata: self.metadata.clone(),
table_info: self.table_info.clone(),
projection: None,
limit: None,
}
}
/// Create a new TableAppend builder for the table.
///
/// Returns:
/// TableAppend builder. Call `create_writer()` to get an AppendWriter.
fn new_append(&self) -> PyResult<TableAppend> {
let _guard = TOKIO_RUNTIME.enter();
let fluss_table = fcore::client::FlussTable::new(
&self.connection,
self.metadata.clone(),
self.table_info.clone(),
);
let table_append = fluss_table
.new_append()
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(TableAppend {
inner: table_append,
table_info: self.table_info.clone(),
})
}
/// Get table information
pub fn get_table_info(&self) -> TableInfo {
TableInfo::from_core(self.table_info.clone())
}
/// Get table path
pub fn get_table_path(&self) -> TablePath {
TablePath::from_core(self.table_path.clone())
}
/// Check if table has primary key
pub fn has_primary_key(&self) -> bool {
self.has_primary_key
}
/// Create a new TableLookup builder for primary key lookups.
///
/// This is only available for tables with a primary key.
///
/// Returns:
/// TableLookup builder. Call `create_lookuper()` to get a Lookuper.
pub fn new_lookup(&self) -> PyResult<TableLookup> {
if !self.has_primary_key {
return Err(FlussError::new_err(
"Lookup is only supported for primary key tables",
));
}
Ok(TableLookup {
connection: self.connection.clone(),
metadata: self.metadata.clone(),
table_info: self.table_info.clone(),
})
}
/// Create a new TableUpsert builder for the table.
///
/// This is only available for tables with a primary key.
///
/// Returns:
/// TableUpsert builder. Call `create_writer()` to get an UpsertWriter,
/// or use `partial_update_by_name()` / `partial_update_by_index()` first.
pub fn new_upsert(&self) -> PyResult<TableUpsert> {
if !self.has_primary_key {
return Err(FlussError::new_err(
"Upsert is only supported for primary key tables",
));
}
let _guard = TOKIO_RUNTIME.enter();
let fluss_table = fcore::client::FlussTable::new(
&self.connection,
self.metadata.clone(),
self.table_info.clone(),
);
let table_upsert = fluss_table
.new_upsert()
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(TableUpsert {
inner: table_upsert,
table_info: self.table_info.clone(),
target_columns: None,
})
}
fn __repr__(&self) -> String {
format!(
"FlussTable(path={}.{})",
self.table_path.database(),
self.table_path.table()
)
}
}
impl FlussTable {
/// Create a FlussTable
pub fn new_table(
connection: Arc<fcore::client::FlussConnection>,
metadata: Arc<fcore::client::Metadata>,
table_info: fcore::metadata::TableInfo,
table_path: fcore::metadata::TablePath,
has_primary_key: bool,
) -> Self {
Self {
connection,
metadata,
table_info,
table_path,
has_primary_key,
}
}
}
/// Builder for creating an AppendWriter.
///
/// Obtain via `FlussTable.new_append()`, then call `create_writer()`.
#[pyclass]
pub struct TableAppend {
inner: fcore::client::TableAppend,
table_info: fcore::metadata::TableInfo,
}
#[pymethods]
impl TableAppend {
/// Create an AppendWriter from this builder.
pub fn create_writer(&self) -> PyResult<AppendWriter> {
let rust_writer = self
.inner
.create_writer()
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(AppendWriter::from_core(
rust_writer,
self.table_info.clone(),
))
}
fn __repr__(&self) -> String {
"TableAppend()".to_string()
}
}
/// Builder for creating an UpsertWriter, with optional partial update configuration.
///
/// Obtain via `FlussTable.new_upsert()`, then optionally call
/// `partial_update_by_name()` or `partial_update_by_index()`,
/// then call `create_writer()`.
#[pyclass]
pub struct TableUpsert {
inner: fcore::client::TableUpsert,
table_info: fcore::metadata::TableInfo,
/// Column indices for partial updates, tracked for Python's dict→GenericRow conversion.
target_columns: Option<Vec<usize>>,
}
#[pymethods]
impl TableUpsert {
/// Configure partial update by column names.
///
/// Only the specified columns will be updated on upsert.
///
/// Args:
/// columns: List of column names to update.
///
/// Returns:
/// A new TableUpsert configured for partial update.
pub fn partial_update_by_name(&self, columns: Vec<String>) -> PyResult<TableUpsert> {
let col_refs: Vec<&str> = columns.iter().map(|s| s.as_str()).collect();
// Core validates and resolves names → indices internally
let updated = self
.inner
.partial_update_with_column_names(&col_refs)
.map_err(|e| FlussError::from_core_error(&e))?;
// Resolve indices for Python's row conversion layer (core validated names above)
let row_type = self.table_info.row_type();
let indices: Vec<usize> = columns
.iter()
.map(|name| {
row_type.get_field_index(name).ok_or_else(|| {
FlussError::new_err(format!("Unknown column name '{name}' for partial update"))
})
})
.collect::<PyResult<Vec<usize>>>()?;
Ok(TableUpsert {
inner: updated,
table_info: self.table_info.clone(),
target_columns: Some(indices),
})
}
/// Configure partial update by column indices.
///
/// Only the specified columns will be updated on upsert.
///
/// Args:
/// column_indices: List of column indices (0-based) to update.
///
/// Returns:
/// A new TableUpsert configured for partial update.
pub fn partial_update_by_index(&self, column_indices: Vec<usize>) -> PyResult<TableUpsert> {
let target = column_indices.clone();
// Core validates indices internally
let updated = self
.inner
.partial_update(Some(column_indices))
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(TableUpsert {
inner: updated,
table_info: self.table_info.clone(),
target_columns: Some(target),
})
}
/// Create an UpsertWriter from this builder.
pub fn create_writer(&self) -> PyResult<crate::UpsertWriter> {
crate::UpsertWriter::new(
&self.inner,
self.table_info.clone(),
self.target_columns.clone(),
)
}
fn __repr__(&self) -> String {
"TableUpsert()".to_string()
}
}
/// Builder for creating a Lookuper.
///
/// Obtain via `FlussTable.new_lookup()`, then call `create_lookuper()`.
#[pyclass]
pub struct TableLookup {
connection: Arc<fcore::client::FlussConnection>,
metadata: Arc<fcore::client::Metadata>,
table_info: fcore::metadata::TableInfo,
}
#[pymethods]
impl TableLookup {
/// Create a Lookuper from this builder.
pub fn create_lookuper(&self) -> PyResult<crate::Lookuper> {
crate::Lookuper::new(
&self.connection,
self.metadata.clone(),
self.table_info.clone(),
)
}
/// Switch to prefix-scan mode for the given lookup columns.
///
/// The columns must be the table's partition keys (if any) plus the
/// bucket keys, in that order.
///
/// Args:
/// column_names: List of column names forming the prefix key.
///
/// Returns:
/// TablePrefixLookup builder. Call `create_lookuper()` to get a PrefixLookuper.
pub fn lookup_by(&self, column_names: Vec<String>) -> TablePrefixLookup {
TablePrefixLookup {
connection: self.connection.clone(),
metadata: self.metadata.clone(),
table_info: self.table_info.clone(),
lookup_column_names: column_names,
}
}
fn __repr__(&self) -> String {
"TableLookup()".to_string()
}
}
/// Builder for creating a PrefixLookuper.
///
/// Obtain via `TableLookup.lookup_by(columns)`, then call `create_lookuper()`.
#[pyclass]
pub struct TablePrefixLookup {
connection: Arc<fcore::client::FlussConnection>,
metadata: Arc<fcore::client::Metadata>,
table_info: fcore::metadata::TableInfo,
lookup_column_names: Vec<String>,
}
#[pymethods]
impl TablePrefixLookup {
/// Create a PrefixLookuper from this builder.
pub fn create_lookuper(&self) -> PyResult<crate::PrefixLookuper> {
crate::PrefixLookuper::new(
&self.connection,
self.metadata.clone(),
self.table_info.clone(),
self.lookup_column_names.clone(),
)
}
fn __repr__(&self) -> String {
"TablePrefixLookup()".to_string()
}
}
/// Writer for appending data to a Fluss table
#[pyclass]
pub struct AppendWriter {
inner: Arc<fcore::client::AppendWriter>,
table_info: fcore::metadata::TableInfo,
}
#[pymethods]
impl AppendWriter {
/// Write Arrow table data (fire-and-forget, use flush() to ensure delivery)
pub fn write_arrow(&self, py: Python, table: Py<PyAny>) -> PyResult<()> {
// Convert Arrow Table to batches and write each batch
let batches = table.call_method0(py, "to_batches")?;
let batch_list: Vec<Py<PyAny>> = batches.extract(py)?;
for batch in batch_list {
// Drop the handle — fire-and-forget for bulk writes
drop(self.write_arrow_batch(py, batch)?);
}
Ok(())
}
/// Write Arrow batch data.
///
/// Returns:
/// WriteResultHandle that can be ignored (fire-and-forget) or
/// awaited via `handle.wait()` for server acknowledgment.
pub fn write_arrow_batch(&self, py: Python, batch: Py<PyAny>) -> PyResult<WriteResultHandle> {
// This shares the underlying Arrow buffers without copying data
let batch_bound = batch.bind(py);
let rust_batch: ArrowRecordBatch = FromPyArrow::from_pyarrow_bound(batch_bound)
.map_err(|e| FlussError::new_err(format!("Failed to convert RecordBatch: {e}")))?;
let result_future = self
.inner
.append_arrow_batch(rust_batch)
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(WriteResultHandle::new(result_future))
}
/// Append a single row to the table.
///
/// Returns:
/// WriteResultHandle that can be ignored (fire-and-forget) or
/// awaited via `handle.wait()` for server acknowledgment.
pub fn append(&self, row: &Bound<'_, PyAny>) -> PyResult<WriteResultHandle> {
let generic_row = python_to_generic_row(row, &self.table_info)?;
let result_future = self
.inner
.append(&generic_row)
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(WriteResultHandle::new(result_future))
}
/// Write Pandas DataFrame data
pub fn write_pandas(&self, py: Python, df: Py<PyAny>) -> PyResult<()> {
// Get the expected Arrow schema from the Fluss table
let row_type = self.table_info.get_row_type();
let expected_schema = fcore::record::to_arrow_schema(row_type)
.map_err(|e| FlussError::from_core_error(&e))?;
// Convert Arrow schema to PyArrow schema
let py_schema = expected_schema
.as_ref()
.to_pyarrow(py)
.map_err(|e| FlussError::new_err(format!("Failed to convert schema: {e}")))?;
// Import pyarrow module
let pyarrow = py.import("pyarrow")?;
// Get the Table class from pyarrow module
let table_class = pyarrow.getattr("Table")?;
// Call Table.from_pandas(df, schema=expected_schema) to ensure proper type casting
let pa_table = table_class.call_method(
"from_pandas",
(df,),
Some(&[("schema", py_schema)].into_py_dict(py)?),
)?;
// Then call write_arrow with the converted table
self.write_arrow(py, pa_table.into())
}
/// Flush any pending data
pub fn flush<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let inner = self.inner.clone();
future_into_py(py, async move {
inner
.flush()
.await
.map_err(|e| FlussError::from_core_error(&e))
})
}
// Enter the async runtime context (for 'async with' statement)
fn __aenter__<'py>(slf: PyRef<'py, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let py_slf = slf.into_pyobject(py)?.unbind();
future_into_py(py, async move { Ok(py_slf) })
}
// Exit the async runtime context (for 'async with' statement)
/// On exit, the writer is automatically flushed.
#[pyo3(signature = (exc_type=None, _exc_value=None, _traceback=None))]
fn __aexit__<'py>(
&self,
py: Python<'py>,
exc_type: Option<Bound<'py, PyAny>>,
_exc_value: Option<Bound<'py, PyAny>>,
_traceback: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
let inner = self.inner.clone();
let is_exc_none = exc_type.as_ref().is_none_or(|e| e.is_none());
future_into_py(py, async move {
let res = inner.flush().await;
if let Err(e) = res {
if is_exc_none {
return Err(FlussError::from_core_error(&e));
}
}
Ok(false)
})
}
fn __repr__(&self) -> String {
"AppendWriter()".to_string()
}
}
impl AppendWriter {
/// Create a AppendWriter from a core append writer
pub fn from_core(
append: fcore::client::AppendWriter,
table_info: fcore::metadata::TableInfo,
) -> Self {
Self {
inner: Arc::new(append),
table_info,
}
}
}
/// Represents different input shapes for a row
#[derive(FromPyObject)]
enum RowInput<'py> {
Dict(Bound<'py, PyDict>),
Tuple(Bound<'py, PyTuple>),
List(Bound<'py, PyList>),
}
/// Convert Python row (dict/list/tuple) to GenericRow requiring all schema columns.
pub fn python_to_generic_row(
row: &Bound<PyAny>,
table_info: &fcore::metadata::TableInfo,
) -> PyResult<fcore::row::GenericRow<'static>> {
let all_indices: Vec<usize> = (0..table_info.row_type().fields().len()).collect();
python_to_sparse_generic_row(row, table_info, &all_indices)
}
/// Process a Python sequence (list or tuple) into datums at the target column positions.
fn process_sequence(
seq: &Bound<PySequence>,
target_indices: &[usize],
fields: &[fcore::metadata::DataField],
datums: &mut [fcore::row::Datum<'static>],
sparse: bool,
) -> PyResult<()> {
if seq.len()? != target_indices.len() {
return Err(FlussError::new_err(format!(
"Expected {} elements, got {}",
target_indices.len(),
seq.len()?
)));
}
for (i, &col_idx) in target_indices.iter().enumerate() {
let field = &fields[col_idx];
let value = seq.get_item(i)?;
let dest = if sparse { col_idx } else { i };
datums[dest] = python_value_to_datum(&value, field.data_type())
.map_err(|e| FlussError::new_err(format!("Field '{}': {}", field.name(), e)))?;
}
Ok(())
}
/// Build a full-width GenericRow filling only the specified column
/// indices from user input; all other columns are set to Null.
pub fn python_to_sparse_generic_row(
row: &Bound<PyAny>,
table_info: &fcore::metadata::TableInfo,
target_indices: &[usize],
) -> PyResult<fcore::row::GenericRow<'static>> {
python_to_generic_row_inner(row, table_info, target_indices, true)
}
/// Build a dense GenericRow with exactly `target_indices.len()` fields,
/// containing only the target column values in order.
pub fn python_to_dense_generic_row(
row: &Bound<PyAny>,
table_info: &fcore::metadata::TableInfo,
target_indices: &[usize],
) -> PyResult<fcore::row::GenericRow<'static>> {
python_to_generic_row_inner(row, table_info, target_indices, false)
}
/// Build a GenericRow from user input. When `sparse` is true, the row is full width and padded with nulls
fn python_to_generic_row_inner(
row: &Bound<PyAny>,
table_info: &fcore::metadata::TableInfo,
target_indices: &[usize],
sparse: bool,
) -> PyResult<fcore::row::GenericRow<'static>> {
let row_type = table_info.row_type();
let fields = row_type.fields();
let target_names: Vec<&str> = target_indices.iter().map(|&i| fields[i].name()).collect();
let num_fields = if sparse {
fields.len()
} else {
target_indices.len()
};
let mut datums: Vec<fcore::row::Datum<'static>> = vec![fcore::row::Datum::Null; num_fields];
let row_input: RowInput = row.extract().map_err(|_| {
let type_name = row
.get_type()
.name()
.map(|n| n.to_string())
.unwrap_or_else(|_| "unknown".to_string());
FlussError::new_err(format!(
"Row must be a dict, list, or tuple; got {type_name}"
))
})?;
match row_input {
RowInput::Dict(dict) => {
for (k, _) in dict.iter() {
let key_str = k.extract::<&str>().map_err(|_| {
let key_type = k
.get_type()
.name()
.map(|n| n.to_string())
.unwrap_or_else(|_| "unknown".to_string());
FlussError::new_err(format!("Dict keys must be strings; got {key_type}"))
})?;
if !target_names.contains(&key_str) {
return Err(FlussError::new_err(format!(
"Unknown field '{}'. Expected: {}",
key_str,
target_names.join(", ")
)));
}
}
for (i, &col_idx) in target_indices.iter().enumerate() {
let name = target_names[i];
let field = &fields[col_idx];
let dest = if sparse { col_idx } else { i };
match dict.get_item(name)? {
Some(value) => {
datums[dest] = python_value_to_datum(&value, field.data_type())
.map_err(|e| FlussError::new_err(format!("Field '{name}': {e}")))?;
}
None if field.data_type().is_nullable() => {}
None => {
return Err(FlussError::new_err(format!(
"Missing value for non-nullable field '{name}'"
)));
}
}
}
}
RowInput::List(list) => {
process_sequence(
list.as_sequence(),
target_indices,
fields,
&mut datums,
sparse,
)?;
}
RowInput::Tuple(tuple) => {
process_sequence(
tuple.as_sequence(),
target_indices,
fields,
&mut datums,
sparse,
)?;
}
}
Ok(fcore::row::GenericRow { values: datums })
}
/// Convert Python value to Datum based on data type
fn python_value_to_datum(value: &Bound<PyAny>, data_type: &DataType) -> PyResult<Datum<'static>> {
if value.is_none() {
return Ok(Datum::Null);
}
match data_type {
DataType::Boolean(_) => {
let v: bool = value.extract()?;
Ok(Datum::Bool(v))
}
DataType::TinyInt(_) => {
// Strict type checking: reject bool for int columns
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for TinyInt column, got bool. Use 0 or 1 explicitly.".to_string(),
));
}
let v: i8 = value.extract()?;
Ok(Datum::Int8(v))
}
DataType::SmallInt(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for SmallInt column, got bool. Use 0 or 1 explicitly."
.to_string(),
));
}
let v: i16 = value.extract()?;
Ok(Datum::Int16(v))
}
DataType::Int(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for Int column, got bool. Use 0 or 1 explicitly.".to_string(),
));
}
let v: i32 = value.extract()?;
Ok(Datum::Int32(v))
}
DataType::BigInt(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for BigInt column, got bool. Use 0 or 1 explicitly.".to_string(),
));
}
let v: i64 = value.extract()?;
Ok(Datum::Int64(v))
}
DataType::Float(_) => {
let v: f32 = value.extract()?;
Ok(Datum::Float32(F32::from(v)))
}
DataType::Double(_) => {
let v: f64 = value.extract()?;
Ok(Datum::Float64(F64::from(v)))
}
DataType::String(_) | DataType::Char(_) => {
let v: String = value.extract()?;
Ok(v.into())
}
DataType::Bytes(_) | DataType::Binary(_) => {
// Efficient extraction: downcast to specific type and use bulk copy.
// PyBytes::as_bytes() and PyByteArray::to_vec() are O(n) bulk copies of the underlying data.
if let Ok(bytes) = value.downcast::<PyBytes>() {
Ok(bytes.as_bytes().to_vec().into())
} else if let Ok(bytearray) = value.downcast::<PyByteArray>() {
Ok(bytearray.to_vec().into())
} else {
Err(FlussError::new_err(format!(
"Expected bytes or bytearray, got {}",
value.get_type().name()?
)))
}
}
DataType::Decimal(decimal_type) => {
python_decimal_to_datum(value, decimal_type.precision(), decimal_type.scale())
}
DataType::Date(_) => python_date_to_datum(value),
DataType::Time(_) => python_time_to_datum(value),
DataType::Timestamp(_) => python_datetime_to_timestamp_ntz(value),
DataType::TimestampLTz(_) => python_datetime_to_timestamp_ltz(value),
DataType::Array(array_type) => {
let element_type = array_type.get_element_type();
if value.is_instance_of::<PyString>() {
return Err(FlussError::new_err(format!(
"Expected sequence for Array column, got {}",
get_type_name(value)
)));
}
let seq = value.downcast::<PySequence>().map_err(|_| {
FlussError::new_err(format!(
"Expected sequence for Array column, got {}",
get_type_name(value)
))
})?;
let len = seq.len()?;
let mut writer = FlussArrayWriter::new(len, element_type);
for i in 0..len {
let item = seq.get_item(i)?;
if item.is_none() {
writer.set_null_at(i);
} else {
let val_datum = python_value_to_datum(&item, element_type)?;
match val_datum {
Datum::Null => writer.set_null_at(i),
Datum::Bool(v) => writer.write_boolean(i, v),
Datum::Int8(v) => writer.write_byte(i, v),
Datum::Int16(v) => writer.write_short(i, v),
Datum::Int32(v) => writer.write_int(i, v),
Datum::Int64(v) => writer.write_long(i, v),
Datum::Float32(v) => writer.write_float(i, v.into_inner()),
Datum::Float64(v) => writer.write_double(i, v.into_inner()),
Datum::String(v) => writer.write_string(i, &v),
Datum::Blob(v) => writer.write_binary_bytes(i, v.as_ref()),
Datum::Decimal(v) => {
if let DataType::Decimal(dt) = element_type {
writer.write_decimal(i, &v, dt.precision());
}
}
Datum::Date(v) => writer.write_date(i, v),
Datum::Time(v) => writer.write_time(i, v),
Datum::TimestampNtz(v) => {
if let DataType::Timestamp(dt) = element_type {
writer.write_timestamp_ntz(i, &v, dt.precision());
}
}
Datum::TimestampLtz(v) => {
if let DataType::TimestampLTz(dt) = element_type {
writer.write_timestamp_ltz(i, &v, dt.precision());
}
}
Datum::Array(v) => writer.write_array(i, &v),
Datum::Map(v) => writer.write_map(i, &v),
Datum::Row(v) => writer
.write_row(i, v.as_ref())
.map_err(|e| FlussError::from_core_error(&e))?,
}
}
}
let array = writer
.complete()
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(Datum::Array(array))
}
DataType::Map(map_type) => {
let key_type = map_type.key_type();
let value_type = map_type.value_type();
let pairs = python_map_pairs(value)?;
let mut writer = FlussMapWriter::new(pairs.len(), key_type, value_type);
for (k, v) in pairs {
let key_datum = python_value_to_datum(&k, key_type)?;
let value_datum = python_value_to_datum(&v, value_type)?;
writer
.write_entry(key_datum, value_datum)
.map_err(|e| FlussError::from_core_error(&e))?;
}
let map = writer
.complete()
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(Datum::Map(map))
}
DataType::Row(row_type) => {
let nested = python_value_to_row_datum(value, row_type)?;
Ok(Datum::Row(Box::new(nested)))
}
}
}
/// Extract `(key, value)` pairs from a Python value representing a MAP column.
/// Accepts a `dict`, or a sequence of `(key, value)` pairs — matching the
/// shape pyarrow's `MapArray.to_pylist()` produces, so MAP values round-trip.
fn python_map_pairs<'py>(
value: &Bound<'py, PyAny>,
) -> PyResult<Vec<(Bound<'py, PyAny>, Bound<'py, PyAny>)>> {
if let Ok(dict) = value.downcast::<PyDict>() {
return Ok(dict.iter().collect());
}
if value.is_instance_of::<PyString>() {
return Err(FlussError::new_err(format!(
"Expected dict or sequence of (key, value) pairs for Map column, got {}",
get_type_name(value)
)));
}
let seq = value.downcast::<PySequence>().map_err(|_| {
FlussError::new_err(format!(
"Expected dict or sequence of (key, value) pairs for Map column, got {}",
get_type_name(value)
))
})?;
let len = seq.len()?;
let mut pairs = Vec::with_capacity(len);
for i in 0..len {
let entry = seq.get_item(i)?;
let pair = entry.downcast::<PySequence>().map_err(|_| {
FlussError::new_err("Map entries must be (key, value) pairs".to_string())
})?;
if pair.len()? != 2 {
return Err(FlussError::new_err(
"Map entries must be (key, value) pairs of length 2".to_string(),
));
}
pairs.push((pair.get_item(0)?, pair.get_item(1)?));
}
Ok(pairs)
}
/// Convert a Python value (`dict` by field name, or `list`/`tuple` by position)
/// into a nested `GenericRow` for a ROW column.
fn python_value_to_row_datum(
value: &Bound<PyAny>,
row_type: &RowType,
) -> PyResult<GenericRow<'static>> {
let fields = row_type.fields();
let mut datums: Vec<Datum<'static>> = vec![Datum::Null; fields.len()];
let row_input: RowInput = value.extract().map_err(|_| {
FlussError::new_err(format!(
"Row column must be a dict, list, or tuple; got {}",
get_type_name(value)
))
})?;
match row_input {
RowInput::Dict(dict) => {
for (k, _) in dict.iter() {
let key_str = k.extract::<&str>().map_err(|_| {
FlussError::new_err(format!(
"Row field keys must be strings; got {}",
get_type_name(&k)
))
})?;
if !fields.iter().any(|f| f.name() == key_str) {
return Err(FlussError::new_err(format!(
"Unknown row field '{}'. Expected: {}",
key_str,
fields
.iter()
.map(|f| f.name())
.collect::<Vec<_>>()
.join(", ")
)));
}
}
for (i, field) in fields.iter().enumerate() {
match dict.get_item(field.name())? {
Some(v) => {
datums[i] = python_value_to_datum(&v, field.data_type()).map_err(|e| {
FlussError::new_err(format!("Row field '{}': {}", field.name(), e))
})?;
}
None if field.data_type().is_nullable() => {}
None => {
return Err(FlussError::new_err(format!(
"Missing value for non-nullable row field '{}'",
field.name()
)));
}
}
}
}
RowInput::List(list) => fill_row_fields(list.as_sequence(), fields, &mut datums)?,
RowInput::Tuple(tuple) => fill_row_fields(tuple.as_sequence(), fields, &mut datums)?,
}
Ok(GenericRow { values: datums })
}
/// Fill ROW field datums from a positional Python sequence.
fn fill_row_fields(
seq: &Bound<PySequence>,
fields: &[DataField],
datums: &mut [Datum<'static>],
) -> PyResult<()> {
if seq.len()? != fields.len() {
return Err(FlussError::new_err(format!(
"Expected {} row fields, got {}",
fields.len(),
seq.len()?
)));
}
for (i, field) in fields.iter().enumerate() {
let v = seq.get_item(i)?;
datums[i] = python_value_to_datum(&v, field.data_type())
.map_err(|e| FlussError::new_err(format!("Row field '{}': {}", field.name(), e)))?;
}
Ok(())
}
/// Convert Rust Datum to Python value based on data type.
/// This is the reverse of python_value_to_datum.
pub fn datum_to_python_value(
py: Python,
row: &dyn InternalRow,
pos: usize,
data_type: &DataType,
) -> PyResult<Py<PyAny>> {
// Check for null first
if row
.is_null_at(pos)
.map_err(|e| FlussError::from_core_error(&e))?
{
return Ok(py.None());
}
match data_type {
DataType::Boolean(_) => Ok(row
.get_boolean(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::TinyInt(_) => Ok(row
.get_byte(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::SmallInt(_) => Ok(row
.get_short(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::Int(_) => Ok(row
.get_int(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::BigInt(_) => Ok(row
.get_long(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::Float(_) => Ok(row
.get_float(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::Double(_) => Ok(row
.get_double(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.into_pyobject(py)?
.to_owned()
.into_any()
.unbind()),
DataType::String(_) => {
let s = row
.get_string(pos)
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(s.into_pyobject(py)?.into_any().unbind())
}
DataType::Char(char_type) => {
let s = row
.get_char(pos, char_type.length() as usize)
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(s.into_pyobject(py)?.into_any().unbind())
}
DataType::Bytes(_) => {
let b = row
.get_bytes(pos)
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(PyBytes::new(py, b).into_any().unbind())
}
DataType::Binary(binary_type) => {
let b = row
.get_binary(pos, binary_type.length())
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(PyBytes::new(py, b).into_any().unbind())
}
DataType::Decimal(decimal_type) => {
let decimal = row
.get_decimal(
pos,
decimal_type.precision() as usize,
decimal_type.scale() as usize,
)
.map_err(|e| FlussError::from_core_error(&e))?;
rust_decimal_to_python(py, &decimal)
}
DataType::Date(_) => {
let date = row
.get_date(pos)
.map_err(|e| FlussError::from_core_error(&e))?;
rust_date_to_python(py, date)
}
DataType::Time(_) => {
let time = row
.get_time(pos)
.map_err(|e| FlussError::from_core_error(&e))?;
rust_time_to_python(py, time)
}
DataType::Timestamp(ts_type) => {
let ts = row
.get_timestamp_ntz(pos, ts_type.precision())
.map_err(|e| FlussError::from_core_error(&e))?;
rust_timestamp_ntz_to_python(py, ts)
}
DataType::TimestampLTz(ts_type) => {
let ts = row
.get_timestamp_ltz(pos, ts_type.precision())
.map_err(|e| FlussError::from_core_error(&e))?;
rust_timestamp_ltz_to_python(py, ts)
}
DataType::Array(array_type) => {
let array_data = row
.get_array(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.try_into_binary()
.map_err(|e| FlussError::from_core_error(&e))?;
array_to_pylist(py, &array_data, array_type.get_element_type())
}
DataType::Map(map_type) => {
let map_data = row
.get_map(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.try_into_binary()
.map_err(|e| FlussError::from_core_error(&e))?;
map_to_pylist(py, &map_data, map_type)
}
DataType::Row(row_type) => {
let nested = row
.get_row(pos)
.map_err(|e| FlussError::from_core_error(&e))?
.try_into_generic(row_type)
.map_err(|e| FlussError::from_core_error(&e))?;
row_to_pydict(py, &nested, row_type)
}
}
}
/// Convert a binary `FlussArray` to a Python list.
fn array_to_pylist(py: Python, arr: &FlussArray, element_type: &DataType) -> PyResult<Py<PyAny>> {
let py_list = pyo3::types::PyList::empty(py);
for i in 0..arr.size() {
py_list.append(array_elem_to_python(py, arr, i, element_type)?)?;
}
Ok(py_list.into_any().unbind())
}
/// Convert a Fluss `MAP` to a Python list of `(key, value)` tuples — matching
/// pyarrow's default `MapArray.to_pylist()` shape (preserves duplicate keys and
/// ordering, and allows non-hashable keys).
fn map_to_pylist(py: Python, map_data: &FlussMap, map_type: &MapType) -> PyResult<Py<PyAny>> {
let keys = map_data.key_array();
let values = map_data.value_array();
let py_list = pyo3::types::PyList::empty(py);
for i in 0..map_data.size() {
let py_key = array_elem_to_python(py, keys, i, map_type.key_type())?;
let py_val = array_elem_to_python(py, values, i, map_type.value_type())?;
py_list.append(pyo3::types::PyTuple::new(py, [py_key, py_val])?)?;
}
Ok(py_list.into_any().unbind())
}
/// Convert a Fluss `ROW` to a Python dict keyed by field name — matching
/// pyarrow's `StructArray.to_pylist()` shape.
fn row_to_pydict(py: Python, row: &dyn InternalRow, row_type: &RowType) -> PyResult<Py<PyAny>> {
let dict = PyDict::new(py);
for (i, field) in row_type.fields().iter().enumerate() {
let py_val = datum_to_python_value(py, row, i, field.data_type())?;
dict.set_item(field.name(), py_val)?;
}
Ok(dict.into_any().unbind())
}
/// Convert element `i` of a binary `FlussArray` to a Python value. A binary
/// array needs the explicit nested type to decode MAP/ROW elements (the generic
/// `DataGetters` trait getters cover only scalars and nested arrays), so this
/// dispatches through `FlussArray`'s inherent typed getters.
fn array_elem_to_python(
py: Python,
arr: &FlussArray,
i: usize,
dt: &DataType,
) -> PyResult<Py<PyAny>> {
if arr.is_null_at(i) {
return Ok(py.None());
}
match dt {
DataType::Array(array_type) => {
let nested = arr
.get_array(i)
.map_err(|e| FlussError::from_core_error(&e))?;
array_to_pylist(py, &nested, array_type.get_element_type())
}
DataType::Map(map_type) => {
let nested = arr
.get_map(i, map_type.key_type(), map_type.value_type())
.map_err(|e| FlussError::from_core_error(&e))?;
map_to_pylist(py, &nested, map_type)
}
DataType::Row(row_type) => {
let nested = arr
.get_row(i, row_type)
.map_err(|e| FlussError::from_core_error(&e))?;
row_to_pydict(py, &nested, row_type)
}
_ => datum_to_python_value(py, arr, i, dt),
}
}
/// Convert Rust Decimal to Python decimal.Decimal
fn rust_decimal_to_python(py: Python, decimal: &fcore::row::Decimal) -> PyResult<Py<PyAny>> {
let decimal_ty = get_decimal_type(py)?;
let decimal_str = decimal.to_string();
let py_decimal = decimal_ty.call1((decimal_str,))?;
Ok(py_decimal.into_any().unbind())
}
/// Convert Rust Date (days since epoch) to Python datetime.date
fn rust_date_to_python(py: Python, date: fcore::row::Date) -> PyResult<Py<PyAny>> {
let days_since_epoch = date.get_inner();
let epoch = jiff::civil::date(1970, 1, 1);
let civil_date = epoch + jiff::Span::new().days(days_since_epoch as i64);
let py_date = PyDate::new(
py,
civil_date.year() as i32,
civil_date.month() as u8,
civil_date.day() as u8,
)?;
Ok(py_date.into_any().unbind())
}
/// Convert Rust Time (millis since midnight) to Python datetime.time
fn rust_time_to_python(py: Python, time: fcore::row::Time) -> PyResult<Py<PyAny>> {
let millis = time.get_inner() as i64;
let hours = millis / MILLIS_PER_HOUR;
let minutes = (millis % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE;
let seconds = (millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND;
let microseconds = (millis % MILLIS_PER_SECOND) * MICROS_PER_MILLI;
let py_time = PyTime::new(
py,
hours as u8,
minutes as u8,
seconds as u8,
microseconds as u32,
None,
)?;
Ok(py_time.into_any().unbind())
}
/// Convert Rust TimestampNtz to Python naive datetime
fn rust_timestamp_ntz_to_python(py: Python, ts: fcore::row::TimestampNtz) -> PyResult<Py<PyAny>> {
let millis = ts.get_millisecond();
let nanos = ts.get_nano_of_millisecond();
let total_micros = millis * MICROS_PER_MILLI + (nanos as i64 / NANOS_PER_MICRO);
// Convert to civil datetime via jiff
let timestamp = jiff::Timestamp::from_microsecond(total_micros)
.map_err(|e| FlussError::new_err(format!("Invalid timestamp: {e}")))?;
let civil_dt = timestamp.to_zoned(jiff::tz::TimeZone::UTC).datetime();
let py_dt = PyDateTime::new(
py,
civil_dt.year() as i32,
civil_dt.month() as u8,
civil_dt.day() as u8,
civil_dt.hour() as u8,
civil_dt.minute() as u8,
civil_dt.second() as u8,
(civil_dt.subsec_nanosecond() / 1000) as u32, // microseconds
None,
)?;
Ok(py_dt.into_any().unbind())
}
/// Convert Rust TimestampLtz to Python timezone-aware datetime (UTC)
fn rust_timestamp_ltz_to_python(py: Python, ts: fcore::row::TimestampLtz) -> PyResult<Py<PyAny>> {
let millis = ts.get_epoch_millisecond();
let nanos = ts.get_nano_of_millisecond();
let total_micros = millis * MICROS_PER_MILLI + (nanos as i64 / NANOS_PER_MICRO);
// Convert to civil datetime via jiff
let timestamp = jiff::Timestamp::from_microsecond(total_micros)
.map_err(|e| FlussError::new_err(format!("Invalid timestamp: {e}")))?;
let civil_dt = timestamp.to_zoned(jiff::tz::TimeZone::UTC).datetime();
let utc = get_utc_timezone(py)?;
let py_dt = PyDateTime::new(
py,
civil_dt.year() as i32,
civil_dt.month() as u8,
civil_dt.day() as u8,
civil_dt.hour() as u8,
civil_dt.minute() as u8,
civil_dt.second() as u8,
(civil_dt.subsec_nanosecond() / 1000) as u32, // microseconds
Some(&utc),
)?;
Ok(py_dt.into_any().unbind())
}
/// Convert an InternalRow to a Python dictionary
pub fn internal_row_to_dict(
py: Python,
row: &dyn fcore::row::InternalRow,
table_info: &fcore::metadata::TableInfo,
) -> PyResult<Py<PyAny>> {
let row_type = table_info.row_type();
let fields = row_type.fields();
let dict = PyDict::new(py);
for (pos, field) in fields.iter().enumerate() {
let value = datum_to_python_value(py, row, pos, field.data_type())?;
dict.set_item(field.name(), value)?;
}
Ok(dict.into_any().unbind())
}
/// Cached decimal.Decimal type
/// Uses PyOnceLock for thread-safety and subinterpreter compatibility.
static DECIMAL_TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();
/// Cached UTC timezone
static UTC_TIMEZONE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
/// Cached UTC epoch type
static UTC_EPOCH: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
/// Get the cached decimal.Decimal type, importing it once per interpreter.
fn get_decimal_type(py: Python) -> PyResult<Bound<PyType>> {
let ty = DECIMAL_TYPE.get_or_try_init(py, || -> PyResult<_> {
let decimal_mod = py.import("decimal")?;
let decimal_ty = decimal_mod.getattr("Decimal")?.downcast_into::<PyType>()?;
Ok(decimal_ty.unbind())
})?;
Ok(ty.bind(py).clone())
}
/// Get the cached UTC timezone (datetime.timezone.utc), creating it once per interpreter.
fn get_utc_timezone(py: Python) -> PyResult<Bound<PyTzInfo>> {
let tz = UTC_TIMEZONE.get_or_try_init(py, || -> PyResult<_> {
let datetime_mod = py.import("datetime")?;
let timezone = datetime_mod.getattr("timezone")?;
let utc = timezone.getattr("utc")?;
Ok(utc.unbind())
})?;
// Downcast to PyTzInfo for use with PyDateTime::new()
Ok(tz.bind(py).clone().downcast_into::<PyTzInfo>()?)
}
/// Get the cached UTC epoch datetime, creating it once per interpreter.
fn get_utc_epoch(py: Python) -> PyResult<Bound<PyAny>> {
let epoch = UTC_EPOCH.get_or_try_init(py, || -> PyResult<_> {
let datetime_mod = py.import("datetime")?;
let timezone = datetime_mod.getattr("timezone")?;
let utc = timezone.getattr("utc")?;
let epoch = datetime_mod
.getattr("datetime")?
.call1((1970, 1, 1, 0, 0, 0, 0, &utc))?;
Ok(epoch.unbind())
})?;
Ok(epoch.bind(py).clone())
}
/// Validate that value is a decimal.Decimal instance.
fn ensure_is_decimal(value: &Bound<PyAny>) -> PyResult<()> {
let decimal_ty = get_decimal_type(value.py())?;
if !value.is_instance(&decimal_ty.into_any())? {
return Err(FlussError::new_err(format!(
"Expected decimal.Decimal, got {}",
get_type_name(value)
)));
}
Ok(())
}
/// Convert Python decimal.Decimal to Datum::Decimal.
/// Only accepts decimal.Decimal
fn python_decimal_to_datum(
value: &Bound<PyAny>,
precision: u32,
scale: u32,
) -> PyResult<fcore::row::Datum<'static>> {
use std::str::FromStr;
ensure_is_decimal(value)?;
let decimal_str: String = value.str()?.extract()?;
let bd = bigdecimal::BigDecimal::from_str(&decimal_str).map_err(|e| {
FlussError::new_err(format!("Failed to parse decimal '{decimal_str}': {e}"))
})?;
let decimal = fcore::row::Decimal::from_big_decimal(bd, precision, scale).map_err(|e| {
FlussError::new_err(format!(
"Failed to convert decimal '{decimal_str}' to DECIMAL({precision}, {scale}): {e}"
))
})?;
Ok(fcore::row::Datum::Decimal(decimal))
}
/// Convert Python datetime.date to Datum::Date.
fn python_date_to_datum(value: &Bound<PyAny>) -> PyResult<fcore::row::Datum<'static>> {
// Reject datetime.datetime (subclass of date) - use timestamp columns for those
if value.downcast::<PyDateTime>().is_ok() {
return Err(FlussError::new_err(
"Expected datetime.date, got datetime.datetime. Use a TIMESTAMP column for datetime values.",
));
}
let date = value.downcast::<PyDate>().map_err(|_| {
FlussError::new_err(format!(
"Expected datetime.date, got {}",
get_type_name(value)
))
})?;
let year = date.get_year();
let month = date.get_month();
let day = date.get_day();
// Calculate days since Unix epoch (1970-01-01)
let civil_date = jiff::civil::date(year as i16, month as i8, day as i8);
let epoch = jiff::civil::date(1970, 1, 1);
let days_since_epoch = (civil_date - epoch).get_days();
Ok(fcore::row::Datum::Date(fcore::row::Date::new(
days_since_epoch,
)))
}
/// Convert Python datetime.time to Datum::Time.
/// Uses PyO3's native PyTime type for efficient access.
///
/// Note: Fluss TIME is always stored as milliseconds since midnight (i32) regardless
/// of the schema's precision setting. This matches the Java Fluss wire protocol.
/// Sub-millisecond precision (microseconds not divisible by 1000) will raise an error
/// to prevent silent data loss and ensure fail-fast behavior.
fn python_time_to_datum(value: &Bound<PyAny>) -> PyResult<fcore::row::Datum<'static>> {
let time = value.downcast::<PyTime>().map_err(|_| {
FlussError::new_err(format!(
"Expected datetime.time, got {}",
get_type_name(value)
))
})?;
let hour = time.get_hour() as i32;
let minute = time.get_minute() as i32;
let second = time.get_second() as i32;
let microsecond = time.get_microsecond() as i32;
// Strict validation: reject sub-millisecond precision
if microsecond % MICROS_PER_MILLI as i32 != 0 {
return Err(FlussError::new_err(format!(
"TIME values with sub-millisecond precision are not supported. \
Got time with {microsecond} microseconds (not divisible by 1000). \
Fluss stores TIME as milliseconds since midnight. \
Please round to milliseconds before insertion."
)));
}
// Convert to milliseconds since midnight
let millis = hour * MILLIS_PER_HOUR as i32
+ minute * MILLIS_PER_MINUTE as i32
+ second * MILLIS_PER_SECOND as i32
+ microsecond / MICROS_PER_MILLI as i32;
Ok(fcore::row::Datum::Time(fcore::row::Time::new(millis)))
}
/// Convert Python datetime-like object to Datum::TimestampNtz.
/// Supports: datetime.datetime (naive preferred), pd.Timestamp, np.datetime64
fn python_datetime_to_timestamp_ntz(value: &Bound<PyAny>) -> PyResult<fcore::row::Datum<'static>> {
let (epoch_millis, nano_of_milli) = extract_datetime_components_ntz(value)?;
let ts = fcore::row::TimestampNtz::from_millis_nanos(epoch_millis, nano_of_milli)
.map_err(|e| FlussError::new_err(format!("Failed to create TimestampNtz: {e}")))?;
Ok(fcore::row::Datum::TimestampNtz(ts))
}
/// Convert Python datetime-like object to Datum::TimestampLtz.
/// For naive datetimes, assumes UTC. For aware datetimes, converts to UTC.
/// Supports: datetime.datetime, pd.Timestamp, np.datetime64
fn python_datetime_to_timestamp_ltz(value: &Bound<PyAny>) -> PyResult<fcore::row::Datum<'static>> {
let (epoch_millis, nano_of_milli) = extract_datetime_components_ltz(value)?;
let ts = fcore::row::TimestampLtz::from_millis_nanos(epoch_millis, nano_of_milli)
.map_err(|e| FlussError::new_err(format!("Failed to create TimestampLtz: {e}")))?;
Ok(fcore::row::Datum::TimestampLtz(ts))
}
/// Extract epoch milliseconds for TimestampNtz (wall-clock time, no timezone conversion).
/// Uses integer arithmetic to avoid float precision issues.
/// For clarity, tz-aware datetimes are rejected - use TimestampLtz for those.
fn extract_datetime_components_ntz(value: &Bound<PyAny>) -> PyResult<(i64, i32)> {
// Try PyDateTime first
if let Ok(dt) = value.downcast::<PyDateTime>() {
// Reject tz-aware datetime for NTZ - it's ambiguous what the user wants
let tzinfo = dt.getattr("tzinfo")?;
if !tzinfo.is_none() {
return Err(FlussError::new_err(
"TIMESTAMP (without timezone) requires a naive datetime. \
Got timezone-aware datetime. Either remove tzinfo or use TIMESTAMP_LTZ column.",
));
}
return datetime_to_epoch_millis_as_utc(dt);
}
// Check for pandas Timestamp by verifying module name
if is_pandas_timestamp(value) {
// For NTZ, reject tz-aware pandas Timestamps for consistency with datetime behavior
if let Ok(tz) = value.getattr("tz") {
if !tz.is_none() {
return Err(FlussError::new_err(
"TIMESTAMP (without timezone) requires a naive pd.Timestamp. \
Got timezone-aware Timestamp. Either use tz_localize(None) or use TIMESTAMP_LTZ column.",
));
}
}
// Naive pandas Timestamp: .value is nanoseconds since epoch (wall-clock as UTC)
let nanos: i64 = value.getattr("value")?.extract()?;
return Ok(nanos_to_millis_and_submillis(nanos));
}
// Try to_pydatetime() for objects that support it
if let Ok(py_dt) = value.call_method0("to_pydatetime") {
if let Ok(dt) = py_dt.downcast::<PyDateTime>() {
let tzinfo = dt.getattr("tzinfo")?;
if !tzinfo.is_none() {
return Err(FlussError::new_err(
"TIMESTAMP (without timezone) requires a naive datetime. \
Got timezone-aware value. Use TIMESTAMP_LTZ column instead.",
));
}
return datetime_to_epoch_millis_as_utc(dt);
}
}
Err(FlussError::new_err(format!(
"Expected naive datetime.datetime or pd.Timestamp, got {}",
get_type_name(value)
)))
}
/// Extract epoch milliseconds for TimestampLtz (instant in time, UTC-based).
/// For naive datetimes, assumes UTC. For aware datetimes, converts to UTC.
fn extract_datetime_components_ltz(value: &Bound<PyAny>) -> PyResult<(i64, i32)> {
// Try PyDateTime first
if let Ok(dt) = value.downcast::<PyDateTime>() {
// Check if timezone-aware
let tzinfo = dt.getattr("tzinfo")?;
if tzinfo.is_none() {
// Naive datetime: assume UTC (treat components as UTC time)
return datetime_to_epoch_millis_as_utc(dt);
} else {
// Aware datetime: use timedelta from epoch to get correct UTC instant
return datetime_to_epoch_millis_utc_aware(dt);
}
}
// Check for pandas Timestamp
if is_pandas_timestamp(value) {
// pandas Timestamp.value is always nanoseconds since UTC epoch
let nanos: i64 = value.getattr("value")?.extract()?;
return Ok(nanos_to_millis_and_submillis(nanos));
}
// Try to_pydatetime()
if let Ok(py_dt) = value.call_method0("to_pydatetime") {
if let Ok(dt) = py_dt.downcast::<PyDateTime>() {
let tzinfo = dt.getattr("tzinfo")?;
if tzinfo.is_none() {
return datetime_to_epoch_millis_as_utc(dt);
} else {
return datetime_to_epoch_millis_utc_aware(dt);
}
}
}
Err(FlussError::new_err(format!(
"Expected datetime.datetime or pd.Timestamp, got {}",
get_type_name(value)
)))
}
/// Convert datetime components to epoch milliseconds treating them as UTC
fn datetime_to_epoch_millis_as_utc(dt: &Bound<'_, PyDateTime>) -> PyResult<(i64, i32)> {
let year = dt.get_year();
let month = dt.get_month();
let day = dt.get_day();
let hour = dt.get_hour();
let minute = dt.get_minute();
let second = dt.get_second();
let microsecond = dt.get_microsecond();
// Create jiff civil datetime and convert to UTC timestamp
// Safe casts: hour (0-23), minute (0-59), second (0-59) all fit in i8
let civil_dt = jiff::civil::date(year as i16, month as i8, day as i8).at(
hour as i8,
minute as i8,
second as i8,
microsecond as i32 * 1000,
);
let timestamp = jiff::tz::Offset::UTC
.to_timestamp(civil_dt)
.map_err(|e| FlussError::new_err(format!("Invalid datetime: {e}")))?;
let millis = timestamp.as_millisecond();
let nano_of_milli = (timestamp.subsec_nanosecond() % NANOS_PER_MILLI as i32) as i32;
Ok((millis, nano_of_milli))
}
/// Convert timezone-aware datetime to epoch milliseconds using Python's timedelta.
/// This correctly handles timezone conversions by computing (dt - UTC_EPOCH).
/// The UTC epoch is cached for performance.
fn datetime_to_epoch_millis_utc_aware(dt: &Bound<'_, PyDateTime>) -> PyResult<(i64, i32)> {
let py = dt.py();
let epoch = get_utc_epoch(py)?;
// Compute delta = dt - epoch (this handles timezone conversion correctly)
let delta = dt.call_method1("__sub__", (epoch,))?;
let delta = delta.downcast::<PyDelta>()?;
// Extract components using integer arithmetic
let days = delta.get_days() as i64;
let seconds = delta.get_seconds() as i64;
let microseconds = delta.get_microseconds() as i64;
// Total milliseconds (note: days can be negative for dates before epoch)
let total_micros = days * MICROS_PER_DAY + seconds * MICROS_PER_SECOND + microseconds;
let millis = total_micros / MICROS_PER_MILLI;
let nano_of_milli = ((total_micros % MICROS_PER_MILLI) * MICROS_PER_MILLI) as i32;
// Handle negative microseconds remainder
let (millis, nano_of_milli) = if nano_of_milli < 0 {
(millis - 1, nano_of_milli + NANOS_PER_MILLI as i32)
} else {
(millis, nano_of_milli)
};
Ok((millis, nano_of_milli))
}
/// Convert nanoseconds to (milliseconds, nano_of_millisecond)
fn nanos_to_millis_and_submillis(nanos: i64) -> (i64, i32) {
let millis = nanos / NANOS_PER_MILLI;
let nano_of_milli = (nanos % NANOS_PER_MILLI) as i32;
// Handle negative nanoseconds correctly (Euclidean remainder)
if nano_of_milli < 0 {
(millis - 1, nano_of_milli + NANOS_PER_MILLI as i32)
} else {
(millis, nano_of_milli)
}
}
/// Check if value is a pandas Timestamp by examining its type.
fn is_pandas_timestamp(value: &Bound<PyAny>) -> bool {
// Check module and class name to avoid importing pandas
if let Ok(cls) = value.get_type().getattr("__module__") {
if let Ok(module) = cls.extract::<&str>() {
if module.starts_with("pandas") {
if let Ok(name) = value.get_type().getattr("__name__") {
if let Ok(name_str) = name.extract::<&str>() {
return name_str == "Timestamp";
}
}
}
}
}
false
}
/// Get type name
fn get_type_name(value: &Bound<PyAny>) -> String {
value
.get_type()
.name()
.map(|s| s.to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
/// Thin Python iterator over [`fcore::client::SyncRecordBatchLogReader`].
/// Used internally as the backing iterator for
/// ``pa.RecordBatchReader.from_batches()``; not registered on the module.
#[pyclass]
struct PyRecordBatchLogReader {
sync_reader: fcore::client::SyncRecordBatchLogReader,
}
#[pymethods]
impl PyRecordBatchLogReader {
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}
fn __next__(&mut self, py: Python) -> PyResult<Option<Py<PyAny>>> {
let result = py.detach(|| self.sync_reader.next().transpose());
match result {
Ok(Some(batch)) => {
let py_batch = batch
.to_pyarrow(py)
.map_err(|e| FlussError::new_err(format!("Failed to convert batch: {e}")))?;
Ok(Some(py_batch.unbind()))
}
Ok(None) => Ok(None),
Err(arrow_err) => Err(FlussError::new_err(format!(
"Error reading batch: {arrow_err}"
))),
}
}
}
/// Wraps the two scanner variants so we never have an impossible state
/// (both None or both Some).
enum ScannerKind {
Record(fcore::client::LogScanner),
Batch(fcore::client::RecordBatchLogScanner),
}
impl ScannerKind {
fn as_record(&self) -> PyResult<&fcore::client::LogScanner> {
match self {
Self::Record(s) => Ok(s),
Self::Batch(_) => Err(FlussError::new_err(
"poll() requires a record-based scanner. Use new_scan().create_log_scanner().",
)),
}
}
fn as_batch(&self) -> PyResult<&fcore::client::RecordBatchLogScanner> {
match self {
Self::Batch(s) => Ok(s),
Self::Record(_) => Err(FlussError::new_err(
"This method requires a batch-based scanner. Use new_scan().create_record_batch_log_scanner().",
)),
}
}
}
/// Dispatch a method call to whichever scanner variant is active.
/// Both `LogScanner` and `RecordBatchLogScanner` share the same subscribe interface.
macro_rules! with_scanner {
($scanner:expr, $method:ident($($arg:expr),*)) => {
match $scanner.as_ref() {
ScannerKind::Record(s) => s.$method($($arg),*).await,
ScannerKind::Batch(s) => s.$method($($arg),*).await,
}
};
}
/// Scanner for reading log data from a Fluss table.
///
/// This scanner supports two modes:
/// - Record-based scanning via `poll()` - returns individual records with metadata
/// - Batch-based scanning via `poll_arrow()` / `poll_record_batch()` - returns Arrow batches
#[pyclass]
pub struct LogScanner {
kind: Arc<ScannerKind>,
admin: Arc<fcore::client::FlussAdmin>,
table_info: fcore::metadata::TableInfo,
/// The projected Arrow schema to use for empty table creation
projected_schema: SchemaRef,
/// The projected row type to use for record-based scanning
projected_row_type: Arc<fcore::metadata::RowType>,
}
#[pymethods]
impl LogScanner {
/// Subscribe to a single bucket at a specific offset (non-partitioned tables).
///
/// Args:
/// bucket_id: The bucket ID to subscribe to
/// start_offset: The offset to start reading from (use EARLIEST_OFFSET for beginning)
fn subscribe(&self, py: Python, bucket_id: i32, start_offset: i64) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(&self.kind, subscribe(bucket_id, start_offset))
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Subscribe to multiple buckets at specified offsets (non-partitioned tables).
///
/// Args:
/// bucket_offsets: A dict mapping bucket_id -> start_offset
fn subscribe_buckets(&self, py: Python, bucket_offsets: HashMap<i32, i64>) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(&self.kind, subscribe_buckets(&bucket_offsets))
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Subscribe to a bucket within a specific partition (partitioned tables only).
///
/// Args:
/// partition_id: The partition ID (from PartitionInfo.partition_id)
/// bucket_id: The bucket ID within the partition
/// start_offset: The offset to start reading from (use EARLIEST_OFFSET for beginning)
fn subscribe_partition(
&self,
py: Python,
partition_id: i64,
bucket_id: i32,
start_offset: i64,
) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(
&self.kind,
subscribe_partition(partition_id, bucket_id, start_offset)
)
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Subscribe to multiple partition+bucket combinations at once (partitioned tables only).
///
/// Args:
/// partition_bucket_offsets: A dict mapping (partition_id, bucket_id) tuples to start_offsets
fn subscribe_partition_buckets(
&self,
py: Python,
partition_bucket_offsets: HashMap<(i64, i32), i64>,
) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(
&self.kind,
subscribe_partition_buckets(&partition_bucket_offsets)
)
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Unsubscribe from a specific bucket (non-partitioned tables only).
///
/// Args:
/// bucket_id: The bucket ID to unsubscribe from
fn unsubscribe(&self, py: Python, bucket_id: i32) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(&self.kind, unsubscribe(bucket_id))
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Unsubscribe from a specific partition bucket (partitioned tables only).
///
/// Args:
/// partition_id: The partition ID to unsubscribe from
/// bucket_id: The bucket ID within the partition
fn unsubscribe_partition(&self, py: Python, partition_id: i64, bucket_id: i32) -> PyResult<()> {
py.detach(|| {
TOKIO_RUNTIME.block_on(async {
with_scanner!(&self.kind, unsubscribe_partition(partition_id, bucket_id))
.map_err(|e| FlussError::from_core_error(&e))
})
})
}
/// Poll for individual records with metadata.
///
/// Args:
/// timeout_ms: Timeout in milliseconds to wait for records
///
/// Returns:
/// ScanRecords grouped by bucket. Supports flat iteration
/// (`for rec in records`) and per-bucket access (`records.buckets()`,
/// `records.records(bucket)`, `records[bucket]`).
///
/// Note:
/// - Requires a record-based scanner (created with new_scan().create_log_scanner())
/// - Returns an empty ScanRecords if no records are available
/// - When timeout expires, returns an empty ScanRecords (NOT an error)
fn poll<'py>(&self, py: Python<'py>, timeout_ms: i64) -> PyResult<Bound<'py, PyAny>> {
if timeout_ms < 0 {
return Err(FlussError::new_err(format!(
"timeout_ms must be non-negative, got: {timeout_ms}"
)));
}
let timeout = Duration::from_millis(timeout_ms as u64);
let scanner = Arc::clone(&self.kind);
let projected_row_type = self.projected_row_type.clone();
future_into_py(py, async move {
let scan_records = scanner
.as_record()?
.poll(timeout)
.await
.map_err(|e| FlussError::from_core_error(&e))?;
Python::attach(|py| {
let mut records_by_bucket = IndexMap::new();
let mut total_count = 0usize;
for (bucket, records) in scan_records.into_records_by_buckets() {
let py_bucket = TableBucket::from_core(bucket);
let mut py_records = Vec::with_capacity(records.len());
for record in &records {
let scan_record = ScanRecord::from_core(py, record, &projected_row_type)?;
py_records.push(Py::new(py, scan_record)?);
total_count += 1;
}
records_by_bucket.insert(py_bucket, py_records);
}
Ok(ScanRecords {
records_by_bucket,
total_count,
})
})
})
}
/// Poll for batches with metadata.
///
/// Args:
/// timeout_ms: Timeout in milliseconds to wait for batches
///
/// Returns:
/// List of RecordBatch objects, each containing the Arrow batch along with
/// bucket, base_offset, and last_offset metadata.
///
/// Note:
/// - Requires a batch-based scanner (created with new_scan().create_record_batch_log_scanner())
/// - Returns an empty list if no batches are available
/// - When timeout expires, returns an empty list (NOT an error)
fn poll_record_batch<'py>(
&self,
py: Python<'py>,
timeout_ms: i64,
) -> PyResult<Bound<'py, PyAny>> {
if timeout_ms < 0 {
return Err(FlussError::new_err(format!(
"timeout_ms must be non-negative, got: {timeout_ms}"
)));
}
let timeout = Duration::from_millis(timeout_ms as u64);
let scanner = Arc::clone(&self.kind);
future_into_py(py, async move {
let scan_batches = scanner
.as_batch()?
.poll(timeout)
.await
.map_err(|e| FlussError::from_core_error(&e))?;
Python::attach(|py| {
scan_batches
.into_iter()
.map(|sb| Py::new(py, RecordBatch::from_scan_batch(sb)))
.collect::<PyResult<Vec<_>>>()
})
})
}
/// Poll for new records as an Arrow Table.
///
/// Args:
/// timeout_ms: Timeout in milliseconds to wait for records
///
/// Returns:
/// PyArrow Table containing the polled records (batches merged)
///
/// Note:
/// - Requires a batch-based scanner (created with new_scan().create_record_batch_log_scanner())
/// - Returns an empty table (with correct schema) if no records are available
/// - When timeout expires, returns an empty table (NOT an error)
fn poll_arrow<'py>(&self, py: Python<'py>, timeout_ms: i64) -> PyResult<Bound<'py, PyAny>> {
if timeout_ms < 0 {
return Err(FlussError::new_err(format!(
"timeout_ms must be non-negative, got: {timeout_ms}"
)));
}
let timeout = Duration::from_millis(timeout_ms as u64);
let scanner = Arc::clone(&self.kind);
let projected_schema = self.projected_schema.clone();
future_into_py(py, async move {
let scan_batches = scanner
.as_batch()?
.poll(timeout)
.await
.map_err(|e| FlussError::from_core_error(&e))?;
let arrow_batches = scan_batches
.into_iter()
.map(|sb| Arc::new(sb.into_batch()))
.collect();
Python::attach(|py| Self::batches_to_arrow_table(py, arrow_batches, &projected_schema))
})
}
/// Create a lazy Arrow RecordBatchReader that reads until latest offsets.
///
/// This is a **blocking / synchronous** API: construction queries the
/// server for latest offsets (via ``block_on``), and each
/// ``RecordBatchReader.__next__()`` call blocks the calling thread until
/// the next batch is available. It is suitable for Arrow interop
/// (feeding into DuckDB, Polars, etc.) but should not be used
/// from ``asyncio`` coroutines -- see issue #545 for a planned
/// asyncio-native streaming alternative.
/// TODO(#545): Add asyncio-native streaming counterpart.
///
/// Returns a PyArrow RecordBatchReader that lazily polls batches one at a
/// time. This is more memory-efficient than ``to_arrow()`` which loads all
/// data into a single table.
///
/// **Concurrency:** While this reader is alive, ``subscribe*`` and
/// ``unsubscribe*`` calls on the scanner are rejected with an error.
/// You should also avoid calling ``poll_arrow`` / ``poll_record_batch``
/// on the same scanner — these are not blocked by the guard, but they
/// share the underlying fetch buffer with the reader and would
/// interleave batches between both consumers. Drop the reader before
/// resuming any of these operations.
///
/// You must call subscribe(), subscribe_buckets(), subscribe_partition(),
/// or subscribe_partition_buckets() first.
///
/// Returns:
/// ``pyarrow.RecordBatchReader`` yielding ``RecordBatch`` objects
fn to_arrow_batch_reader(&self, py: Python) -> PyResult<Py<PyAny>> {
let scanner = self.kind.as_batch()?;
let sync_reader = py
.detach(|| {
TOKIO_RUNTIME.block_on(async {
let reader = fcore::client::RecordBatchLogReader::new_until_latest(
scanner.new_shared_handle(),
&self.admin,
)
.await?;
Ok::<_, fcore::error::Error>(
reader.to_record_batch_reader(TOKIO_RUNTIME.handle().clone()),
)
})
})
.map_err(|e| FlussError::from_core_error(&e))?;
let py_schema = sync_reader
.schema()
.to_pyarrow(py)
.map_err(|e| FlussError::new_err(format!("Failed to convert schema: {e}")))?;
let py_iter = Py::new(py, PyRecordBatchLogReader { sync_reader })?;
let pyarrow = py.import("pyarrow")?;
let batch_reader = pyarrow
.getattr("RecordBatchReader")?
.call_method1("from_batches", (py_schema, py_iter))?;
Ok(batch_reader.into())
}
/// Convert all data to Arrow Table.
///
/// Reads from currently subscribed buckets until reaching their latest offsets.
/// Works for both partitioned and non-partitioned tables.
///
/// Materializes batches in Rust (``RecordBatchLogReader::collect_all_batches``)
/// then builds one PyArrow table, avoiding per-batch Python iteration.
///
/// You must call subscribe(), subscribe_buckets(), subscribe_partition(), or subscribe_partition_buckets() first.
///
/// Returns:
/// PyArrow Table containing all data from subscribed buckets
fn to_arrow<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
future_into_py(
py,
Self::scan_to_arrow_table(
Arc::clone(&self.kind),
Arc::clone(&self.admin),
self.projected_schema.clone(),
),
)
}
/// Convert all data to Pandas DataFrame.
///
/// Reads from currently subscribed buckets until reaching their latest offsets.
/// Works for both partitioned and non-partitioned tables.
///
/// You must call subscribe(), subscribe_buckets(), subscribe_partition(), or subscribe_partition_buckets() first.
///
/// Returns:
/// Pandas DataFrame containing all data from subscribed buckets
fn to_pandas<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let kind = Arc::clone(&self.kind);
let admin = Arc::clone(&self.admin);
let projected_schema = self.projected_schema.clone();
future_into_py(py, async move {
let table = Self::scan_to_arrow_table(kind, admin, projected_schema).await?;
Python::attach(|py| table.call_method0(py, "to_pandas"))
})
}
fn __aiter__<'py>(slf: PyRef<'py, Self>) -> PyResult<Bound<'py, PyAny>> {
let py = slf.py();
// Single lock for the generic async generator
static ASYNC_GEN_FN: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
let gen_fn = ASYNC_GEN_FN.get_or_init(py, || {
let code = pyo3::ffi::c_str!(
r#"
async def _async_scan_generic(scanner, method_name, timeout_ms):
poll_method = getattr(scanner, method_name)
while True:
for item in await poll_method(timeout_ms):
yield item
"#
);
let globals = pyo3::types::PyDict::new(py);
py.run(code, Some(&globals), None).unwrap();
globals
.get_item("_async_scan_generic")
.unwrap()
.unwrap()
.unbind()
});
let method_name = match slf.kind.as_ref() {
ScannerKind::Record(_) => "poll",
ScannerKind::Batch(_) => "poll_record_batch",
};
gen_fn.bind(py).call1((
slf.into_bound_py_any(py)?,
method_name,
DEFAULT_POLL_INTERVAL_MS,
))
}
fn __repr__(&self) -> String {
format!("LogScanner(table={})", self.table_info.table_path)
}
}
impl LogScanner {
fn new(
scanner: ScannerKind,
admin: Arc<fcore::client::FlussAdmin>,
table_info: fcore::metadata::TableInfo,
projected_schema: SchemaRef,
projected_row_type: Arc<fcore::metadata::RowType>,
) -> Self {
Self {
kind: Arc::new(scanner),
admin,
table_info,
projected_schema,
projected_row_type,
}
}
/// Read until the latest offsets and build one PyArrow Table.
async fn scan_to_arrow_table(
kind: Arc<ScannerKind>,
admin: Arc<fcore::client::FlussAdmin>,
projected_schema: SchemaRef,
) -> PyResult<Py<PyAny>> {
let scanner = kind.as_batch()?;
let mut reader = fcore::client::RecordBatchLogReader::new_until_latest(
scanner.new_shared_handle(),
&admin,
)
.await
.map_err(|e| FlussError::from_core_error(&e))?;
let batches: Vec<Arc<ArrowRecordBatch>> = reader
.collect_all_batches()
.await
.map_err(|e| FlussError::from_core_error(&e))?
.into_iter()
.map(|sb| Arc::new(sb.into_batch()))
.collect();
Python::attach(|py| Self::batches_to_arrow_table(py, batches, &projected_schema))
}
/// Convert Arrow record batches to a PyArrow Table (or empty table if no batches).
fn batches_to_arrow_table(
py: Python<'_>,
batches: Vec<Arc<ArrowRecordBatch>>,
projected_schema: &SchemaRef,
) -> PyResult<Py<PyAny>> {
if batches.is_empty() {
let py_schema = projected_schema
.as_ref()
.to_pyarrow(py)
.map_err(|e| FlussError::new_err(format!("Failed to convert schema: {e}")))?;
let pyarrow = py.import("pyarrow")?;
let empty_table = pyarrow
.getattr("Table")?
.call_method1("from_batches", (vec![] as Vec<Py<PyAny>>, py_schema))?;
Ok(empty_table.into())
} else {
Utils::combine_batches_to_table(py, batches)
}
}
}
/// One-shot bounded scanner over a single bucket.
///
/// Obtained via `table.new_scan().limit(n).create_bucket_batch_scanner(bucket)`.
/// The scan runs on the first `next_batch()` and yields its single batch once,
/// then is spent. Honors the configured limit and any projection.
#[pyclass]
pub struct BatchScanner {
inner: Arc<Mutex<LimitBatchScanner>>,
bucket: TableBucket,
projected_schema: SchemaRef,
}
#[pymethods]
impl BatchScanner {
/// The bucket scanned by this batch scanner.
#[getter]
fn bucket(&self) -> TableBucket {
self.bucket.clone()
}
/// Run the scan and return its batch, or `None` once the scanner is spent.
///
/// The scan runs on the first call and is not retried; on error, create a
/// new scanner.
fn next_batch<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let inner = Arc::clone(&self.inner);
future_into_py(py, async move {
let mut scanner = inner.lock().await;
let batch = scanner
.next_batch()
.await
.map_err(|e| FlussError::from_core_error(&e))?;
Python::attach(|py| match batch {
Some(sb) => Ok(Some(Py::new(py, RecordBatch::from_scan_batch(sb))?)),
None => Ok(None),
})
})
}
/// Drain the scanner into all of its batches.
fn collect_all_batches<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let inner = Arc::clone(&self.inner);
future_into_py(py, async move {
let mut scanner = inner.lock().await;
let batches = scanner
.collect_all_batches()
.await
.map_err(|e| FlussError::from_core_error(&e))?;
Python::attach(|py| {
batches
.into_iter()
.map(|sb| Py::new(py, RecordBatch::from_scan_batch(sb)))
.collect::<PyResult<Vec<_>>>()
})
})
}
/// Drain the scanner into a PyArrow Table (empty, with the projected schema,
/// when the scan yields nothing).
fn to_arrow<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
future_into_py(
py,
Self::scan_to_arrow_table(Arc::clone(&self.inner), self.projected_schema.clone()),
)
}
/// Drain the scanner into a Pandas DataFrame.
fn to_pandas<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let inner = Arc::clone(&self.inner);
let projected_schema = self.projected_schema.clone();
future_into_py(py, async move {
let table = Self::scan_to_arrow_table(inner, projected_schema).await?;
Python::attach(|py| table.call_method0(py, "to_pandas"))
})
}
fn __repr__(&self) -> String {
format!("BatchScanner(bucket={})", self.bucket.__str__())
}
}
impl BatchScanner {
fn new(scanner: LimitBatchScanner, bucket: TableBucket, projected_schema: SchemaRef) -> Self {
Self {
inner: Arc::new(Mutex::new(scanner)),
bucket,
projected_schema,
}
}
/// Drain the scanner into one PyArrow Table.
async fn scan_to_arrow_table(
inner: Arc<Mutex<LimitBatchScanner>>,
projected_schema: SchemaRef,
) -> PyResult<Py<PyAny>> {
let mut scanner = inner.lock().await;
let batches = scanner
.collect_all_batches()
.await
.map_err(|e| FlussError::from_core_error(&e))?
.into_iter()
.map(|sb| Arc::new(sb.into_batch()))
.collect();
Python::attach(|py| LogScanner::batches_to_arrow_table(py, batches, &projected_schema))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nanos_to_millis_and_submillis() {
// Simple positive case
assert_eq!(nanos_to_millis_and_submillis(1_500_000), (1, 500_000));
// Exact millisecond boundary
assert_eq!(nanos_to_millis_and_submillis(2_000_000), (2, 0));
// Zero
assert_eq!(nanos_to_millis_and_submillis(0), (0, 0));
// Large value
assert_eq!(
nanos_to_millis_and_submillis(86_400_000_000_000), // 1 day in nanos
(86_400_000, 0)
);
// Negative: -1.5 milliseconds should be (-2 millis, +500_000 nanos)
// Because -1_500_000 nanos = -2ms + 500_000ns
assert_eq!(nanos_to_millis_and_submillis(-1_500_000), (-2, 500_000));
// Negative exact boundary
assert_eq!(nanos_to_millis_and_submillis(-2_000_000), (-2, 0));
// Small negative
assert_eq!(nanos_to_millis_and_submillis(-1), (-1, 999_999));
// Negative with sub-millisecond part
assert_eq!(nanos_to_millis_and_submillis(-500_000), (-1, 500_000));
}
}