Skip to main content

sift_query_core/query/
bucket.rs

1use std::marker::PhantomData;
2
3use crate::{FieldId, FieldRef, IntoScalarValue, ParamError, ScalarValue, SearchEntity};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum TimeBucketUnit {
7    Hour,
8    Day,
9    Week,
10    Month,
11}
12
13impl TimeBucketUnit {
14    #[must_use]
15    pub const fn sql_name(self) -> &'static str {
16        match self {
17            Self::Hour => "hour",
18            Self::Day => "day",
19            Self::Week => "week",
20            Self::Month => "month",
21        }
22    }
23}
24
25#[derive(Debug, Clone)]
26pub struct TimeBucketRequest<E: SearchEntity> {
27    field: FieldId,
28    schema_fingerprint: u64,
29    unit: TimeBucketUnit,
30    timezone: &'static str,
31    _entity: PhantomData<fn() -> E>,
32}
33
34impl<E: SearchEntity> TimeBucketRequest<E> {
35    pub fn new<T, K>(
36        field: FieldRef<E, T, K>,
37        unit: TimeBucketUnit,
38        timezone: &'static str,
39    ) -> Result<Self, ParamError> {
40        if E::META.field(field.id()).is_none() {
41            return Err(ParamError::UnknownField(format!("#{}", field.id().0)));
42        }
43        if timezone.is_empty()
44            || timezone.contains('\'')
45            || timezone.contains(';')
46            || timezone.contains("--")
47            || timezone.contains("/*")
48        {
49            return Err(ParamError::InvalidValue {
50                field: "timezone".to_owned(),
51                reason: "timezone contains an unsafe token".to_owned(),
52            });
53        }
54        Ok(Self {
55            field: field.id(),
56            schema_fingerprint: E::META.schema_fingerprint,
57            unit,
58            timezone,
59            _entity: PhantomData,
60        })
61    }
62
63    #[must_use]
64    pub const fn field_id(&self) -> FieldId {
65        self.field
66    }
67    #[must_use]
68    pub const fn schema_fingerprint(&self) -> u64 {
69        self.schema_fingerprint
70    }
71    #[must_use]
72    pub const fn unit(&self) -> TimeBucketUnit {
73        self.unit
74    }
75    #[must_use]
76    pub const fn timezone(&self) -> &'static str {
77        self.timezone
78    }
79}
80
81#[derive(Debug, Clone)]
82pub struct NumericBucketRequest<E: SearchEntity> {
83    field: FieldId,
84    schema_fingerprint: u64,
85    boundaries: Vec<ScalarValue>,
86    _entity: PhantomData<fn() -> E>,
87}
88
89impl<E: SearchEntity> NumericBucketRequest<E> {
90    pub fn new<T, K, V, I>(field: FieldRef<E, T, K>, boundaries: I) -> Result<Self, ParamError>
91    where
92        V: IntoScalarValue,
93        I: IntoIterator<Item = V>,
94    {
95        let boundaries = boundaries
96            .into_iter()
97            .map(IntoScalarValue::into_scalar_value)
98            .collect::<Vec<_>>();
99        if boundaries.is_empty() || boundaries.len() > 1_000 {
100            return Err(ParamError::InvalidValue {
101                field: "bucket.boundaries".to_owned(),
102                reason: "boundaries must contain between 1 and 1000 values".to_owned(),
103            });
104        }
105        let value_type = boundaries[0].value_type();
106        let Some(field_meta) = E::META.field(field.id()) else {
107            return Err(ParamError::UnknownField(format!("#{}", field.id().0)));
108        };
109        if !matches!(
110            value_type,
111            crate::ValueType::I32
112                | crate::ValueType::I64
113                | crate::ValueType::F32
114                | crate::ValueType::F64
115        ) || field_meta.value_type != value_type
116            || boundaries
117                .iter()
118                .any(|value| value.value_type() != value_type)
119        {
120            return Err(ParamError::InvalidValue {
121                field: "bucket.boundaries".to_owned(),
122                reason: "numeric boundaries must use one numeric value type".to_owned(),
123            });
124        }
125        if boundaries
126            .windows(2)
127            .any(|pair| !scalar_lt(&pair[0], &pair[1]))
128        {
129            return Err(ParamError::InvalidValue {
130                field: "bucket.boundaries".to_owned(),
131                reason: "boundaries must be strictly increasing".to_owned(),
132            });
133        }
134        Ok(Self {
135            field: field.id(),
136            schema_fingerprint: E::META.schema_fingerprint,
137            boundaries,
138            _entity: PhantomData,
139        })
140    }
141
142    #[must_use]
143    pub const fn field_id(&self) -> FieldId {
144        self.field
145    }
146    #[must_use]
147    pub const fn schema_fingerprint(&self) -> u64 {
148        self.schema_fingerprint
149    }
150    #[must_use]
151    pub fn boundaries(&self) -> &[ScalarValue] {
152        &self.boundaries
153    }
154}
155
156#[derive(Debug, Clone)]
157pub enum BucketRequest<E: SearchEntity> {
158    Time(TimeBucketRequest<E>),
159    Numeric(NumericBucketRequest<E>),
160}
161
162impl<E: SearchEntity> BucketRequest<E> {
163    #[must_use]
164    pub const fn field_id(&self) -> FieldId {
165        match self {
166            Self::Time(request) => request.field_id(),
167            Self::Numeric(request) => request.field_id(),
168        }
169    }
170
171    #[must_use]
172    pub const fn schema_fingerprint(&self) -> u64 {
173        match self {
174            Self::Time(request) => request.schema_fingerprint(),
175            Self::Numeric(request) => request.schema_fingerprint(),
176        }
177    }
178}
179
180#[derive(Debug, Clone, PartialEq)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize))]
182pub struct BucketEntry {
183    pub label: String,
184    pub count: i64,
185}
186
187#[derive(Debug, Clone, PartialEq)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize))]
189pub struct BucketResult {
190    pub field: &'static str,
191    pub buckets: Vec<BucketEntry>,
192}
193
194fn scalar_lt(left: &ScalarValue, right: &ScalarValue) -> bool {
195    match (left, right) {
196        (ScalarValue::I32(left), ScalarValue::I32(right)) => left < right,
197        (ScalarValue::I64(left), ScalarValue::I64(right)) => left < right,
198        (ScalarValue::F32(left), ScalarValue::F32(right)) => left < right,
199        (ScalarValue::F64(left), ScalarValue::F64(right)) => left < right,
200        _ => false,
201    }
202}
203
204#[doc(hidden)]
205pub fn numeric_label(boundaries: &[ScalarValue], index: usize) -> String {
206    if index == 0 {
207        format!("<{}", boundaries[0].to_external_text())
208    } else if index < boundaries.len() {
209        format!(
210            "[{}, {})",
211            boundaries[index - 1].to_external_text(),
212            boundaries[index].to_external_text()
213        )
214    } else {
215        format!(">={}", boundaries[boundaries.len() - 1].to_external_text())
216    }
217}