Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ trait GeoGetter[F[_], K, V] {
def geoRadius(key: K, geoRadius: GeoRadius, unit: GeoArgs.Unit, args: GeoArgs): F[List[GeoRadiusResult[V]]]
def geoRadiusByMember(key: K, value: V, dist: Distance, unit: GeoArgs.Unit): F[Set[V]]
def geoRadiusByMember(key: K, value: V, dist: Distance, unit: GeoArgs.Unit, args: GeoArgs): F[List[GeoRadiusResult[V]]]
def geoSearch(key: K, from: GeoSearch[V], area: GeoSearchArea): F[Set[V]]
def geoSearch(key: K, from: GeoSearch[V], area: GeoSearchArea, args: GeoArgs): F[List[GeoSearchResult[V]]]
}

trait GeoSetter[F[_], K, V] {
Expand All @@ -38,4 +40,6 @@ trait GeoSetter[F[_], K, V] {
def geoRadius(key: K, geoRadius: GeoRadius, unit: GeoArgs.Unit, storage: GeoRadiusDistStorage[K]): F[Unit]
def geoRadiusByMember(key: K, value: V, dist: Distance, unit: GeoArgs.Unit, storage: GeoRadiusKeyStorage[K]): F[Unit]
def geoRadiusByMember(key: K, value: V, dist: Distance, unit: GeoArgs.Unit, storage: GeoRadiusDistStorage[K]): F[Unit]
def geoSearch(key: K, from: GeoSearch[V], area: GeoSearchArea, storage: GeoRadiusKeyStorage[K]): F[Unit]
def geoSearch(key: K, from: GeoSearch[V], area: GeoSearchArea, storage: GeoRadiusDistStorage[K]): F[Unit]
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,27 @@ object effects {

final case class GeoCoordinate(x: Double, y: Double)
final case class GeoRadiusResult[V](value: V, dist: Distance, hash: GeoHash, coordinate: GeoCoordinate)
final case class GeoSearchResult[V](
value: V,
dist: Option[Distance],
hash: Option[GeoHash],
coordinate: Option[GeoCoordinate]
)
final case class GeoRadiusKeyStorage[K](key: K, count: Long, sort: GeoArgs.Sort)
final case class GeoRadiusDistStorage[K](key: K, count: Long, sort: GeoArgs.Sort)

sealed trait GeoSearch[V]
object GeoSearch {
final case class FromMember[V](member: V) extends GeoSearch[V]
final case class FromLonLat[V](longitude: Longitude, latitude: Latitude) extends GeoSearch[V]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V is not used in any of the values held, just extend GeoSearch[Nothing]

}

sealed trait GeoSearchArea
object GeoSearchArea {
final case class ByRadius(radius: Distance, unit: GeoArgs.Unit) extends GeoSearchArea
final case class ByBox(width: Double, height: Double, unit: GeoArgs.Unit) extends GeoSearchArea
}

final case class Score(value: Double) extends AnyVal
final case class ScoreWithValue[V](score: Score, value: V)
final case class ZRange[V](start: V, end: V)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,53 @@ private[redis4cats] class BaseRedis[F[_]: FutureLift: MonadThrow: Log, K, V](
): F[Unit] =
async.flatMap(_.georadiusbymember(key, value, dist.value, unit, storage.asGeoRadiusStoreArgs).futureLift.void)

override def geoSearch(key: K, from: effects.GeoSearch[V], area: GeoSearchArea): F[Set[V]] =
async.flatMap(_.geosearch(key, from.asJava, area.asJava).futureLift.map(_.asScala.toSet))

Comment on lines +1348 to +1350

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds multiple geoSearch overloads, but the test scenario only exercises the GeoArgs overload and the GeoRadiusKeyStorage overload. Add coverage for (1) the no-args geoSearch(key, from, area): F[Set[V]] overload and (2) the GeoRadiusDistStorage overload (ideally asserting that scores are distances when stored) to guard against regressions in these wrappers.

Copilot uses AI. Check for mistakes.
override def geoSearch(
key: K,
from: effects.GeoSearch[V],
area: GeoSearchArea,
args: GeoArgs
): F[List[GeoSearchResult[V]]] =
async.flatMap(
_.geosearch(key, from.asJava, area.asJava, args).futureLift.map(_.asScala.toList.map(_.asGeoSearchResult))
)

override def geoSearch(
key: K,
from: effects.GeoSearch[V],
area: GeoSearchArea,
storage: GeoRadiusKeyStorage[K]
): F[Unit] =
async.flatMap(
_.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
GeoArgs.Builder.count(storage.count),

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GeoRadiusKeyStorage includes a sort parameter, but this geoSearch(..., storage: GeoRadiusKeyStorage[K]) implementation ignores it and always uses only count. That means callers cannot control ASC/DESC ordering when COUNT is used (and the stored set contents may differ). Build the GeoArgs passed to geosearchstore using both storage.count and storage.sort (or otherwise thread sort through) so the wrapper matches the storage API contract implied by the parameter.

Suggested change
GeoArgs.Builder.count(storage.count),
GeoArgs.Builder.count(storage.count).sort(storage.sort),

Copilot uses AI. Check for mistakes.
false
).futureLift.void
)

override def geoSearch(
key: K,
from: effects.GeoSearch[V],
area: GeoSearchArea,
storage: GeoRadiusDistStorage[K]
): F[Unit] =
async.flatMap(
_.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
GeoArgs.Builder.count(storage.count),
true
).futureLift.void
)

Comment on lines +1367 to +1394

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the GeoRadiusKeyStorage overload: GeoRadiusDistStorage carries a sort, but it is ignored here (only count is applied). If sort is meant to be part of the storage configuration, include it in the GeoArgs you pass to geosearchstore so ASC/DESC behavior is actually honored.

Suggested change
async.flatMap(
_.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
GeoArgs.Builder.count(storage.count),
false
).futureLift.void
)
override def geoSearch(
key: K,
from: effects.GeoSearch[V],
area: GeoSearchArea,
storage: GeoRadiusDistStorage[K]
): F[Unit] =
async.flatMap(
_.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
GeoArgs.Builder.count(storage.count),
true
).futureLift.void
)
async.flatMap { commands =>
val geoArgs = GeoArgs.Builder.count(storage.count)
storage.sort.foreach(geoArgs.sort)
commands
.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
geoArgs,
false
)
.futureLift
.void
}
override def geoSearch(
key: K,
from: effects.GeoSearch[V],
area: GeoSearchArea,
storage: GeoRadiusDistStorage[K]
): F[Unit] =
async.flatMap { commands =>
val geoArgs = GeoArgs.Builder.count(storage.count)
storage.sort.foreach(geoArgs.sort)
commands
.geosearchstore(
storage.key,
key,
from.asJava,
area.asJava,
geoArgs,
true
)
.futureLift
.void
}

Copilot uses AI. Check for mistakes.
// format: off
/******************************* Sorted Sets API **********************************/
// format: on
Expand Down Expand Up @@ -2034,6 +2081,14 @@ private[redis4cats] trait RedisConversionOps {
GeoHash(v.getGeohash),
GeoCoordinate(v.getCoordinates.getX.doubleValue(), v.getCoordinates.getY.doubleValue())
)

def asGeoSearchResult: GeoSearchResult[V] =
GeoSearchResult[V](
v.getMember,
Option(v.getDistance).map(Distance(_)),
Option(v.getGeohash).map(GeoHash(_)),
Option(v.getCoordinates).map(c => GeoCoordinate(c.getX.doubleValue(), c.getY.doubleValue()))
)
}

private[redis4cats] implicit class GeoRadiusKeyStorageOps[K](v: GeoRadiusKeyStorage[K]) {
Expand All @@ -2054,6 +2109,21 @@ private[redis4cats] trait RedisConversionOps {
}
}

private[redis4cats] implicit class GeoSearchOps[V](v: effects.GeoSearch[V]) {
def asJava[K]: io.lettuce.core.GeoSearch.GeoRef[K] = v match {
case effects.GeoSearch.FromMember(member) => io.lettuce.core.GeoSearch.fromMember(member.asInstanceOf[K])
Comment on lines +2113 to +2114

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GeoSearchOps.asJava introduces an unsafe asInstanceOf cast and a spurious type parameter (def asJava[K]), even though the conversion target type is determined by the member type V. This can be made type-safe by returning GeoRef[V] directly and passing member without casting, reducing the risk of runtime ClassCastException and making the conversion easier to reason about.

Suggested change
def asJava[K]: io.lettuce.core.GeoSearch.GeoRef[K] = v match {
case effects.GeoSearch.FromMember(member) => io.lettuce.core.GeoSearch.fromMember(member.asInstanceOf[K])
def asJava: io.lettuce.core.GeoSearch.GeoRef[V] = v match {
case effects.GeoSearch.FromMember(member) => io.lettuce.core.GeoSearch.fromMember(member)

Copilot uses AI. Check for mistakes.
case effects.GeoSearch.FromLonLat(longitude, latitude) =>
io.lettuce.core.GeoSearch.fromCoordinates(longitude.value, latitude.value)
}
}

private[redis4cats] implicit class GeoSearchAreaOps(v: GeoSearchArea) {
def asJava: io.lettuce.core.GeoSearch.GeoPredicate = v match {
case GeoSearchArea.ByRadius(radius, unit) => io.lettuce.core.GeoSearch.byRadius(radius.value, unit)
case GeoSearchArea.ByBox(width, height, unit) => io.lettuce.core.GeoSearch.byBox(width, height, unit)
}
}

private[redis4cats] implicit class ZRangeOps[T: Numeric](range: ZRange[T]) {
def asJavaRange: JRange[Number] = {
def toJavaNumber(t: T): java.lang.Number = t match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ trait TestScenarios { self: FunSuite =>
_ <- IO(assert(y.contains(GeoCoordinate(-43.17289799451828, -22.906801071586663))))
z <- redis.geoRadius(testKey, GeoRadius(_Montevideo.lon, _Montevideo.lat, Distance(10000.0)), GeoArgs.Unit.km)
_ <- IO(assert(z.toList.containsSlice(List(_BuenosAires.value, _Montevideo.value, _RioDeJaneiro.value))))
gs1 <- redis.geoSearch(
testKey,
GeoSearch.FromMember(_BuenosAires.value),
GeoSearchArea.ByRadius(Distance(500.0), GeoArgs.Unit.km),
GeoArgs.Builder.distance()
)
_ <- IO(assert(gs1.exists(_.value == _BuenosAires.value)))
_ <- IO(assert(gs1.exists(_.value == _Montevideo.value)))
_ <- IO(assert(!gs1.exists(_.value == _RioDeJaneiro.value)))
_ <- IO(assert(gs1.forall(_.dist.isDefined)))
gs2 <- redis.geoSearch(
testKey,
GeoSearch.FromLonLat(_BuenosAires.lon, _BuenosAires.lat),
GeoSearchArea.ByBox(1000.0, 1000.0, GeoArgs.Unit.km),
GeoArgs.Builder.count(2)
)
_ <- IO(assertEquals(gs2.size, 2))
_ <- IO(assert(gs2.exists(_.value == _BuenosAires.value)))
_ <- IO(assert(gs2.exists(_.value == _Montevideo.value)))
_ <- redis.geoSearch(
testKey,
GeoSearch.FromMember(_BuenosAires.value),
GeoSearchArea.ByRadius(Distance(500.0), GeoArgs.Unit.km),
GeoRadiusKeyStorage("gs-store", 10, GeoArgs.Sort.asc)
)
gs3 <- redis.zRange("gs-store", 0, -1)
_ <- IO(assert(gs3.contains(_BuenosAires.value)))
_ <- IO(assert(gs3.contains(_Montevideo.value)))
} yield ()
}

Expand Down
Loading