mod parse;
pub(super) use parse::{parse_legacy_radius, parse_opts, parse_opts_at};
use kevy_geo::{EARTH_RADIUS_METERS, decode_score, haversine_meters, neighbor_score_ranges};
use kevy_resp::{ArgvView, CmdError, encode_array_len, encode_bulk, encode_error, encode_integer};
use kevy_store::{ScoreBound, Store};
use crate::cmd::{store_err, wrong_args};
use super::score_to_point;
pub(super) fn cmd_geosearch<A: ArgvView + ?Sized>(store: &mut Store, args: &A, out: &mut Vec<u8>) {
if args.len() < 4 {
return wrong_args(out, "geosearch");
}
let opts = match parse_opts(args) {
Ok(o) => o,
Err(msg) => return encode_error(out, msg.as_wire()),
};
let key = args[1].to_vec();
let hits = match run_search(store, &key, &opts) {
Ok(h) => h,
Err(SearchError::NoMember) => {
return encode_error(out, "ERR could not decode requested zset member");
}
Err(SearchError::Store(e)) => return store_err(out, e),
};
emit_reply(&hits, &opts, out);
}
pub(super) fn run_search(
store: &mut Store,
key: &[u8],
opts: &Opts,
) -> Result<Vec<Hit>, SearchError> {
let (clon, clat) = resolve_center(store, key, &opts.from)?;
let ranges = neighbor_score_ranges(clon, clat, opts.shape.bounding_radius_meters());
let mut hits = collect_hits(store, key, &ranges, clon, clat, opts)?;
apply_sort(&mut hits, opts.sort);
apply_count(&mut hits, opts.sort, opts.count, opts.any);
Ok(hits)
}
pub(super) fn search_pairs(
store: &mut Store,
key: &[u8],
opts: &Opts,
) -> Result<Vec<(Vec<u8>, f64)>, SearchError> {
let hits = run_search(store, key, opts)?;
Ok(store_pairs(&hits, opts))
}
fn store_pairs(hits: &[Hit], opts: &Opts) -> Vec<(Vec<u8>, f64)> {
hits.iter()
.map(|h| {
let score = if opts.storedist { h.dist_m / opts.unit } else { h.score };
(h.member.clone(), score)
})
.collect()
}
pub(super) enum SearchError {
NoMember,
Store(kevy_store::StoreError),
}
impl From<kevy_store::StoreError> for SearchError {
fn from(e: kevy_store::StoreError) -> Self {
SearchError::Store(e)
}
}
pub(super) enum Anchor {
Member(Vec<u8>),
LonLat(f64, f64),
}
#[derive(Clone, Copy)]
enum Shape {
Radius { r_m: f64 },
Box { w_m: f64, h_m: f64 },
}
impl Shape {
fn bounding_radius_meters(&self) -> f64 {
match *self {
Shape::Radius { r_m } => r_m,
Shape::Box { w_m, h_m } => 0.5 * (w_m * w_m + h_m * h_m).sqrt(),
}
}
}
#[derive(Default, Clone, Copy)]
enum Sort {
#[default]
None,
Asc,
Desc,
}
pub(super) struct Opts {
from: Anchor,
shape: Shape,
unit: f64,
sort: Sort,
count: Option<usize>,
any: bool,
with_coord: bool,
with_dist: bool,
with_hash: bool,
pub(super) storedist: bool,
}
pub(super) struct Hit {
pub(super) member: Vec<u8>,
pub(super) score: f64,
pub(super) dist_m: f64,
}
fn resolve_center(store: &mut Store, key: &[u8], from: &Anchor) -> Result<(f64, f64), SearchError> {
match from {
Anchor::Member(m) => match score_to_point(store, key, m) {
Ok(Some(p)) => Ok(p),
Ok(None) => Err(SearchError::NoMember),
Err(e) => Err(SearchError::Store(e)),
},
Anchor::LonLat(lon, lat) => Ok((*lon, *lat)),
}
}
fn collect_hits(
store: &mut Store,
key: &[u8],
ranges: &[(f64, f64)],
clon: f64,
clat: f64,
opts: &Opts,
) -> Result<Vec<Hit>, kevy_store::StoreError> {
let mut hits = Vec::new();
for (min, max) in ranges {
let members = store.zrange_by_score(
key,
ScoreBound { value: *min, exclusive: false },
ScoreBound { value: *max, exclusive: false },
)?;
for (member, score) in members {
let (mlon, mlat) = decode_score(score);
if !in_shape(opts.shape, clon, clat, mlon, mlat) {
continue;
}
let dist_m = haversine_meters(clon, clat, mlon, mlat);
hits.push(Hit { member, score, dist_m });
}
}
Ok(hits)
}
fn in_shape(shape: Shape, clon: f64, clat: f64, mlon: f64, mlat: f64) -> bool {
match shape {
Shape::Radius { r_m } => haversine_meters(clon, clat, mlon, mlat) <= r_m,
Shape::Box { w_m, h_m } => {
let dlat_m = (mlat - clat).to_radians() * EARTH_RADIUS_METERS;
let dlon_m = (mlon - clon).to_radians() * EARTH_RADIUS_METERS * clat.to_radians().cos();
dlat_m.abs() <= h_m / 2.0 && dlon_m.abs() <= w_m / 2.0
}
}
}
fn apply_sort(hits: &mut [Hit], sort: Sort) {
match sort {
Sort::Asc => hits.sort_by(|a, b| {
a.dist_m.partial_cmp(&b.dist_m).expect(
"GEOADD rejects non-finite coordinates and a non-finite centre matches no cell",
)
}),
Sort::Desc => hits.sort_by(|a, b| {
b.dist_m.partial_cmp(&a.dist_m).expect(
"GEOADD rejects non-finite coordinates and a non-finite centre matches no cell",
)
}),
Sort::None => {}
}
}
fn apply_count(hits: &mut Vec<Hit>, sort: Sort, count: Option<usize>, any: bool) {
let Some(n) = count else { return };
if matches!(sort, Sort::None) && !any {
hits.sort_by(|a, b| {
a.dist_m.partial_cmp(&b.dist_m).expect(
"GEOADD rejects non-finite coordinates and a non-finite centre matches no cell",
)
});
}
hits.truncate(n);
}
pub(super) struct LegacyRadiusParsed {
pub(super) opts: Opts,
pub(super) store_dst: Option<Vec<u8>>,
}
pub(super) enum RadiusReply {
Replied,
Stored(usize),
}
pub(super) fn emit_or_store(
out: &mut Vec<u8>,
store: &mut Store,
hits: &[Hit],
parsed: &LegacyRadiusParsed,
) -> RadiusReply {
match &parsed.store_dst {
None => {
emit_reply(hits, &parsed.opts, out);
RadiusReply::Replied
}
Some(dst) => {
let pairs = store_pairs(hits, &parsed.opts);
RadiusReply::Stored(store.zstore_result(dst, &pairs))
}
}
}
pub(super) fn cmd_geosearchstore<A: ArgvView + ?Sized>(
store: &mut Store,
args: &A,
out: &mut Vec<u8>,
) {
let (src, opts) = match plan_geosearchstore(args) {
Ok(p) => p,
Err(msg) => return encode_error(out, msg.as_wire()),
};
let dst = args[1].to_vec();
match search_pairs(store, &src, &opts) {
Ok(pairs) => encode_integer(out, store.zstore_result(&dst, &pairs) as i64),
Err(SearchError::NoMember) => {
encode_error(out, "ERR could not decode requested zset member");
}
Err(SearchError::Store(e)) => store_err(out, e),
}
}
pub(super) fn plan_geosearchstore<A: ArgvView + ?Sized>(
args: &A,
) -> Result<(Vec<u8>, Opts), CmdError> {
if args.len() < 5 {
return Err(CmdError::Wire("ERR wrong number of arguments for 'geosearchstore' command"));
}
let opts = parse_opts_at(args, 3)?;
Ok((args[2].to_vec(), opts))
}
fn emit_reply(hits: &[Hit], opts: &Opts, out: &mut Vec<u8>) {
let any_with = opts.with_coord || opts.with_dist || opts.with_hash;
encode_array_len(out, hits.len() as i64);
if !any_with {
for h in hits {
encode_bulk(out, &h.member);
}
return;
}
for h in hits {
let extras =
i64::from(opts.with_dist) + i64::from(opts.with_hash) + i64::from(opts.with_coord);
encode_array_len(out, 1 + extras);
encode_bulk(out, &h.member);
if opts.with_dist {
encode_bulk(out, format!("{:.4}", h.dist_m / opts.unit).as_bytes());
}
if opts.with_hash {
encode_integer(out, h.score as i64);
}
if opts.with_coord {
let (lon, lat) = decode_score(h.score);
encode_array_len(out, 2);
encode_bulk(out, format!("{lon:.17}").as_bytes());
encode_bulk(out, format!("{lat:.17}").as_bytes());
}
}
}