Skip to main content

glide/commands/
geo.rs

1// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
2//! Geospatial types and commands.
3
4#![allow(clippy::too_many_arguments)]
5
6use crate::GlideError;
7use crate::ValkeyResult;
8use crate::cmd::Cmd;
9use crate::commands::options::{ExistenceCheck, OrderBy};
10use crate::executor::CommandExecutor;
11use crate::value::FromValkeyValue;
12use crate::value::ValkeyValue;
13use crate::value::to_glide_error;
14use crate::write::ToSingleValkeyArg;
15use crate::write::ToValkeyArgs;
16use crate::write::ValkeyWrite;
17use async_trait::async_trait;
18use bytes::Bytes;
19
20/// Distance unit for the geo commands.
21///
22/// Mirrors redis-rs's `geo::Unit` type.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum GeoUnit {
25    /// Meters (`m`).
26    Meters,
27    /// Kilometers (`km`).
28    Kilometers,
29    /// Miles (`mi`).
30    Miles,
31    /// Feet (`ft`).
32    Feet,
33}
34
35impl ToValkeyArgs for GeoUnit {
36    fn write_valkey_args<W: ?Sized + ValkeyWrite>(&self, out: &mut W) {
37        out.write_arg(match self {
38            GeoUnit::Meters => b"m".as_slice(),
39            GeoUnit::Kilometers => b"km".as_slice(),
40            GeoUnit::Miles => b"mi".as_slice(),
41            GeoUnit::Feet => b"ft".as_slice(),
42        });
43    }
44}
45
46impl ToSingleValkeyArg for GeoUnit {}
47
48/// A longitude/latitude coordinate.
49///
50/// Mirrors redis-rs's `geo::Coord` type.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct GeoCoord<T> {
53    /// Longitude.
54    pub longitude: T,
55    /// Latitude.
56    pub latitude: T,
57}
58
59impl<T> GeoCoord<T> {
60    /// Create a coordinate from a longitude and a latitude.
61    pub fn lon_lat(longitude: T, latitude: T) -> Self {
62        Self {
63            longitude,
64            latitude,
65        }
66    }
67}
68
69impl<T: ToValkeyArgs> ToValkeyArgs for GeoCoord<T> {
70    fn write_valkey_args<W: ?Sized + ValkeyWrite>(&self, out: &mut W) {
71        self.longitude.write_valkey_args(out);
72        self.latitude.write_valkey_args(out);
73    }
74}
75
76impl<T: FromValkeyValue> FromValkeyValue for GeoCoord<T> {
77    fn from_owned_valkey_value(value: ValkeyValue) -> ValkeyResult<Self> {
78        match value {
79            ValkeyValue::Array(items) if items.len() == 2 => {
80                let mut items = items.into_iter();
81                let (Some(longitude), Some(latitude)) = (items.next(), items.next()) else {
82                    unreachable!("checked length");
83                };
84                Ok(Self {
85                    longitude: T::from_owned_valkey_value(longitude)?,
86                    latitude: T::from_owned_valkey_value(latitude)?,
87                })
88            }
89            other => Err(to_glide_error(other, "Expected a pair of numbers.")),
90        }
91    }
92}
93
94/// The search area shape for `GEOSEARCH`/`GEOSEARCHSTORE`.
95///
96/// Mirrors Python `GeoSearchByRadius`/`GeoSearchByBox`.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum GeoSearchShape {
99    /// A circular area of the given radius (`BYRADIUS`).
100    ByRadius {
101        /// The radius.
102        radius: f64,
103        /// The distance unit.
104        unit: GeoUnit,
105    },
106    /// A rectangular area of the given width and height (`BYBOX`).
107    ByBox {
108        /// The box width.
109        width: f64,
110        /// The box height.
111        height: f64,
112        /// The distance unit.
113        unit: GeoUnit,
114    },
115}
116
117impl ToValkeyArgs for GeoSearchShape {
118    fn write_valkey_args<W: ?Sized + ValkeyWrite>(&self, out: &mut W) {
119        match self {
120            GeoSearchShape::ByRadius { radius, unit } => {
121                out.write_arg(b"BYRADIUS");
122                radius.write_valkey_args(out);
123                unit.write_valkey_args(out);
124            }
125            GeoSearchShape::ByBox {
126                width,
127                height,
128                unit,
129            } => {
130                out.write_arg(b"BYBOX");
131                width.write_valkey_args(out);
132                height.write_valkey_args(out);
133                unit.write_valkey_args(out);
134            }
135        }
136    }
137}
138
139/// Geospatial commands.
140#[async_trait]
141pub trait GeoCommands: CommandExecutor {
142    /// Search a geospatial index by radius from a member (`GEOSEARCH ... FROMMEMBER ... BYRADIUS`).
143    async fn geosearch_by_radius_from_member<K: ToValkeyArgs + Send, M: ToValkeyArgs + Send>(
144        &self,
145        key: K,
146        member: M,
147        radius: f64,
148        unit: GeoUnit,
149    ) -> ValkeyResult<Vec<Bytes>> {
150        let mut cmd = Cmd::new();
151        cmd.arg("GEOSEARCH")
152            .arg(key)
153            .arg("FROMMEMBER")
154            .arg(member)
155            .arg("BYRADIUS")
156            .arg(radius)
157            .arg(unit);
158        match self.execute_command(cmd, None).await? {
159            ValkeyValue::Array(items) => items
160                .into_iter()
161                .map(Bytes::from_owned_valkey_value)
162                .collect(),
163            ValkeyValue::Nil => Ok(Vec::new()),
164            other => Ok(vec![Bytes::from_owned_valkey_value(other)?]),
165        }
166    }
167
168    /// Add geospatial members with options (`GEOADD` with `NX`/`XX`/`CH`).
169    /// Returns the number of added (or, with `changed`, changed) members.
170    async fn geoadd_options<K: ToValkeyArgs + Send, M: ToValkeyArgs + Send + Sync>(
171        &self,
172        key: K,
173        members: M,
174        existence_check: Option<ExistenceCheck>,
175        changed: bool,
176    ) -> ValkeyResult<i64> {
177        let mut cmd = Cmd::new();
178        cmd.arg("GEOADD").arg(key);
179        if let Some(c) = existence_check {
180            cmd.arg(c);
181        }
182        if changed {
183            cmd.arg("CH");
184        }
185        cmd.arg(members);
186        i64::from_owned_valkey_value(self.execute_command(cmd, None).await?)
187    }
188
189    /// Search a geospatial index from a member with a given shape.
190    /// Returns the matching members.
191    async fn geosearch_from_member<K: ToValkeyArgs + Send, M: ToValkeyArgs + Send>(
192        &self,
193        key: K,
194        member: M,
195        shape: GeoSearchShape,
196        options: GeoSearchOptions,
197    ) -> ValkeyResult<Vec<GeoSearchResult>> {
198        let mut cmd = Cmd::new();
199        cmd.arg("GEOSEARCH")
200            .arg(key)
201            .arg("FROMMEMBER")
202            .arg(member)
203            .arg(shape)
204            .arg(options);
205        Vec::from_owned_valkey_value(self.execute_command(cmd, None).await?)
206    }
207
208    /// Search a geospatial index from a coordinate with a given shape.
209    /// Returns the matching members.
210    async fn geosearch_from_coord<K: ToValkeyArgs + Send>(
211        &self,
212        key: K,
213        coord: GeoCoord<f64>,
214        shape: GeoSearchShape,
215        options: GeoSearchOptions,
216    ) -> ValkeyResult<Vec<GeoSearchResult>> {
217        let mut cmd = Cmd::new();
218        cmd.arg("GEOSEARCH")
219            .arg(key)
220            .arg("FROMLONLAT")
221            .arg(coord.longitude)
222            .arg(coord.latitude)
223            .arg(shape)
224            .arg(options);
225        Vec::from_owned_valkey_value(self.execute_command(cmd, None).await?)
226    }
227
228    /// Search from a member and store the results into `destination`.
229    /// Returns the number of members stored.
230    async fn geosearchstore_from_member<
231        D: ToValkeyArgs + Send,
232        S: ToValkeyArgs + Send,
233        M: ToValkeyArgs + Send,
234    >(
235        &self,
236        destination: D,
237        source: S,
238        member: M,
239        shape: GeoSearchShape,
240        options: GeoSearchStoreOptions,
241    ) -> ValkeyResult<i64> {
242        let mut cmd = Cmd::new();
243        cmd.arg("GEOSEARCHSTORE")
244            .arg(destination)
245            .arg(source)
246            .arg("FROMMEMBER")
247            .arg(member)
248            .arg(shape)
249            .arg(options);
250        i64::from_owned_valkey_value(self.execute_command(cmd, None).await?)
251    }
252
253    /// Search from a coordinate and store the results into `destination`.
254    /// Returns the number of members stored.
255    async fn geosearchstore_from_coord<D: ToValkeyArgs + Send, S: ToValkeyArgs + Send>(
256        &self,
257        destination: D,
258        source: S,
259        coord: GeoCoord<f64>,
260        shape: GeoSearchShape,
261        options: GeoSearchStoreOptions,
262    ) -> ValkeyResult<i64> {
263        let mut cmd = Cmd::new();
264        cmd.arg("GEOSEARCHSTORE")
265            .arg(destination)
266            .arg(source)
267            .arg("FROMLONLAT")
268            .arg(coord.longitude)
269            .arg(coord.latitude)
270            .arg(shape)
271            .arg(options);
272        i64::from_owned_valkey_value(self.execute_command(cmd, None).await?)
273    }
274}
275
276/// Options for `GEOSEARCH`.
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct GeoSearchOptions {
279    /// The sort order for results (`ASC`/`DESC`), or `None` for unsorted results.
280    pub order: Option<OrderBy>,
281    /// The maximum number of results to return, or `None` for no limit (`COUNT`).
282    pub count: Option<i64>,
283    /// Whether to allow non-closest results (`ANY`). Requires `count`.
284    pub any: bool,
285    /// Whether to include each member's position (`WITHCOORD`).
286    pub with_position: bool,
287    /// Whether to include each member's distance from the search origin (`WITHDIST`).
288    pub with_distance: bool,
289    /// Whether to include each member's geohash (`WITHHASH`).
290    pub with_hash: bool,
291}
292
293impl ToValkeyArgs for GeoSearchOptions {
294    fn write_valkey_args<W: ?Sized + ValkeyWrite>(&self, out: &mut W) {
295        write_order_count_any(out, self.order, self.count, self.any);
296        if self.with_position {
297            out.write_arg(b"WITHCOORD");
298        }
299        if self.with_distance {
300            out.write_arg(b"WITHDIST");
301        }
302        if self.with_hash {
303            out.write_arg(b"WITHHASH");
304        }
305    }
306}
307
308/// A matching member from a `GEOSEARCH` command.
309#[derive(Debug, Clone, PartialEq)]
310pub struct GeoSearchResult {
311    /// The member name.
312    pub member: Bytes,
313    /// The member's position (`WITHCOORD`).
314    pub position: Option<GeoCoord<f64>>,
315    /// The member's distance from the search origin, in the shape's unit (`WITHDIST`).
316    pub distance: Option<f64>,
317    /// The member's geohash (`WITHHASH`).
318    pub hash: Option<i64>,
319}
320
321impl FromValkeyValue for GeoSearchResult {
322    fn from_owned_valkey_value(value: ValkeyValue) -> ValkeyResult<Self> {
323        // Without `WITH*` flags, each result is a member name.
324        if let ValkeyValue::BulkString(member) = value {
325            return Ok(Self {
326                member,
327                position: None,
328                distance: None,
329                hash: None,
330            });
331        }
332
333        let ValkeyValue::Array(items) = value else {
334            return Err(to_glide_error(value, "Unexpected GEOSEARCH result."));
335        };
336
337        // With `WITH*` flags, each result is a `[member, [distance?, hash?, position?]]` array.
338        if items.len() != 2 {
339            return Err(to_glide_error(
340                ValkeyValue::Array(items),
341                "Unexpected GEOSEARCH result.",
342            ));
343        }
344        let mut items = items.into_iter();
345        let (Some(member), Some(ValkeyValue::Array(extras))) = (items.next(), items.next()) else {
346            return Err(GlideError::Request("Unexpected GEOSEARCH result.".into()));
347        };
348
349        let mut result = Self {
350            member: Bytes::from_owned_valkey_value(member)?,
351            position: None,
352            distance: None,
353            hash: None,
354        };
355
356        // The extras have distinct reply types:
357        //  - distance is a double
358        //  - hash is an integer
359        //  - position is an array
360        for extra in extras {
361            match extra {
362                ValkeyValue::Int(hash) => result.hash = Some(hash),
363                ValkeyValue::Array(_) => {
364                    result.position = Some(GeoCoord::from_owned_valkey_value(extra)?)
365                }
366                distance => result.distance = Some(f64::from_owned_valkey_value(distance)?),
367            }
368        }
369
370        Ok(result)
371    }
372}
373
374/// Options for `GEOSEARCHSTORE`.
375#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
376pub struct GeoSearchStoreOptions {
377    /// The sort order for results (`ASC`/`DESC`), or `None` for unsorted results.
378    pub order: Option<OrderBy>,
379    /// The maximum number of results to store, or `None` for no limit (`COUNT`).
380    pub count: Option<i64>,
381    /// Whether to allow non-closest results (`ANY`). Requires `count`.
382    pub any: bool,
383    /// Whether to store distances as scores instead of geohash values (`STOREDIST`).
384    pub store_dist: bool,
385}
386
387impl ToValkeyArgs for GeoSearchStoreOptions {
388    fn write_valkey_args<W: ?Sized + ValkeyWrite>(&self, out: &mut W) {
389        write_order_count_any(out, self.order, self.count, self.any);
390        if self.store_dist {
391            out.write_arg(b"STOREDIST");
392        }
393    }
394}
395
396/// Writes the `[ASC|DESC] [COUNT count [ANY]]` arguments shared by the geo search options.
397fn write_order_count_any<W: ?Sized + ValkeyWrite>(
398    out: &mut W,
399    order: Option<OrderBy>,
400    count: Option<i64>,
401    any: bool,
402) {
403    if let Some(order) = order {
404        out.write_arg(order.as_arg().as_bytes());
405    }
406    if let Some(count) = count {
407        out.write_arg(b"COUNT");
408        out.write_arg_fmt(count);
409    }
410    if any {
411        out.write_arg(b"ANY");
412    }
413}
414
415impl<T: CommandExecutor + ?Sized> GeoCommands for T {}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::test_utils::assert_args;
421    use crate::test_utils::assert_args_empty;
422
423    #[test]
424    fn unit_args() {
425        assert_args(GeoUnit::Meters, &["m"]);
426        assert_args(GeoUnit::Kilometers, &["km"]);
427        assert_args(GeoUnit::Miles, &["mi"]);
428        assert_args(GeoUnit::Feet, &["ft"]);
429    }
430
431    #[test]
432    fn coord_args() {
433        assert_args(GeoCoord::lon_lat(13.5, 38.5), &["13.5", "38.5"]);
434    }
435
436    #[test]
437    fn geosearch_shape_args() {
438        assert_args(
439            GeoSearchShape::ByRadius {
440                radius: 5.0,
441                unit: GeoUnit::Kilometers,
442            },
443            &["BYRADIUS", "5.0", "km"],
444        );
445        assert_args(
446            GeoSearchShape::ByBox {
447                width: 2.0,
448                height: 3.0,
449                unit: GeoUnit::Meters,
450            },
451            &["BYBOX", "2.0", "3.0", "m"],
452        );
453    }
454
455    #[test]
456    fn geosearch_options_args() {
457        assert_args_empty(GeoSearchOptions::default());
458        assert_args(
459            GeoSearchOptions {
460                order: Some(OrderBy::Asc),
461                count: Some(10),
462                any: true,
463                ..Default::default()
464            },
465            &["ASC", "COUNT", "10", "ANY"],
466        );
467        assert_args(
468            GeoSearchOptions {
469                order: Some(OrderBy::Desc),
470                count: Some(5),
471                with_position: true,
472                with_distance: true,
473                with_hash: true,
474                ..Default::default()
475            },
476            &["DESC", "COUNT", "5", "WITHCOORD", "WITHDIST", "WITHHASH"],
477        );
478    }
479
480    #[test]
481    fn geosearchstore_options_args() {
482        assert_args_empty(GeoSearchStoreOptions::default());
483        assert_args(
484            GeoSearchStoreOptions {
485                order: Some(OrderBy::Desc),
486                count: Some(3),
487                any: true,
488                store_dist: true,
489            },
490            &["DESC", "COUNT", "3", "ANY", "STOREDIST"],
491        );
492    }
493
494    #[test]
495    fn coord_decoding() {
496        let pos = ValkeyValue::Array(vec![
497            ValkeyValue::BulkString(b"13.5".to_vec().into()),
498            ValkeyValue::BulkString(b"38.5".to_vec().into()),
499        ]);
500        assert_eq!(
501            GeoCoord::<f64>::from_owned_valkey_value(pos.clone()).unwrap(),
502            GeoCoord::lon_lat(13.5, 38.5)
503        );
504        assert_eq!(
505            GeoCoord::<String>::from_owned_valkey_value(pos).unwrap(),
506            GeoCoord::lon_lat("13.5".to_string(), "38.5".to_string())
507        );
508        assert!(
509            GeoCoord::<f64>::from_owned_valkey_value(ValkeyValue::Array(vec![ValkeyValue::Int(1)]))
510                .is_err()
511        );
512    }
513
514    #[test]
515    fn geosearch_result_decoding() {
516        let bulk = |s: &str| ValkeyValue::BulkString(s.as_bytes().to_vec().into());
517        let member = |name: &str| GeoSearchResult {
518            member: Bytes::from(name.to_string()),
519            position: None,
520            distance: None,
521            hash: None,
522        };
523
524        assert_eq!(
525            GeoSearchResult::from_owned_valkey_value(bulk("Palermo")).unwrap(),
526            member("Palermo")
527        );
528
529        let all = ValkeyValue::Array(vec![
530            bulk("Palermo"),
531            ValkeyValue::Array(vec![
532                ValkeyValue::Double(190.4424),
533                ValkeyValue::Int(3479099956230698),
534                ValkeyValue::Array(vec![ValkeyValue::Double(13.5), ValkeyValue::Double(38.5)]),
535            ]),
536        ]);
537        assert_eq!(
538            GeoSearchResult::from_owned_valkey_value(all).unwrap(),
539            GeoSearchResult {
540                position: Some(GeoCoord::lon_lat(13.5, 38.5)),
541                distance: Some(190.4424),
542                hash: Some(3479099956230698),
543                ..member("Palermo")
544            }
545        );
546
547        let hash_only = ValkeyValue::Array(vec![
548            bulk("Catania"),
549            ValkeyValue::Array(vec![ValkeyValue::Int(42)]),
550        ]);
551        assert_eq!(
552            GeoSearchResult::from_owned_valkey_value(hash_only).unwrap(),
553            GeoSearchResult {
554                hash: Some(42),
555                ..member("Catania")
556            }
557        );
558
559        let strings = ValkeyValue::Array(vec![
560            bulk("Palermo"),
561            ValkeyValue::Array(vec![
562                bulk("190.4424"),
563                ValkeyValue::Array(vec![bulk("13.5"), bulk("38.5")]),
564            ]),
565        ]);
566        assert_eq!(
567            GeoSearchResult::from_owned_valkey_value(strings).unwrap(),
568            GeoSearchResult {
569                position: Some(GeoCoord::lon_lat(13.5, 38.5)),
570                distance: Some(190.4424),
571                ..member("Palermo")
572            }
573        );
574
575        let position_only = ValkeyValue::Array(vec![
576            bulk("Catania"),
577            ValkeyValue::Array(vec![ValkeyValue::Array(vec![
578                ValkeyValue::Double(15.0),
579                ValkeyValue::Double(37.5),
580            ])]),
581        ]);
582        assert_eq!(
583            GeoSearchResult::from_owned_valkey_value(position_only).unwrap(),
584            GeoSearchResult {
585                position: Some(GeoCoord::lon_lat(15.0, 37.5)),
586                ..member("Catania")
587            }
588        );
589
590        assert!(GeoSearchResult::from_owned_valkey_value(ValkeyValue::Int(1)).is_err());
591        assert!(GeoSearchResult::from_owned_valkey_value(ValkeyValue::Array(vec![])).is_err());
592        assert!(
593            GeoSearchResult::from_owned_valkey_value(ValkeyValue::Array(vec![
594                bulk("Palermo"),
595                ValkeyValue::Array(vec![]),
596                ValkeyValue::Int(1),
597            ]))
598            .is_err()
599        );
600        assert!(
601            GeoSearchResult::from_owned_valkey_value(ValkeyValue::Array(vec![
602                bulk("Palermo"),
603                ValkeyValue::Int(1),
604            ]))
605            .is_err()
606        );
607    }
608}