| # 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. |
| |
| from typing import Any, List, Optional, Tuple |
| |
| import pyarrow as pa |
| import pyarrow.dataset as ds |
| from pyarrow import RecordBatch |
| |
| from pypaimon.common.file_io import FileIO |
| from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader |
| from pypaimon.read.reader.lance_utils import to_lance_specified |
| from pypaimon.schema.data_types import DataField, PyarrowFieldParser |
| from pypaimon.table.special_fields import SpecialFields |
| |
| |
| class FormatLanceReader(RecordBatchReader): |
| """ |
| A Format Reader that reads record batch from a Lance file using PyArrow, |
| and filters it based on the provided predicate and projection. |
| """ |
| |
| # row_indices: from IndexedSplit (ANN vector search), discrete local row offsets within the file. |
| # shard_range: from SlicedSplit (parallel shard scan), a contiguous [start, end) row range within the file. |
| def __init__(self, file_io: FileIO, file_path: str, read_fields: List[DataField], |
| push_down_predicate: Any, batch_size: int = 1024, |
| row_indices: Optional[List[int]] = None, |
| shard_range: Optional[Tuple[int, int]] = None): |
| """Initialize Lance reader.""" |
| import lance |
| |
| self._read_field_names = [f.name for f in read_fields] |
| |
| file_path_for_lance, storage_options = to_lance_specified(file_io, file_path) |
| |
| # Read file metadata (footer only) to get actual schema |
| file_schema_names = set( |
| lance.file.LanceFileReader( |
| file_path_for_lance, storage_options=storage_options |
| ).metadata().schema.names |
| ) |
| self.existing_fields = [f.name for f in read_fields |
| if f.name in file_schema_names] |
| self.missing_fields = [f.name for f in read_fields |
| if f.name not in file_schema_names] |
| |
| # Read data with column pruning |
| columns_for_lance = self.existing_fields if self.existing_fields else None |
| lance_reader = lance.file.LanceFileReader( |
| file_path_for_lance, |
| storage_options=storage_options, |
| columns=columns_for_lance) |
| if row_indices is not None: |
| reader_results = lance_reader.take_rows(row_indices) |
| elif shard_range is not None: |
| reader_results = lance_reader.read_range(shard_range[0], shard_range[1] - shard_range[0]) |
| else: |
| reader_results = lance_reader.read_all() |
| |
| pa_table = reader_results.to_table() |
| |
| # Precompute output schema for missing fields |
| if self.missing_fields: |
| output_schema = PyarrowFieldParser.from_paimon_schema(read_fields) |
| self._missing_out_fields = [] |
| for name in self.missing_fields: |
| idx = output_schema.get_field_index(name) |
| col_type = output_schema.field(idx).type if idx >= 0 else pa.null() |
| nullable = not SpecialFields.is_system_field(name) |
| self._missing_out_fields.append(pa.field(name, col_type, nullable=nullable)) |
| |
| if push_down_predicate is not None: |
| in_memory_dataset = ds.InMemoryDataset(pa_table) |
| scanner = in_memory_dataset.scanner(filter=push_down_predicate, batch_size=batch_size) |
| self.reader = scanner.to_reader() |
| else: |
| self.reader = iter(pa_table.to_batches(max_chunksize=batch_size)) |
| |
| def read_arrow_batch(self) -> Optional[RecordBatch]: |
| try: |
| # Handle both scanner reader and iterator |
| if hasattr(self.reader, 'read_next_batch'): |
| batch = self.reader.read_next_batch() |
| else: |
| batch = next(self.reader) |
| |
| if not self.missing_fields: |
| return batch |
| |
| # Reconstruct the batch with all fields in the correct order |
| all_columns = [] |
| out_fields = [] |
| for field_name in self._read_field_names: |
| if field_name in self.existing_fields: |
| col_idx = self.existing_fields.index(field_name) |
| all_columns.append(batch.column(col_idx)) |
| out_fields.append(batch.schema.field(col_idx)) |
| else: |
| miss_idx = self.missing_fields.index(field_name) |
| out_field = self._missing_out_fields[miss_idx] |
| all_columns.append(pa.nulls(batch.num_rows, type=out_field.type)) |
| out_fields.append(out_field) |
| return pa.RecordBatch.from_arrays(all_columns, schema=pa.schema(out_fields)) |
| |
| except StopIteration: |
| return None |
| |
| def close(self): |
| if self.reader is not None: |
| self.reader = None |