1use std::sync::Arc;
7
8use arrow::array::{Array, BooleanArray, Int64Array, StringArray};
9use arrow::compute;
10use arrow::compute::kernels::boolean as arrow_bool;
11use arrow::datatypes::{DataType, Int32Type};
12use arrow::record_batch::RecordBatch;
13
14use crate::error::{QueryError, Result};
15use chronix_core::TombstoneSet;
16
17pub fn time_range_mask(timestamps: &Int64Array, start: i64, end: i64) -> Result<BooleanArray> {
25 let gte = compute::kernels::cmp::gt_eq(timestamps, &Int64Array::new_scalar(start))?;
26 let lte = compute::kernels::cmp::lt_eq(timestamps, &Int64Array::new_scalar(end))?;
27 let combined = arrow_bool::and(>e, <e)?;
28 Ok(combined)
29}
30
31pub fn tag_equality_mask(column: &StringArray, value: &str) -> Result<BooleanArray> {
39 let scalar = StringArray::new_scalar(value);
40 let mask = compute::kernels::cmp::eq(column, &scalar)?;
41 Ok(mask)
42}
43
44pub fn dict_equality_mask(col: &dyn Array, value: &str) -> Result<BooleanArray> {
54 if let Some(dict) = col
57 .as_any()
58 .downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
59 {
60 let scalar = StringArray::new_scalar(value);
61 let mask = compute::kernels::cmp::eq(dict, &scalar)?;
62 return Ok(mask);
63 }
64
65 let casted = compute::cast(col, &DataType::Utf8)?;
67 let str_arr = casted
68 .as_any()
69 .downcast_ref::<StringArray>()
70 .ok_or_else(|| {
71 QueryError::Validation("dictionary column could not be cast to Utf8".into())
72 })?;
73 tag_equality_mask(str_arr, value)
74}
75
76pub fn combine_masks(masks: &[BooleanArray]) -> Result<BooleanArray> {
84 if masks.is_empty() {
85 return Err(QueryError::Validation("no masks to combine".into()));
86 }
87
88 let mut result = masks[0].clone();
89 for mask in &masks[1..] {
90 result = arrow_bool::and(&result, mask)?;
91 }
92 Ok(result)
93}
94
95pub fn apply_filter(batch: &RecordBatch, mask: &BooleanArray) -> Result<RecordBatch> {
101 let filtered_columns: std::result::Result<Vec<_>, _> = batch
102 .columns()
103 .iter()
104 .map(|col| compute::filter(col.as_ref(), mask))
105 .collect();
106 let filtered_columns = filtered_columns?;
107
108 let batch = RecordBatch::try_new(batch.schema(), filtered_columns)?;
109 Ok(batch)
110}
111
112pub fn filter_batch(
126 batch: &RecordBatch,
127 time_start: i64,
128 time_end: i64,
129 tag_filters: &[(&str, &str)],
130) -> Result<RecordBatch> {
131 if batch.num_rows() == 0 {
132 return Ok(batch.clone());
133 }
134
135 let mut masks: Vec<BooleanArray> = Vec::new();
136
137 let time_col = batch
139 .column_by_name(chronix_core::TIME_COLUMN)
140 .ok_or_else(|| QueryError::Validation("'timestamp' column not found".into()))?;
141 let timestamps = time_col
142 .as_any()
143 .downcast_ref::<Int64Array>()
144 .ok_or_else(|| QueryError::Validation("'timestamp' column is not Int64".into()))?;
145
146 if time_start != i64::MIN || time_end != i64::MAX {
148 masks.push(time_range_mask(timestamps, time_start, time_end)?);
149 }
150
151 for (key, value) in tag_filters {
153 if let Some(col) = batch.column_by_name(key) {
154 let mask = match col.data_type() {
158 DataType::Utf8 => col
159 .as_any()
160 .downcast_ref::<StringArray>()
161 .map(|s| tag_equality_mask(s, value)),
162 DataType::Dictionary(_, _) => Some(dict_equality_mask(col.as_ref(), value)),
163 DataType::LargeUtf8 | DataType::Utf8View => {
164 compute::cast(col.as_ref(), &DataType::Utf8)
165 .ok()
166 .and_then(|arr| arr.as_any().downcast_ref::<StringArray>().cloned())
167 .map(|s| tag_equality_mask(&s, value))
168 }
169 _ => None,
170 };
171 match mask {
172 Some(Ok(m)) => masks.push(m),
173 Some(Err(e)) => return Err(e),
174 None => {
175 return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
177 }
178 }
179 } else {
180 return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
183 }
184 }
185
186 if masks.is_empty() {
187 return Ok(batch.clone());
188 }
189
190 let combined = combine_masks(&masks)?;
191 apply_filter(batch, &combined)
192}
193
194pub fn project_batch(batch: &RecordBatch, projection: &[String]) -> Result<RecordBatch> {
202 if projection.is_empty() {
203 return Ok(batch.clone());
204 }
205
206 let mut col_names: Vec<&str> = vec![chronix_core::TIME_COLUMN];
208 for name in projection {
209 if name != chronix_core::TIME_COLUMN {
210 col_names.push(name);
211 }
212 }
213
214 let mut fields = Vec::new();
215 let mut columns: Vec<Arc<dyn Array>> = Vec::new();
216
217 for name in &col_names {
218 if let Ok(idx) = batch.schema().index_of(name) {
219 fields.push(batch.schema().field(idx).clone());
220 columns.push(Arc::clone(batch.column(idx)));
221 }
222 }
223
224 if fields.is_empty() {
225 return Ok(batch.clone());
226 }
227
228 let schema = Arc::new(arrow::datatypes::Schema::new(fields));
229 let projected = RecordBatch::try_new(schema, columns)?;
230 Ok(projected)
231}
232
233pub fn filter_tombstoned(
251 batch: &RecordBatch,
252 measurement: &str,
253 tombstones: &TombstoneSet,
254 tag_col_names: Option<&[&str]>,
255 segment_id: u64,
256) -> Result<RecordBatch> {
257 if tombstones.is_empty() || batch.num_rows() == 0 {
258 return Ok(batch.clone());
259 }
260
261 let schema = batch.schema();
264 let tag_columns: Vec<(usize, &str)> = if let Some(names) = tag_col_names {
265 names
266 .iter()
267 .filter_map(|&name| schema.index_of(name).ok().map(|idx| (idx, name)))
268 .collect()
269 } else {
270 schema
271 .fields()
272 .iter()
273 .enumerate()
274 .filter(|(_, f)| is_string_like(f.data_type()) && f.name() != chronix_core::TIME_COLUMN)
275 .map(|(i, f)| (i, f.name().as_str()))
276 .collect()
277 };
278
279 let ts_col = batch
283 .column_by_name(chronix_core::TIME_COLUMN)
284 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
285 let keep: Vec<bool> = (0..batch.num_rows())
286 .map(|row| {
287 let canonical = compute_series_canonical(measurement, &tag_columns, batch, row);
288 let ts = ts_col.map_or(0, |a| a.value(row));
289 !tombstones.is_tombstoned_in(&canonical, ts, segment_id)
290 })
291 .collect();
292
293 if keep.iter().all(|&k| k) {
295 return Ok(batch.clone());
296 }
297
298 let mask = BooleanArray::from(keep);
299 apply_filter(batch, &mask)
300}
301
302fn is_string_like(dt: &DataType) -> bool {
305 matches!(
306 dt,
307 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View | DataType::Dictionary(_, _)
308 )
309}
310
311fn extract_string_value(arr: &dyn Array, row: usize) -> Option<&str> {
316 if arr.is_null(row) {
317 return None;
318 }
319 if let Some(s) = arr.as_any().downcast_ref::<StringArray>() {
320 return Some(s.value(row));
321 }
322 if let Some(dict) = arr
323 .as_any()
324 .downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
325 {
326 let values = dict.values().as_any().downcast_ref::<StringArray>()?;
327 let key = dict.keys().value(row);
328 return Some(values.value(key as usize));
329 }
330 None
331}
332
333fn compute_series_canonical(
341 measurement: &str,
342 tag_columns: &[(usize, &str)],
343 batch: &RecordBatch,
344 row: usize,
345) -> String {
346 let mut tag_pairs: Vec<(&str, &str)> = tag_columns
347 .iter()
348 .filter_map(|&(col_idx, col_name)| {
349 let arr = batch.column(col_idx);
350 let value = extract_string_value(arr.as_ref(), row)?;
351 Some((col_name, value))
352 })
353 .collect();
354 tag_pairs.sort_unstable_by_key(|&(name, _)| name);
355
356 chronix_core::canonical_from_pairs(measurement, tag_pairs.iter().copied())
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use arrow::array::Float64Array;
366 use arrow::datatypes::{DataType, Field, Schema};
367
368 fn test_batch() -> RecordBatch {
369 let schema = Arc::new(Schema::new(vec![
370 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
371 Field::new("host", DataType::Utf8, false),
372 Field::new("value", DataType::Float64, false),
373 ]));
374
375 let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
376 let hosts = Arc::new(StringArray::from(vec!["a", "b", "a", "b", "a"]));
377 let values = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]));
378
379 RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
380 }
381
382 #[test]
383 fn time_range_filter() {
384 let batch = test_batch();
385 let filtered = filter_batch(&batch, 200, 400, &[]).unwrap();
386 assert_eq!(filtered.num_rows(), 3);
387
388 let times = filtered
389 .column(0)
390 .as_any()
391 .downcast_ref::<Int64Array>()
392 .unwrap();
393 assert_eq!(times.value(0), 200);
394 assert_eq!(times.value(1), 300);
395 assert_eq!(times.value(2), 400);
396 }
397
398 #[test]
399 fn tag_equality_filter() {
400 let batch = test_batch();
401 let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
402 assert_eq!(filtered.num_rows(), 3);
403
404 let hosts = filtered
405 .column(1)
406 .as_any()
407 .downcast_ref::<StringArray>()
408 .unwrap();
409 for i in 0..3 {
410 assert_eq!(hosts.value(i), "a");
411 }
412 }
413
414 #[test]
415 fn combined_time_and_tag_filter() {
416 let batch = test_batch();
417 let filtered = filter_batch(&batch, 200, 400, &[("host", "a")]).unwrap();
418 assert_eq!(filtered.num_rows(), 1);
419
420 let times = filtered
421 .column(0)
422 .as_any()
423 .downcast_ref::<Int64Array>()
424 .unwrap();
425 assert_eq!(times.value(0), 300);
426 }
427
428 #[test]
429 fn filter_empty_batch() {
430 let schema = Arc::new(Schema::new(vec![Field::new(
431 chronix_core::TIME_COLUMN,
432 DataType::Int64,
433 false,
434 )]));
435 let batch = RecordBatch::new_empty(schema);
436 let result = filter_batch(&batch, 0, 100, &[]).unwrap();
437 assert_eq!(result.num_rows(), 0);
438 }
439
440 #[test]
441 fn project_columns() {
442 let batch = test_batch();
443 let projected = project_batch(&batch, &["value".to_string()]).unwrap();
444 assert_eq!(projected.num_columns(), 2); assert_eq!(
446 projected.schema().field(0).name(),
447 chronix_core::TIME_COLUMN
448 );
449 assert_eq!(projected.schema().field(1).name(), "value");
450 }
451
452 #[test]
453 fn project_empty_returns_all() {
454 let batch = test_batch();
455 let projected = project_batch(&batch, &[]).unwrap();
456 assert_eq!(projected.num_columns(), batch.num_columns());
457 }
458
459 #[test]
460 fn time_range_mask_correctness() {
461 let ts = Int64Array::from(vec![10, 20, 30, 40, 50]);
462 let mask = time_range_mask(&ts, 20, 40).unwrap();
463 let expected = BooleanArray::from(vec![false, true, true, true, false]);
464 assert_eq!(mask, expected);
465 }
466
467 #[test]
468 fn tag_equality_mask_correctness() {
469 let col = StringArray::from(vec!["a", "b", "a", "c", "a"]);
470 let mask = tag_equality_mask(&col, "a").unwrap();
471 let expected = BooleanArray::from(vec![true, false, true, false, true]);
472 assert_eq!(mask, expected);
473 }
474
475 #[test]
476 fn combine_two_masks() {
477 let m1 = BooleanArray::from(vec![true, true, false, true]);
478 let m2 = BooleanArray::from(vec![true, false, false, true]);
479 let result = combine_masks(&[m1, m2]).unwrap();
480 let expected = BooleanArray::from(vec![true, false, false, true]);
481 assert_eq!(result, expected);
482 }
483
484 #[test]
485 fn dict_equality_mask_matches() {
486 use arrow::array::DictionaryArray;
487 use arrow::datatypes::Int32Type;
488 let dict: DictionaryArray<Int32Type> = vec!["a", "b", "a", "c", "a"].into_iter().collect();
490 let mask = dict_equality_mask(&dict, "a").unwrap();
491 let expected = BooleanArray::from(vec![true, false, true, false, true]);
492 assert_eq!(mask, expected);
493 }
494
495 #[test]
496 fn dict_equality_mask_no_match() {
497 use arrow::array::DictionaryArray;
498 use arrow::datatypes::Int32Type;
499 let dict: DictionaryArray<Int32Type> = vec!["a", "b", "c"].into_iter().collect();
500 let mask = dict_equality_mask(&dict, "z").unwrap();
501 let expected = BooleanArray::from(vec![false, false, false]);
502 assert_eq!(mask, expected);
503 }
504
505 #[test]
506 fn filter_batch_with_dict_column() {
507 use arrow::array::DictionaryArray;
508 use arrow::datatypes::Int32Type;
509 let timestamps = Int64Array::from(vec![100, 200, 300, 400, 500]);
510 let hosts: DictionaryArray<Int32Type> = vec!["a", "b", "a", "b", "a"].into_iter().collect();
511 let values = arrow::array::Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
512 let schema = Arc::new(Schema::new(vec![
513 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
514 Field::new("host", hosts.data_type().clone(), false),
515 Field::new("value", DataType::Float64, false),
516 ]));
517 let batch = RecordBatch::try_new(
518 schema,
519 vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
520 )
521 .unwrap();
522 let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
523 assert_eq!(filtered.num_rows(), 3);
524 }
525
526 #[test]
527 fn tombstone_filter_with_dict_column() {
528 use arrow::array::DictionaryArray;
529 use arrow::datatypes::Int32Type;
530
531 let timestamps = Int64Array::from(vec![100, 200, 300]);
532 let hosts: DictionaryArray<Int32Type> =
533 vec!["host-a", "host-b", "host-a"].into_iter().collect();
534 let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
535 let schema = Arc::new(Schema::new(vec![
536 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
537 Field::new("host", hosts.data_type().clone(), false),
538 Field::new("value", DataType::Float64, false),
539 ]));
540 let batch = RecordBatch::try_new(
541 schema,
542 vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
543 )
544 .unwrap();
545
546 let mut tombstones = TombstoneSet::new();
548 tombstones.insert(
549 chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
550 "cpu",
551 [("host", "host-a")],
552 ))
553 .with_segments([1]),
554 );
555
556 let result = filter_tombstoned(&batch, "cpu", &tombstones, Some(&["host"]), 1).unwrap();
557 assert_eq!(result.num_rows(), 1);
559 }
560
561 #[test]
562 fn tombstone_filter_heuristic_detects_dict_columns() {
563 use arrow::array::DictionaryArray;
564 use arrow::datatypes::Int32Type;
565
566 let timestamps = Int64Array::from(vec![100, 200]);
567 let hosts: DictionaryArray<Int32Type> = vec!["host-x", "host-y"].into_iter().collect();
568 let values = Float64Array::from(vec![1.0, 2.0]);
569 let schema = Arc::new(Schema::new(vec![
570 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
571 Field::new("host", hosts.data_type().clone(), false),
572 Field::new("value", DataType::Float64, false),
573 ]));
574 let batch = RecordBatch::try_new(
575 schema,
576 vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
577 )
578 .unwrap();
579
580 let mut tombstones = TombstoneSet::new();
582 tombstones.insert(
583 chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
584 "cpu",
585 [("host", "host-x")],
586 ))
587 .with_segments([1]),
588 );
589
590 let result = filter_tombstoned(&batch, "cpu", &tombstones, None, 1).unwrap();
591 assert_eq!(result.num_rows(), 1);
592 }
593}