1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use arrow::array::{
11 ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
12};
13use arrow::datatypes::{DataType, Field, Schema};
14use arrow::record_batch::RecordBatch;
15
16use chronix_core::types::{FieldValue, Point};
17
18use crate::error::{QueryError, Result};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum ColumnRole {
23 Timestamp,
24 Tag,
25 Field(FieldType),
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum FieldType {
31 I64,
32 U64,
33 F64,
34 Bool,
35 String,
36 Decimal(u8),
38 Histogram,
40}
41
42#[derive(Debug)]
44struct Column {
45 name: String,
46 role: ColumnRole,
47}
48
49#[must_use]
59fn role_metadata(role: ColumnRole) -> std::collections::HashMap<String, String> {
60 let name = match role {
62 ColumnRole::Timestamp => "timestamp",
63 ColumnRole::Tag => "tag",
64 ColumnRole::Field(_) => "field",
65 };
66 [("role".to_string(), name.to_string())].into()
67}
68
69pub fn points_to_record_batch(points: &[Point]) -> Result<RecordBatch> {
83 if points.is_empty() {
84 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
85 }
86
87 let columns = discover_columns(points);
89
90 let mut arrow_arrays: Vec<ArrayRef> = Vec::with_capacity(columns.len());
92 let mut fields: Vec<Field> = Vec::with_capacity(columns.len());
93
94 for col in &columns {
95 let meta = role_metadata(col.role);
100 match col.role {
101 ColumnRole::Timestamp => {
102 let values: Vec<i64> = points.iter().map(Point::timestamp).collect();
103 arrow_arrays.push(Arc::new(Int64Array::from(values)));
104 fields
105 .push(Field::new(&col.name, DataType::Int64, true).with_metadata(meta.clone()));
106 }
107 ColumnRole::Tag => {
108 let values: Vec<Option<&str>> = points
109 .iter()
110 .map(|p| p.series_key().tag(&col.name))
111 .collect();
112 arrow_arrays.push(Arc::new(StringArray::from(values)));
113 fields
114 .push(Field::new(&col.name, DataType::Utf8, true).with_metadata(meta.clone()));
115 }
116 ColumnRole::Field(ft) => match ft {
117 FieldType::I64 => {
118 let values: Vec<Option<i64>> = points
119 .iter()
120 .map(|p| match p.field(&col.name) {
121 Some(FieldValue::I64(v)) => Some(*v),
122 _ => None,
123 })
124 .collect();
125 arrow_arrays.push(Arc::new(Int64Array::from(values)));
126 fields.push(
127 Field::new(&col.name, DataType::Int64, true).with_metadata(meta.clone()),
128 );
129 }
130 FieldType::U64 => {
131 let values: Vec<Option<u64>> = points
132 .iter()
133 .map(|p| match p.field(&col.name) {
134 Some(FieldValue::U64(v)) => Some(*v),
135 _ => None,
136 })
137 .collect();
138 arrow_arrays.push(Arc::new(UInt64Array::from(values)));
139 fields.push(
140 Field::new(&col.name, DataType::UInt64, true).with_metadata(meta.clone()),
141 );
142 }
143 FieldType::F64 => {
144 let values: Vec<Option<f64>> = points
145 .iter()
146 .map(|p| match p.field(&col.name) {
147 Some(FieldValue::F64(v)) => Some(*v),
148 _ => None,
149 })
150 .collect();
151 arrow_arrays.push(Arc::new(Float64Array::from(values)));
152 fields.push(
153 Field::new(&col.name, DataType::Float64, true).with_metadata(meta.clone()),
154 );
155 }
156 FieldType::Bool => {
157 let values: Vec<Option<bool>> = points
158 .iter()
159 .map(|p| match p.field(&col.name) {
160 Some(FieldValue::Bool(v)) => Some(*v),
161 _ => None,
162 })
163 .collect();
164 arrow_arrays.push(Arc::new(BooleanArray::from(values)));
165 fields.push(
166 Field::new(&col.name, DataType::Boolean, true).with_metadata(meta.clone()),
167 );
168 }
169 FieldType::Histogram => {
170 let values: Vec<Option<Vec<u8>>> = points
174 .iter()
175 .map(|p| match p.field(&col.name) {
176 Some(FieldValue::Histogram(h)) => {
177 postcard::to_allocvec(h.as_ref()).ok()
178 }
179 _ => None,
180 })
181 .collect();
182 let refs: Vec<Option<&[u8]>> = values.iter().map(|v| v.as_deref()).collect();
183 arrow_arrays.push(Arc::new(arrow::array::BinaryArray::from(refs)));
184 fields.push(
185 Field::new(&col.name, DataType::Binary, true).with_metadata(meta.clone()),
186 );
187 }
188 FieldType::Decimal(scale) => {
189 let values: Vec<Option<i128>> = points
195 .iter()
196 .map(|p| match p.field(&col.name) {
197 Some(FieldValue::Decimal(d)) => {
198 d.rescale(scale).ok().map(|v| v.mantissa())
199 }
200 _ => None,
201 })
202 .collect();
203 let dt = DataType::Decimal128(
204 chronix_core::DECIMAL_PRECISION,
205 i8::try_from(scale).unwrap_or(0),
206 );
207 let array = Decimal128Array::from(values)
208 .with_precision_and_scale(
209 chronix_core::DECIMAL_PRECISION,
210 i8::try_from(scale).unwrap_or(0),
211 )
212 .map_err(QueryError::from)?;
213 arrow_arrays.push(Arc::new(array));
214 fields.push(Field::new(&col.name, dt, true).with_metadata(meta.clone()));
215 }
216 FieldType::String => {
217 let values: Vec<Option<String>> = points
218 .iter()
219 .map(|p| match p.field(&col.name) {
220 Some(FieldValue::String(v)) => Some(v.clone()),
221 _ => None,
222 })
223 .collect();
224 let refs: Vec<Option<&str>> = values.iter().map(|v| v.as_deref()).collect();
225 arrow_arrays.push(Arc::new(StringArray::from(refs)));
226 fields.push(
227 Field::new(&col.name, DataType::Utf8, true).with_metadata(meta.clone()),
228 );
229 }
230 },
231 }
232 }
233
234 let schema = Arc::new(Schema::new(fields));
235 RecordBatch::try_new(schema, arrow_arrays).map_err(QueryError::from)
236}
237
238fn discover_columns(points: &[Point]) -> Vec<Column> {
243 let mut tag_keys: BTreeSet<String> = BTreeSet::new();
244 let mut field_defs: BTreeMap<String, FieldType> = BTreeMap::new();
245
246 for p in points {
247 for key in p.series_key().tag_keys() {
248 tag_keys.insert(key.to_string());
249 }
250 for (name, value) in p.fields() {
251 let ft = match value {
252 FieldValue::I64(_) => FieldType::I64,
253 FieldValue::U64(_) => FieldType::U64,
254 FieldValue::F64(_) => FieldType::F64,
255 FieldValue::Bool(_) => FieldType::Bool,
256 FieldValue::String(_) => FieldType::String,
257 FieldValue::Decimal(d) => FieldType::Decimal(d.scale()),
258 FieldValue::Histogram(_) => FieldType::Histogram,
259 };
260 field_defs
264 .entry(name.to_string())
265 .and_modify(|existing| {
266 if *existing != ft {
267 *existing = match (*existing, ft) {
271 (FieldType::I64, FieldType::F64)
272 | (FieldType::F64, FieldType::I64)
273 | (FieldType::U64, FieldType::F64)
274 | (FieldType::F64, FieldType::U64)
275 | (FieldType::I64, FieldType::U64)
276 | (FieldType::U64, FieldType::I64) => FieldType::F64,
277 (FieldType::Decimal(a), FieldType::Decimal(b)) => {
282 FieldType::Decimal(a.max(b))
283 }
284 _ => FieldType::String,
285 };
286 }
287 })
288 .or_insert(ft);
289 }
290 }
291
292 let mut columns = Vec::new();
293
294 columns.push(Column {
296 name: chronix_core::TIME_COLUMN.to_string(),
297 role: ColumnRole::Timestamp,
298 });
299
300 for key in &tag_keys {
302 columns.push(Column {
303 name: key.clone(),
304 role: ColumnRole::Tag,
305 });
306 }
307
308 for (name, ft) in &field_defs {
310 columns.push(Column {
311 name: name.clone(),
312 role: ColumnRole::Field(*ft),
313 });
314 }
315
316 columns
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::collections::BTreeMap;
323
324 use chronix_core::types::SeriesKey;
325
326 fn make_point(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
327 let tags: BTreeMap<String, String> = [("host".to_string(), host.to_string())]
328 .into_iter()
329 .collect();
330 let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
331 let fields: BTreeMap<String, FieldValue> = [("value".to_string(), FieldValue::F64(value))]
332 .into_iter()
333 .collect();
334 Point::new(series_key, fields, ts).unwrap()
335 }
336
337 #[test]
338 fn empty_points_returns_empty_batch() {
339 let batch = points_to_record_batch(&[]).unwrap();
340 assert_eq!(batch.num_rows(), 0);
341 }
342
343 #[test]
344 fn single_point_roundtrip() {
345 let points = vec![make_point("cpu", "server-1", 1000, 42.5)];
346 let batch = points_to_record_batch(&points).unwrap();
347
348 assert_eq!(batch.num_rows(), 1);
349 assert_eq!(batch.num_columns(), 3); let ts = batch
352 .column(0)
353 .as_any()
354 .downcast_ref::<Int64Array>()
355 .unwrap();
356 assert_eq!(ts.value(0), 1000);
357
358 let host = batch
359 .column(1)
360 .as_any()
361 .downcast_ref::<StringArray>()
362 .unwrap();
363 assert_eq!(host.value(0), "server-1");
364
365 let value = batch
366 .column(2)
367 .as_any()
368 .downcast_ref::<Float64Array>()
369 .unwrap();
370 assert!((value.value(0) - 42.5).abs() < f64::EPSILON);
371 }
372
373 #[test]
374 #[allow(clippy::cast_precision_loss)]
375 fn multiple_points_same_series() {
376 let points: Vec<Point> = (0..10)
377 .map(|i| make_point("cpu", "a", i * 100, i as f64))
378 .collect();
379 let batch = points_to_record_batch(&points).unwrap();
380
381 assert_eq!(batch.num_rows(), 10);
382 assert_eq!(batch.num_columns(), 3);
383 }
384
385 #[test]
386 fn multiple_series_different_tags() {
387 let points = vec![
388 make_point("cpu", "a", 100, 1.0),
389 make_point("cpu", "b", 200, 2.0),
390 ];
391 let batch = points_to_record_batch(&points).unwrap();
392
393 assert_eq!(batch.num_rows(), 2);
394 let host = batch
395 .column(1)
396 .as_any()
397 .downcast_ref::<StringArray>()
398 .unwrap();
399 assert_eq!(host.value(0), "a");
400 assert_eq!(host.value(1), "b");
401 }
402
403 #[test]
404 fn multiple_field_types() {
405 let tags: BTreeMap<String, String> = BTreeMap::new();
406 let key = SeriesKey::new("m".to_string(), tags).unwrap();
407 let fields: BTreeMap<String, FieldValue> = [
408 ("f_i64".to_string(), FieldValue::I64(42)),
409 ("f_u64".to_string(), FieldValue::U64(99)),
410 ("f_f64".to_string(), FieldValue::F64(3.125)),
411 ("f_bool".to_string(), FieldValue::Bool(true)),
412 ("f_str".to_string(), FieldValue::String("hello".into())),
413 ]
414 .into_iter()
415 .collect();
416 let point = Point::new(key, fields, 1000).unwrap();
417
418 let batch = points_to_record_batch(&[point]).unwrap();
419 assert_eq!(batch.num_rows(), 1);
420 assert_eq!(batch.num_columns(), 6);
422
423 let schema = batch.schema();
425 assert_eq!(schema.field(0).data_type(), &DataType::Int64); assert_eq!(schema.field(1).data_type(), &DataType::Boolean); assert_eq!(schema.field(2).data_type(), &DataType::Float64); assert_eq!(schema.field(3).data_type(), &DataType::Int64); assert_eq!(schema.field(4).data_type(), &DataType::Utf8); assert_eq!(schema.field(5).data_type(), &DataType::UInt64); }
432
433 #[test]
434 fn schema_order_is_deterministic() {
435 let tags: BTreeMap<String, String> = [
436 ("dc".to_string(), "us".to_string()),
437 ("host".to_string(), "a".to_string()),
438 ]
439 .into_iter()
440 .collect();
441 let key = SeriesKey::new("cpu".to_string(), tags).unwrap();
442 let fields: BTreeMap<String, FieldValue> = [
443 ("z_field".to_string(), FieldValue::F64(1.0)),
444 ("a_field".to_string(), FieldValue::F64(2.0)),
445 ]
446 .into_iter()
447 .collect();
448 let point = Point::new(key, fields, 1000).unwrap();
449
450 let batch = points_to_record_batch(&[point]).unwrap();
451 let schema = batch.schema();
452
453 assert_eq!(schema.field(0).name(), chronix_core::TIME_COLUMN);
455 assert_eq!(schema.field(1).name(), "dc");
456 assert_eq!(schema.field(2).name(), "host");
457 assert_eq!(schema.field(3).name(), "a_field");
458 assert_eq!(schema.field(4).name(), "z_field");
459 }
460}