laminar_core/lookup/source/
mod.rs1use std::future::Future;
4use std::time::Duration;
5
6use arrow::compute::filter_record_batch;
7use arrow_array::BooleanArray;
8use arrow_array::RecordBatch;
9use arrow_schema::SchemaRef;
10
11use crate::lookup::predicate::{split_predicates, Predicate, ScalarValue, SourceCapabilities};
12
13pub type ColumnId = u32;
15
16#[derive(Debug, thiserror::Error)]
18pub enum LookupError {
19 #[error("connection failed: {0}")]
21 Connection(String),
22
23 #[error("query failed: {0}")]
25 Query(String),
26
27 #[error("timeout after {0:?}")]
29 Timeout(Duration),
30
31 #[error("not available: {0}")]
33 NotAvailable(String),
34
35 #[error("internal: {0}")]
37 Internal(String),
38}
39
40#[derive(Debug, Clone, Default)]
46pub struct LookupSourceCapabilities {
47 pub supports_predicate_pushdown: bool,
49 pub supports_projection_pushdown: bool,
51 pub supports_batch_lookup: bool,
53 pub max_batch_size: usize,
55}
56
57impl LookupSourceCapabilities {
58 #[must_use]
60 pub fn none() -> Self {
61 Self::default()
62 }
63}
64
65pub fn projection_names(
73 schema: &SchemaRef,
74 projection: &[ColumnId],
75) -> Result<Vec<String>, LookupError> {
76 if projection.is_empty() {
77 return Ok(schema.fields().iter().map(|f| f.name().clone()).collect());
78 }
79 projection
80 .iter()
81 .map(|&c| {
82 schema
83 .fields()
84 .get(c as usize)
85 .map(|f| f.name().clone())
86 .ok_or_else(|| LookupError::Internal(format!("projection column {c} out of range")))
87 })
88 .collect()
89}
90
91pub trait LookupSource: Send + Sync {
104 fn query(
110 &self,
111 keys: &[&[u8]],
112 predicates: &[Predicate],
113 projection: &[ColumnId],
114 ) -> impl Future<Output = Result<Vec<Option<RecordBatch>>, LookupError>> + Send;
115
116 fn capabilities(&self) -> LookupSourceCapabilities;
118
119 fn source_name(&self) -> &'static str;
121
122 fn schema(&self) -> SchemaRef;
124
125 fn estimated_row_count(&self) -> Option<u64> {
127 None
128 }
129
130 fn health_check(&self) -> impl Future<Output = Result<(), LookupError>> + Send {
132 async { Ok(()) }
133 }
134}
135
136#[async_trait::async_trait]
142pub trait LookupSourceDyn: Send + Sync {
143 async fn query_batch(
145 &self,
146 keys: &[&[u8]],
147 predicates: &[Predicate],
148 projection: &[ColumnId],
149 ) -> Result<Vec<Option<RecordBatch>>, LookupError>;
150
151 fn schema(&self) -> SchemaRef;
153}
154
155#[async_trait::async_trait]
156impl<T: LookupSource> LookupSourceDyn for T {
157 async fn query_batch(
158 &self,
159 keys: &[&[u8]],
160 predicates: &[Predicate],
161 projection: &[ColumnId],
162 ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
163 self.query(keys, predicates, projection).await
164 }
165
166 fn schema(&self) -> SchemaRef {
167 LookupSource::schema(self)
168 }
169}
170
171pub struct PushdownAdapter<S> {
177 inner: S,
178 column_capabilities: SourceCapabilities,
179}
180
181impl<S: LookupSource> PushdownAdapter<S> {
182 pub fn new(inner: S, column_capabilities: SourceCapabilities) -> Self {
188 Self {
189 inner,
190 column_capabilities,
191 }
192 }
193
194 fn split(&self, predicates: &[Predicate]) -> (Vec<Predicate>, Vec<Predicate>) {
196 let split = split_predicates(predicates.to_vec(), &self.column_capabilities);
197 (split.pushable, split.local)
198 }
199}
200
201fn compare_column_scalar(
205 batch: &RecordBatch,
206 column: &str,
207 value: &ScalarValue,
208 cmp_fn: fn(
209 &dyn arrow_array::Datum,
210 &dyn arrow_array::Datum,
211 ) -> Result<BooleanArray, arrow::error::ArrowError>,
212) -> Option<BooleanArray> {
213 use arrow_array::types::{TimestampMicrosecondType, TimestampMillisecondType};
214 use arrow_array::{Float64Array, Int64Array, PrimitiveArray, Scalar, StringArray};
215
216 let idx = batch.schema().index_of(column).ok()?;
217 let col = batch.column(idx);
218 match value {
219 ScalarValue::Int64(v) => cmp_fn(col, &Scalar::new(Int64Array::from(vec![*v]))).ok(),
220 ScalarValue::Float64(v) => cmp_fn(col, &Scalar::new(Float64Array::from(vec![*v]))).ok(),
221 ScalarValue::Utf8(v) => cmp_fn(col, &Scalar::new(StringArray::from(vec![v.as_str()]))).ok(),
222 ScalarValue::Bool(v) => cmp_fn(col, &Scalar::new(BooleanArray::from(vec![*v]))).ok(),
223 ScalarValue::Timestamp(us) => {
224 if col
225 .as_any()
226 .is::<PrimitiveArray<TimestampMicrosecondType>>()
227 {
228 let scalar = PrimitiveArray::<TimestampMicrosecondType>::from(vec![*us]);
229 cmp_fn(col, &Scalar::new(scalar)).ok()
230 } else if col
231 .as_any()
232 .is::<PrimitiveArray<TimestampMillisecondType>>()
233 {
234 let scalar = PrimitiveArray::<TimestampMillisecondType>::from(vec![*us / 1000]);
235 cmp_fn(col, &Scalar::new(scalar)).ok()
236 } else {
237 None
238 }
239 }
240 _ => None,
241 }
242}
243
244fn evaluate_predicate(batch: &RecordBatch, predicate: &Predicate) -> Option<BooleanArray> {
246 use arrow::compute::kernels::cmp;
247
248 match predicate {
249 Predicate::Eq { column, value } => compare_column_scalar(batch, column, value, cmp::eq),
250 Predicate::NotEq { column, value } => compare_column_scalar(batch, column, value, cmp::neq),
251 Predicate::Lt { column, value } => compare_column_scalar(batch, column, value, cmp::lt),
252 Predicate::LtEq { column, value } => {
253 compare_column_scalar(batch, column, value, cmp::lt_eq)
254 }
255 Predicate::Gt { column, value } => compare_column_scalar(batch, column, value, cmp::gt),
256 Predicate::GtEq { column, value } => {
257 compare_column_scalar(batch, column, value, cmp::gt_eq)
258 }
259 Predicate::IsNull { column } => {
260 let idx = batch.schema().index_of(column).ok()?;
261 let col = batch.column(idx);
262 Some(arrow::compute::is_null(col).ok()?)
263 }
264 Predicate::IsNotNull { column } => {
265 let idx = batch.schema().index_of(column).ok()?;
266 let col = batch.column(idx);
267 Some(arrow::compute::is_not_null(col).ok()?)
268 }
269 Predicate::In { column, values } => {
270 let idx = batch.schema().index_of(column).ok()?;
271 let col = batch.column(idx);
272 let mut mask: Option<BooleanArray> = None;
273 for v in values {
274 let eq_mask = evaluate_predicate(
275 batch,
276 &Predicate::Eq {
277 column: column.clone(),
278 value: v.clone(),
279 },
280 )?;
281 mask = Some(match mask {
282 Some(existing) => arrow::compute::or(&existing, &eq_mask).ok()?,
283 None => eq_mask,
284 });
285 }
286 mask.or_else(|| Some(BooleanArray::from(vec![false; col.len()])))
287 }
288 }
289}
290
291fn apply_local_predicates(batch: &RecordBatch, predicates: &[Predicate]) -> Option<RecordBatch> {
293 if predicates.is_empty() {
294 return Some(batch.clone());
295 }
296 let mut combined: Option<BooleanArray> = None;
297 for pred in predicates {
298 let mask = evaluate_predicate(batch, pred)?;
299 combined = Some(match combined {
300 Some(existing) => arrow::compute::and(&existing, &mask).ok()?,
301 None => mask,
302 });
303 }
304 match combined {
305 Some(mask) => filter_record_batch(batch, &mask).ok(),
306 None => Some(batch.clone()),
307 }
308}
309
310impl<S: LookupSource> LookupSource for PushdownAdapter<S> {
311 async fn query(
312 &self,
313 keys: &[&[u8]],
314 predicates: &[Predicate],
315 projection: &[ColumnId],
316 ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
317 let (pushable, local) = self.split(predicates);
318 let results = self.inner.query(keys, &pushable, projection).await?;
319
320 if local.is_empty() {
321 return Ok(results);
322 }
323
324 Ok(results
325 .into_iter()
326 .map(|opt| {
327 opt.and_then(|batch| {
328 let filtered = apply_local_predicates(&batch, &local)?;
329 if filtered.num_rows() == 0 {
330 None
331 } else {
332 Some(filtered)
333 }
334 })
335 })
336 .collect())
337 }
338
339 fn capabilities(&self) -> LookupSourceCapabilities {
340 self.inner.capabilities()
341 }
342
343 fn source_name(&self) -> &'static str {
344 self.inner.source_name()
345 }
346
347 fn schema(&self) -> SchemaRef {
348 self.inner.schema()
349 }
350
351 fn estimated_row_count(&self) -> Option<u64> {
352 self.inner.estimated_row_count()
353 }
354
355 fn health_check(&self) -> impl Future<Output = Result<(), LookupError>> + Send {
356 self.inner.health_check()
357 }
358}
359
360#[cfg(test)]
361#[allow(clippy::disallowed_types)] mod tests;