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    let current = get(fields, &path);
46    let (stored, result) = match transform
47        .transform_type
48        .as_ref()
49        .ok_or("A field transform needs a transform type.")?
50    {
51        TransformType::SetToServerValue(kind) => {
52            if *kind != ServerValue::RequestTime as i32 {
53                return Err("Unsupported server value.".into());
54            }
55            let value = timestamp(request_time(commit_time));
56            (value.clone(), value)
57        }
58        TransformType::Increment(operand) => {
59            let operand_number = number(operand).ok_or(NOT_A_NUMBER)?;
60            let value = match current.and_then(number) {
61                Some(current) => from_number(add(current, operand_number)),
62                None => operand.clone(),
63            };
64            (value.clone(), value)
65        }
66        TransformType::Maximum(operand) => {
67            let value = extreme(current, operand, Ordering::Greater)?;
68            (value.clone(), value)
69        }
70        TransformType::Minimum(operand) => {
71            let value = extreme(current, operand, Ordering::Less)?;
72            (value.clone(), value)
73        }
74        TransformType::AppendMissingElements(operands) => {
75            let mut elements = array_elements(current);
76            for operand in &operands.values {
77                if !elements.iter().any(|e| equal(e, operand)) {
78                    elements.push(operand.clone());
79                }
80            }
81            (array(elements), null())
82        }
83        TransformType::RemoveAllFromArray(operands) => {
84            let mut elements = array_elements(current);
85            elements.retain(|e| !operands.values.iter().any(|operand| equal(e, operand)));
86            (array(elements), null())
87        }
88    };
89    set(fields, &path, stored);
90    Ok(result)
91}
92
93const NOT_A_NUMBER: &str = "Input must be int64 or double.";
94
95/// `REQUEST_TIME` has millisecond precision.
96pub fn request_time(commit_time: &Timestamp) -> Timestamp {
97    Timestamp {
98        seconds: commit_time.seconds,
99        nanos: commit_time.nanos - commit_time.nanos % 1_000_000,
100    }
101}
102
103fn extreme(current: Option<&Value>, operand: &Value, keep: Ordering) -> Result<Value, String> {
104    let operand_number = number(operand).ok_or(NOT_A_NUMBER)?;
105    let Some(current_value) = current.filter(|v| number(v).is_some()) else {
106        return Ok(operand.clone());
107    };
108    let current_number = number(current_value).unwrap_or(Number::Integer(0));
109    if is_nan(current_number) {
110        return Ok(current_value.clone());
111    }
112    if is_nan(operand_number) {
113        return Ok(operand.clone());
114    }
115    // Equal values keep the stored one, type included.
116    Ok(if compare_number(operand_number, current_number) == keep {
117        operand.clone()
118    } else {
119        current_value.clone()
120    })
121}
122
123fn is_nan(n: Number) -> bool {
124    matches!(n, Number::Double(d) if d.is_nan())
125}
126
127fn add(a: Number, b: Number) -> Number {
128    match (a, b) {
129        (Number::Integer(x), Number::Integer(y)) => Number::Integer(x.saturating_add(y)),
130        (x, y) => Number::Double(as_f64(x) + as_f64(y)),
131    }
132}
133
134#[allow(clippy::cast_precision_loss)]
135fn as_f64(n: Number) -> f64 {
136    match n {
137        Number::Integer(i) => i as f64,
138        Number::Double(d) => d,
139    }
140}
141
142fn number(value: &Value) -> Option<Number> {
143    value.value_type.as_ref().and_then(Number::of)
144}
145
146fn from_number(n: Number) -> Value {
147    Value {
148        value_type: Some(match n {
149            Number::Integer(i) => ValueType::IntegerValue(i),
150            Number::Double(d) => ValueType::DoubleValue(d),
151        }),
152    }
153}
154
155fn equal(a: &Value, b: &Value) -> bool {
156    compare(a, b) == Ordering::Equal
157}
158
159fn array_elements(current: Option<&Value>) -> Vec<Value> {
160    match current.and_then(|v| v.value_type.as_ref()) {
161        Some(ValueType::ArrayValue(array)) => array.values.clone(),
162        _ => Vec::new(),
163    }
164}
165
166fn array(values: Vec<Value>) -> Value {
167    Value {
168        value_type: Some(ValueType::ArrayValue(ArrayValue { values })),
169    }
170}
171
172fn timestamp(ts: Timestamp) -> Value {
173    Value {
174        value_type: Some(ValueType::TimestampValue(ts)),
175    }
176}
177
178fn null() -> Value {
179    Value {
180        value_type: Some(ValueType::NullValue(0)),
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use hidane_proto::google::firestore::v1::MapValue;
187
188    use super::*;
189
190    fn int(i: i64) -> Value {
191        Value {
192            value_type: Some(ValueType::IntegerValue(i)),
193        }
194    }
195
196    fn double(d: f64) -> Value {
197        Value {
198            value_type: Some(ValueType::DoubleValue(d)),
199        }
200    }
201
202    fn run(fields: &mut Fields, path: &str, kind: TransformType) -> Value {
203        let transform = FieldTransform {
204            field_path: path.to_owned(),
205            transform_type: Some(kind),
206        };
207        apply(
208            fields,
209            &transform,
210            &Timestamp {
211                seconds: 10,
212                nanos: 123_456_789,
213            },
214        )
215        .unwrap()
216    }
217
218    fn field(fields: &Fields, name: &str) -> Value {
219        fields[name].clone()
220    }
221
222    #[test]
223    fn server_time_has_millisecond_precision() {
224        let mut fields = Fields::new();
225        let result = run(&mut fields, "t", TransformType::SetToServerValue(1));
226        assert_eq!(
227            result,
228            timestamp(Timestamp {
229                seconds: 10,
230                nanos: 123_000_000
231            })
232        );
233        assert_eq!(field(&fields, "t"), result);
234    }
235
236    #[test]
237    fn increments_saturate_in_both_directions() {
238        let mut fields: Fields = [
239            ("hi".into(), int(i64::MAX - 1)),
240            ("lo".into(), int(i64::MIN + 1)),
241        ]
242        .into();
243        assert_eq!(
244            run(&mut fields, "hi", TransformType::Increment(int(5))),
245            int(i64::MAX)
246        );
247        assert_eq!(
248            run(&mut fields, "lo", TransformType::Increment(int(-5))),
249            int(i64::MIN)
250        );
251    }
252
253    #[test]
254    fn extremes_keep_the_stored_value_when_equal_and_propagate_nan() {
255        let mut fields: Fields = [
256            ("neg_zero".into(), double(-0.0)),
257            ("stored_nan".into(), double(f64::NAN)),
258            ("three".into(), int(3)),
259        ]
260        .into();
261        let kept = run(&mut fields, "neg_zero", TransformType::Maximum(double(0.0)));
262        let Some(ValueType::DoubleValue(d)) = kept.value_type else {
263            panic!()
264        };
265        assert!(d == 0.0 && d.is_sign_negative(), "the stored -0.0 is kept");
266        let nan = run(&mut fields, "stored_nan", TransformType::Minimum(int(1)));
267        assert!(matches!(nan.value_type, Some(ValueType::DoubleValue(d)) if d.is_nan()));
268        assert_eq!(
269            run(&mut fields, "three", TransformType::Minimum(double(3.0))),
270            int(3)
271        );
272        assert_eq!(
273            run(&mut fields, "three", TransformType::Maximum(double(3.5))),
274            double(3.5)
275        );
276    }
277
278    #[test]
279    fn non_numeric_operands_are_rejected_with_the_official_message() {
280        for kind in [
281            TransformType::Increment(Value {
282                value_type: Some(ValueType::StringValue("x".into())),
283            }),
284            TransformType::Maximum(Value {
285                value_type: Some(ValueType::BooleanValue(true)),
286            }),
287            TransformType::Minimum(Value { value_type: None }),
288        ] {
289            let transform = FieldTransform {
290                field_path: "a".into(),
291                transform_type: Some(kind),
292            };
293            let err = apply(&mut Fields::new(), &transform, &Timestamp::default()).unwrap_err();
294            assert_eq!(err, "Input must be int64 or double.");
295        }
296    }
297
298    #[test]
299    fn array_operations_use_value_equality() {
300        let map = |v: Value| Value {
301            value_type: Some(ValueType::MapValue(MapValue {
302                fields: [("k".to_owned(), v)].into(),
303            })),
304        };
305        let mut fields: Fields = [(
306            "a".into(),
307            array(vec![int(1), map(int(1)), double(f64::NAN)]),
308        )]
309        .into();
310        let operands = ArrayValue {
311            values: vec![double(1.0), map(double(1.0)), double(f64::NAN), int(2)],
312        };
313        run(
314            &mut fields,
315            "a",
316            TransformType::AppendMissingElements(operands.clone()),
317        );
318        assert_eq!(array_elements(fields.get("a")).len(), 4, "only 2 is new");
319        run(
320            &mut fields,
321            "a",
322            TransformType::RemoveAllFromArray(operands),
323        );
324        assert!(array_elements(fields.get("a")).is_empty());
325    }
326}