Skip to main content

hidane_core/
transform.rs

1//! Field transforms (`serverTimestamp`, `increment`, `maximum`, `minimum`, `arrayUnion`,
2//! `arrayRemove`).
3//!
4//! Semantics as measured on the official emulator v1.22.0 (`tools/oracle/transforms.py`):
5//!
6//! - `REQUEST_TIME` is the commit's time truncated to milliseconds, the same for every field of
7//!   the commit.
8//! - `increment`: a missing or non-numeric field becomes the operand; two integers add with
9//!   saturation at `i64::MIN` / `i64::MAX`; otherwise both are added as doubles.
10//! - `maximum` / `minimum`: a missing or non-numeric field becomes the operand; equal values
11//!   (`3` and `3.0`, `-0.0` and `0.0`) keep the stored value and its type; NaN on either side
12//!   gives NaN.
13//! - `arrayUnion` appends operand elements not already present; `arrayRemove` removes every
14//!   element equal to an operand. Equality is Firestore value equality (`1 == 1.0`, NaN equals
15//!   NaN, maps and arrays compare element-wise). A field that is not an array starts empty, so
16//!   `arrayRemove` on it leaves an empty array.
17//! - Transforms run after the write's update, in order; several may target the same field, and
18//!   a path through a non-map value replaces that value with a map.
19
20use std::cmp::Ordering;
21
22use hidane_proto::google::firestore::v1::{
23    ArrayValue, Value,
24    document_transform::{
25        FieldTransform,
26        field_transform::{ServerValue, TransformType},
27    },
28    value::ValueType,
29};
30use prost_types::Timestamp;
31
32use crate::{
33    field_path::{FieldPath, Fields, get, set},
34    order::{Number, compare, compare_number},
35};
36
37/// Applies `transform` to `fields` and returns its transform result. Errors are the official
38/// `INVALID_ARGUMENT` messages.
39pub fn apply(
40    fields: &mut Fields,
41    transform: &FieldTransform,
42    commit_time: &Timestamp,
43) -> Result<Value, String> {
44    let path = FieldPath::parse(&transform.field_path)?;
45    apply_at(fields, &path, transform, commit_time)
46}
47
48/// Applies `transform` at `path` rather than at its own field path, for a caller that maps the
49/// path first (a transform of `__name__` writes the field `__key__` on the official emulator).
50pub fn apply_at(
51    fields: &mut Fields,
52    path: &FieldPath,
53    transform: &FieldTransform,
54    commit_time: &Timestamp,
55) -> Result<Value, String> {
56    let current = get(fields, path);
57    let (stored, result) = match transform
58        .transform_type
59        .as_ref()
60        .ok_or("A field transform needs a transform type.")?
61    {
62        TransformType::SetToServerValue(kind) => {
63            if *kind != ServerValue::RequestTime as i32 {
64                return Err("Unsupported server value.".into());
65            }
66            let value = timestamp(request_time(commit_time));
67            (value.clone(), value)
68        }
69        TransformType::Increment(operand) => {
70            let operand_number = number(operand).ok_or(NOT_A_NUMBER)?;
71            let value = match current.and_then(number) {
72                Some(current) => from_number(add(current, operand_number)),
73                None => operand.clone(),
74            };
75            (value.clone(), value)
76        }
77        TransformType::Maximum(operand) => {
78            let value = extreme(current, operand, Ordering::Greater)?;
79            (value.clone(), value)
80        }
81        TransformType::Minimum(operand) => {
82            let value = extreme(current, operand, Ordering::Less)?;
83            (value.clone(), value)
84        }
85        TransformType::AppendMissingElements(operands) => {
86            let mut elements = array_elements(current);
87            for operand in &operands.values {
88                if !elements.iter().any(|e| equal(e, operand)) {
89                    elements.push(operand.clone());
90                }
91            }
92            (array(elements), null())
93        }
94        TransformType::RemoveAllFromArray(operands) => {
95            let mut elements = array_elements(current);
96            elements.retain(|e| !operands.values.iter().any(|operand| equal(e, operand)));
97            (array(elements), null())
98        }
99    };
100    set(fields, path, stored);
101    Ok(result)
102}
103
104const NOT_A_NUMBER: &str = "Input must be int64 or double.";
105
106/// `REQUEST_TIME` has millisecond precision.
107pub fn request_time(commit_time: &Timestamp) -> Timestamp {
108    Timestamp {
109        seconds: commit_time.seconds,
110        nanos: commit_time.nanos - commit_time.nanos % 1_000_000,
111    }
112}
113
114fn extreme(current: Option<&Value>, operand: &Value, keep: Ordering) -> Result<Value, String> {
115    let operand_number = number(operand).ok_or(NOT_A_NUMBER)?;
116    let Some(current_value) = current.filter(|v| number(v).is_some()) else {
117        return Ok(operand.clone());
118    };
119    let current_number = number(current_value).unwrap_or(Number::Integer(0));
120    if is_nan(current_number) {
121        return Ok(current_value.clone());
122    }
123    if is_nan(operand_number) {
124        return Ok(operand.clone());
125    }
126    // Equal values keep the stored one, type included.
127    Ok(if compare_number(operand_number, current_number) == keep {
128        operand.clone()
129    } else {
130        current_value.clone()
131    })
132}
133
134fn is_nan(n: Number) -> bool {
135    matches!(n, Number::Double(d) if d.is_nan())
136}
137
138fn add(a: Number, b: Number) -> Number {
139    match (a, b) {
140        (Number::Integer(x), Number::Integer(y)) => Number::Integer(x.saturating_add(y)),
141        (x, y) => Number::Double(as_f64(x) + as_f64(y)),
142    }
143}
144
145#[allow(clippy::cast_precision_loss)]
146fn as_f64(n: Number) -> f64 {
147    match n {
148        Number::Integer(i) => i as f64,
149        Number::Double(d) => d,
150    }
151}
152
153fn number(value: &Value) -> Option<Number> {
154    value.value_type.as_ref().and_then(Number::of)
155}
156
157fn from_number(n: Number) -> Value {
158    Value {
159        value_type: Some(match n {
160            Number::Integer(i) => ValueType::IntegerValue(i),
161            Number::Double(d) => ValueType::DoubleValue(d),
162        }),
163    }
164}
165
166fn equal(a: &Value, b: &Value) -> bool {
167    compare(a, b) == Ordering::Equal
168}
169
170fn array_elements(current: Option<&Value>) -> Vec<Value> {
171    match current.and_then(|v| v.value_type.as_ref()) {
172        Some(ValueType::ArrayValue(array)) => array.values.clone(),
173        _ => Vec::new(),
174    }
175}
176
177fn array(values: Vec<Value>) -> Value {
178    Value {
179        value_type: Some(ValueType::ArrayValue(ArrayValue { values })),
180    }
181}
182
183fn timestamp(ts: Timestamp) -> Value {
184    Value {
185        value_type: Some(ValueType::TimestampValue(ts)),
186    }
187}
188
189fn null() -> Value {
190    Value {
191        value_type: Some(ValueType::NullValue(0)),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use hidane_proto::google::firestore::v1::MapValue;
198
199    use super::*;
200
201    fn int(i: i64) -> Value {
202        Value {
203            value_type: Some(ValueType::IntegerValue(i)),
204        }
205    }
206
207    fn double(d: f64) -> Value {
208        Value {
209            value_type: Some(ValueType::DoubleValue(d)),
210        }
211    }
212
213    fn run(fields: &mut Fields, path: &str, kind: TransformType) -> Value {
214        let transform = FieldTransform {
215            field_path: path.to_owned(),
216            transform_type: Some(kind),
217        };
218        apply(
219            fields,
220            &transform,
221            &Timestamp {
222                seconds: 10,
223                nanos: 123_456_789,
224            },
225        )
226        .unwrap()
227    }
228
229    fn field(fields: &Fields, name: &str) -> Value {
230        fields[name].clone()
231    }
232
233    #[test]
234    fn server_time_has_millisecond_precision() {
235        let mut fields = Fields::new();
236        let result = run(&mut fields, "t", TransformType::SetToServerValue(1));
237        assert_eq!(
238            result,
239            timestamp(Timestamp {
240                seconds: 10,
241                nanos: 123_000_000
242            })
243        );
244        assert_eq!(field(&fields, "t"), result);
245    }
246
247    #[test]
248    fn increments_saturate_in_both_directions() {
249        let mut fields: Fields = [
250            ("hi".into(), int(i64::MAX - 1)),
251            ("lo".into(), int(i64::MIN + 1)),
252        ]
253        .into();
254        assert_eq!(
255            run(&mut fields, "hi", TransformType::Increment(int(5))),
256            int(i64::MAX)
257        );
258        assert_eq!(
259            run(&mut fields, "lo", TransformType::Increment(int(-5))),
260            int(i64::MIN)
261        );
262    }
263
264    #[test]
265    fn extremes_keep_the_stored_value_when_equal_and_propagate_nan() {
266        let mut fields: Fields = [
267            ("neg_zero".into(), double(-0.0)),
268            ("stored_nan".into(), double(f64::NAN)),
269            ("three".into(), int(3)),
270        ]
271        .into();
272        let kept = run(&mut fields, "neg_zero", TransformType::Maximum(double(0.0)));
273        let Some(ValueType::DoubleValue(d)) = kept.value_type else {
274            panic!()
275        };
276        assert!(d == 0.0 && d.is_sign_negative(), "the stored -0.0 is kept");
277        let nan = run(&mut fields, "stored_nan", TransformType::Minimum(int(1)));
278        assert!(matches!(nan.value_type, Some(ValueType::DoubleValue(d)) if d.is_nan()));
279        assert_eq!(
280            run(&mut fields, "three", TransformType::Minimum(double(3.0))),
281            int(3)
282        );
283        assert_eq!(
284            run(&mut fields, "three", TransformType::Maximum(double(3.5))),
285            double(3.5)
286        );
287    }
288
289    #[test]
290    fn non_numeric_operands_are_rejected_with_the_official_message() {
291        for kind in [
292            TransformType::Increment(Value {
293                value_type: Some(ValueType::StringValue("x".into())),
294            }),
295            TransformType::Maximum(Value {
296                value_type: Some(ValueType::BooleanValue(true)),
297            }),
298            TransformType::Minimum(Value { value_type: None }),
299        ] {
300            let transform = FieldTransform {
301                field_path: "a".into(),
302                transform_type: Some(kind),
303            };
304            let err = apply(&mut Fields::new(), &transform, &Timestamp::default()).unwrap_err();
305            assert_eq!(err, "Input must be int64 or double.");
306        }
307    }
308
309    #[test]
310    fn array_operations_use_value_equality() {
311        let map = |v: Value| Value {
312            value_type: Some(ValueType::MapValue(MapValue {
313                fields: [("k".to_owned(), v)].into(),
314            })),
315        };
316        let mut fields: Fields = [(
317            "a".into(),
318            array(vec![int(1), map(int(1)), double(f64::NAN)]),
319        )]
320        .into();
321        let operands = ArrayValue {
322            values: vec![double(1.0), map(double(1.0)), double(f64::NAN), int(2)],
323        };
324        run(
325            &mut fields,
326            "a",
327            TransformType::AppendMissingElements(operands.clone()),
328        );
329        assert_eq!(array_elements(fields.get("a")).len(), 4, "only 2 is new");
330        run(
331            &mut fields,
332            "a",
333            TransformType::RemoveAllFromArray(operands),
334        );
335        assert!(array_elements(fields.get("a")).is_empty());
336    }
337}