From 62432f9671d39218f5fcc0f04b41abfea02a99a6 Mon Sep 17 00:00:00 2001 From: mgravell Date: Fri, 13 Nov 2026 14:08:15 +0000 Subject: [PATCH] v4 overload minimalism: SortedSetRange, Expiration on RESTORE, GeoCenter The three findings from the v4 group-surface overload audit (notes/api-surface/overload-audit.md): - SortedSets: one method per operation - RangeAsync, RangeWithScoresAsync, RangeAndStoreAsync, RemoveRangeAsync, RangeLengthAsync - each taking a SortedSetRange operand (ByRank/ByScore/ByValue) instead of eleven per-axis methods; LengthAsync slims to bare ZCARD. The per-axis methods demote to internal, keeping the wire spelling and the legacy IDatabase adapter. Cells the server cannot spell (WITHSCORES over BYLEX, LIMIT or a count over ranks) throw rather than quietly answering a different question; a by-rank exclusivity is no longer representable at all. The operand store path also gains open lexical bounds and descending bound-swapping, which the SortedSetOrder-shaped legacy method left to the caller. - Keys.RestoreAsync takes an Expiration: relative stays positional milliseconds, absolute adds ABSTTL, KEEPTTL/PERSIST/ENX are rejected, and the TimeSpan.MaxValue-means-none wart survives only in the internal legacy sibling. - Geospatial: a GeoCenter operand (implicit from RedisValue/string/GeoPosition; deliberately not from a tuple - ValueTupleNotReferenced pins that) folds the member/lon-lat SearchAsync and SearchAndStoreAsync pairs 4 -> 2. En route: RangeByScoreCore and CombineCore accepted a CancellationToken from their public callers and dropped it before SendAsync; both now forward it. Geo's SearchCore has the same drop, left for a sweep of its own. --- docs/Groups.md | 2 +- docs/LegacyApi.md | 32 +- notes/api-surface/overload-audit.md | 140 +++++++++ .../Database/RedisDatabase.Geospatial.cs | 8 +- .../Database/RedisDatabase.Keys.cs | 4 +- .../Database/RedisDatabase.SortedSets.cs | 8 +- src/StackExchange.Redis/GeoCenter.cs | 85 +++++ .../Groups/Geospatial.Methods.cs | 89 ++---- src/StackExchange.Redis/Groups/Geospatial.cs | 2 +- .../Groups/Keys.Methods.cs | 43 ++- .../Groups/SortedSets.Methods.cs | 290 +++++++++++++++--- .../Protocol/RespLiterals.cs | 4 + .../PublicAPI/PublicAPI.Unshipped.txt | 39 ++- src/StackExchange.Redis/SortedSetRange.cs | 100 ++++++ .../RespSurfaceGeospatialTests.cs | 15 +- .../RespSurfaceKeysTests.cs | 31 ++ .../RespSurfaceSortedSetsTests.cs | 112 +++++-- 17 files changed, 824 insertions(+), 180 deletions(-) create mode 100644 notes/api-surface/overload-audit.md create mode 100644 src/StackExchange.Redis/GeoCenter.cs create mode 100644 src/StackExchange.Redis/SortedSetRange.cs diff --git a/docs/Groups.md b/docs/Groups.md index 3f6133f3b..a8ff7398d 100644 --- a/docs/Groups.md +++ b/docs/Groups.md @@ -31,7 +31,7 @@ On a database (`IDatabase`, and anything else that carries a database - see belo | `Hashes` | [hashes](https://redis.io/docs/latest/develop/data-types/hashes/) | `GetAsync`, `SetAsync`, `GetAllAsync` | | `Lists` | [lists](https://redis.io/docs/latest/develop/data-types/lists/) | `LeftPushAsync`, `RightPopAsync`, `RangeAsync` | | `Sets` | [sets](https://redis.io/docs/latest/develop/data-types/sets/) | `AddAsync`, `ContainsAsync`, `MembersAsync` | -| `SortedSets` | [sorted sets](https://redis.io/docs/latest/develop/data-types/sorted-sets/) | `AddAsync`, `RangeByScoreAsync`, `PopAsync` | +| `SortedSets` | [sorted sets](https://redis.io/docs/latest/develop/data-types/sorted-sets/) | `AddAsync`, `RangeAsync`, `PopAsync` | | `Streams` | [streams](https://redis.io/docs/latest/develop/data-types/streams/) | `AddAsync`, `ReadAsync`, `ReadGroupAsync` | | `Keys` | any key | `DeleteAsync`, `ExistsAsync`, `ExpireAsync` | | `Geospatial` | [geospatial indexes](https://redis.io/docs/latest/develop/data-types/geospatial/) | `AddAsync`, `SearchAsync` | diff --git a/docs/LegacyApi.md b/docs/LegacyApi.md index 6adba57c0..afb669457 100644 --- a/docs/LegacyApi.md +++ b/docs/LegacyApi.md @@ -16,7 +16,7 @@ db.KeyDelete(key); await db.Keys.UnlinkAsync(key); What actually changed --- -**The prefix became a group.** `db.StringGet` is `db.Strings.GetAsync`; `db.SortedSetRangeByScore` is `db.SortedSets.RangeByScoreAsync`. The prefix was always naming a data type - it just had nowhere to live, because one interface had to hold every command. Now the receiver says which family you are in, so IntelliSense lists twenty members instead of four hundred, and the name stops repeating what the receiver already said. +**The prefix became a group.** `db.StringGet` is `db.Strings.GetAsync`; `db.SortedSetRangeByScore` is `db.SortedSets.RangeAsync` with a `SortedSetRange.ByScore(...)` operand. The prefix was always naming a data type - it just had nowhere to live, because one interface had to hold every command. Now the receiver says which family you are in, so IntelliSense lists twenty members instead of four hundred, and the name stops repeating what the receiver already said. **It is async-only.** There is no `db.Strings.Get`. The synchronous half of the old API exists because it always has, and it stays for the code that uses it, but blocking on a multiplexed connection is the single most common cause of the timeouts this library gets reported - see [Sync over async](SyncOverAsync). A new surface that offered a sync twin would be inviting the problem back in. @@ -47,7 +47,7 @@ Mechanical, with three rules: drop the type prefix, use it as the group, add `As | `db.SetAdd(key, value)` | `await db.Sets.AddAsync(key, value)` | | `db.SetMembers(key)` | `await db.Sets.MembersAsync(key)` | | `db.SortedSetAdd(key, member, score)` | `await db.SortedSets.AddAsync(key, member, score)` | -| `db.SortedSetRangeByScore(key)` | `await db.SortedSets.RangeByScoreAsync(key)` | +| `db.SortedSetRangeByScore(key)` | `await db.SortedSets.RangeAsync(key, SortedSetRange.ByScore())` | | `db.GeoAdd(key, lon, lat, member)` | `await db.Geospatial.AddAsync(key, lon, lat, member)` | | `db.HyperLogLogAdd(key, value)` | `await db.HyperLogLog.AddAsync(key, value)` | | `db.StringBitCount(key)` | `await db.Bitmaps.CountAsync(key)` | @@ -206,23 +206,23 @@ Generated from the v4 `IDatabase` implementation by `eng/docs/api-map.py`; each | `SortedSetCombineWithScores` | `db.SortedSets.CombineWithScoresAsync` | | `SortedSetIncrement` | `db.SortedSets.IncrementAsync` | | `SortedSetIntersectionLength` | `db.SortedSets.CombineLengthAsync` | -| `SortedSetLength` | `db.SortedSets.LengthAsync` | -| `SortedSetLengthByValue` | `db.SortedSets.LengthByValueAsync` | +| `SortedSetLength` | `db.SortedSets.LengthAsync`, or `RangeLengthAsync` with a score range | +| `SortedSetLengthByValue` | `db.SortedSets.RangeLengthAsync` with `SortedSetRange.ByValue(...)` | | `SortedSetPop` | `db.SortedSets.PopAsync` | | `SortedSetRandomMember` | `db.SortedSets.RandomMemberAsync` | | `SortedSetRandomMembers` | `db.SortedSets.RandomMembersAsync` | | `SortedSetRandomMembersWithScores` | `db.SortedSets.RandomMembersWithScoresAsync` | -| `SortedSetRangeAndStore` | `db.SortedSets.RangeAndStoreAsync` | -| `SortedSetRangeByRank` | `db.SortedSets.RangeByRankAsync` | -| `SortedSetRangeByRankWithScores` | `db.SortedSets.RangeByRankWithScoresAsync` | -| `SortedSetRangeByScore` | `db.SortedSets.RangeByScoreAsync` | -| `SortedSetRangeByScoreWithScores` | `db.SortedSets.RangeByScoreWithScoresAsync` | -| `SortedSetRangeByValue` | `db.SortedSets.RangeByValueAsync` | +| `SortedSetRangeAndStore` | `db.SortedSets.RangeAndStoreAsync` with a `SortedSetRange` | +| `SortedSetRangeByRank` | `db.SortedSets.RangeAsync` with `SortedSetRange.ByRank(...)` | +| `SortedSetRangeByRankWithScores` | `db.SortedSets.RangeWithScoresAsync` with `SortedSetRange.ByRank(...)` | +| `SortedSetRangeByScore` | `db.SortedSets.RangeAsync` with `SortedSetRange.ByScore(...)` | +| `SortedSetRangeByScoreWithScores` | `db.SortedSets.RangeWithScoresAsync` with `SortedSetRange.ByScore(...)` | +| `SortedSetRangeByValue` | `db.SortedSets.RangeAsync` with `SortedSetRange.ByValue(...)` | | `SortedSetRank` | `db.SortedSets.RankAsync` | | `SortedSetRemove` | `db.SortedSets.RemoveAsync` | -| `SortedSetRemoveRangeByRank` | `db.SortedSets.RemoveRangeByRankAsync` | -| `SortedSetRemoveRangeByScore` | `db.SortedSets.RemoveRangeByScoreAsync` | -| `SortedSetRemoveRangeByValue` | `db.SortedSets.RemoveRangeByValueAsync` | +| `SortedSetRemoveRangeByRank` | `db.SortedSets.RemoveRangeAsync` with `SortedSetRange.ByRank(...)` | +| `SortedSetRemoveRangeByScore` | `db.SortedSets.RemoveRangeAsync` with `SortedSetRange.ByScore(...)` | +| `SortedSetRemoveRangeByValue` | `db.SortedSets.RemoveRangeAsync` with `SortedSetRange.ByValue(...)` | | `SortedSetScan` | `db.SortedSets.ScanAsync` | | `SortedSetScore` | `db.SortedSets.ScoreAsync` | | `SortedSetScores` | `db.SortedSets.ScoresAsync` | @@ -245,7 +245,7 @@ Generated from the v4 `IDatabase` implementation by `eng/docs/api-map.py`; each | `KeyRandom` | `db.Keys.RandomAsync` | | `KeyRefCount` | `db.Keys.RefCountAsync` | | `KeyRename` | `db.Keys.RenameAsync` | -| `KeyRestore` | `db.Keys.RestoreAsync` | +| `KeyRestore` | `db.Keys.RestoreAsync` - takes an `Expiration`, so an absolute deadline (`ABSTTL`) works too | | `KeyTimeToLive` | `db.Keys.TimeToLiveAsync` | | `KeyTouch` | `db.Keys.TouchAsync` | | `KeyType` | `db.Keys.TypeAsync` | @@ -291,8 +291,8 @@ Generated from the v4 `IDatabase` implementation by `eng/docs/api-map.py`; each | `GeoHash` | `db.Geospatial.HashAsync` | | `GeoPosition` | `db.Geospatial.PositionAsync` | | `GeoRemove` | `db.Geospatial.RemoveAsync` | -| `GeoSearch` | `db.Geospatial.SearchAsync` | -| `GeoSearchAndStore` | `db.Geospatial.SearchAndStoreAsync` | +| `GeoSearch` | `db.Geospatial.SearchAsync` - one method; the member/position pair became a `GeoCenter` | +| `GeoSearchAndStore` | `db.Geospatial.SearchAndStoreAsync` - likewise via `GeoCenter` | **HyperLogLog** diff --git a/notes/api-surface/overload-audit.md b/notes/api-surface/overload-audit.md new file mode 100644 index 000000000..f5321e487 --- /dev/null +++ b/notes/api-surface/overload-audit.md @@ -0,0 +1,140 @@ +# v4 group-surface overload audit + +An audit of the new (context/group) API surface for the overload-minimalism goal: one method with +richer optional operands (`Expiration`, `ValueCondition`) rather than a stack of overloads +accumulated per nuance, as the legacy surface has. The legacy `IDatabase` surface is out of scope: +its overloads mirror v3 deliberately, for migration. + +Method: every `public static` extension in `Groups/*.Methods.cs` (247 methods, 17 groups), +clustered by name within a group, each cluster classified. + +## The surface is already mostly minimal + +Nearly every overload pair is one of three deliberate shapes, none of which is the cumulative +pattern: + +- **Single vs many** (`AddAsync(key, value)` / `AddAsync(key, span)`): different reply types + (`bool` vs `long`, value vs lease), so they cannot be one method. ~30 clusters, all fine. +- **`long` vs `double`** (`IncrementAsync`): typed returns; folding would box or lose inference. +- **Different command, different reply** (`DeleteAsync` → `XDEL` vs `XDELEX`; + `IncrementAsync` → `INCRBY` vs `INCREX`, whose reply carries the applied delta): the reply shape + forces the split, and each is documented at the method. + +`Strings.SetAsync(key, value, Expiration = default, ValueCondition = default)` is the exemplar +already in place; `VectorSets` already uses request objects (`VectorSetAddRequest`) where the +clause space is large. `Streams` expresses its large option spaces as optional parameters and +`StreamAddOptions`, not overloads. + +## Findings + +### A. SortedSets: the range family is still the v3 per-command split (the one real cluster) + +Eleven methods say "a range of a sorted set", split by axis exactly as the pre-6.2 commands were: + +| axis | read | read+scores | remove | length | +|---|---|---|---|---| +| rank | `RangeByRankAsync` | `RangeByRankWithScoresAsync` | `RemoveRangeByRankAsync` | - | +| score | `RangeByScoreAsync` | `RangeByScoreWithScoresAsync` | `RemoveRangeByScoreAsync` | `LengthAsync(min,max,exclude)` | +| value | `RangeByValueAsync` | - | `RemoveRangeByValueAsync` | `LengthByValueAsync` | + +plus `RangeAndStoreAsync(... SortedSetOrder sortedSetOrder ...)`, which already smuggles the axis +in as an enum. The implementations still compose `ZRANGEBYSCORE`/`ZREVRANGEBYLEX`/etc. per cell. + +**Proposal**: a `SortedSetRange` operand type in the `Expiration`/`ValueCondition` mould - a +readonly struct with factories `SortedSetRange.ByRank(start, stop)`, +`.ByScore(min, max, Exclude = None)`, `.ByValue(min, max, Exclude = None)` - and five methods: + +``` +RangeAsync(key, SortedSetRange range, Order order = Ascending, long skip = 0, long take = -1) +RangeWithScoresAsync(key, SortedSetRange range, Order order = Ascending, long skip = 0, long take = -1) +RangeAndStoreAsync(source, destination, SortedSetRange range, Order order = ..., long skip = 0, long? take = null) +RemoveRangeAsync(key, SortedSetRange range) +RangeLengthAsync(key, SortedSetRange range) +``` + +Order/skip/take stay on the methods, not the operand, because the remove/length commands have no +spelling for them (and a by-rank length is `(stop-start+1)` clamped, which the operand can refuse +or the method can compute). The operand picks the wire command (modern `ZRANGE`/`ZRANGESTORE` +where the axis needs it, the per-axis command where it doesn't or the server is old). + +Eleven methods become five, and the axis enum on `RangeAndStoreAsync` disappears. All alpha, so +the old names can just go. This is the one change in the audit I'd call clearly worth making. + +**Correction found while implementing**: the by-value `WithScores` cell is not "arbitrarily +lacking" - the server has no `WITHSCORES` for `ZRANGEBYLEX` (every member of a lex-ordered set +shares one score by definition), so `RangeWithScoresAsync` *rejects* a by-value range rather than +filling the cell. Likewise there is no server command counting a rank range, so +`RangeLengthAsync` rejects by-rank (the answer is arithmetic over `LengthAsync`). The by-value +*store* cell is real, though, and the operand surface did gain it - including open (`-`/`+`) +lexical bounds and descending bound-swapping, which the `SortedSetOrder`-shaped legacy method +left to the caller to spell. + +### B. Keys.RestoreAsync takes `TimeSpan?` where `Expiration` fits better + +`RestoreAsync(key, value, TimeSpan? expiry = null)` - but `RESTORE` has `ABSTTL`, so an +`Expiration` (which already spells relative, absolute, and none, and `KEEPTTL` is simply invalid +here) is the natural operand: `RestoreAsync(key, value, Expiration expiry = default)`. Small, +mechanical, and exactly the pattern the rest of the surface uses. The `TimeSpan.MaxValue` +means-none special case goes away too. + +### C. Geospatial: four methods carrying the same four-knob tail + +`SearchAsync` ×2 and `SearchAndStoreAsync` ×2 differ only in how the centre is spelled (member vs +longitude/latitude), and each repeats `int count = -1, bool demandClosest = true, Order? order = +null, ...`. Two options, independent of each other: + +1. A `GeoCenter` struct with implicit conversions from `RedisValue` and `(double, double)` folds + each pair into one method (4 → 2). +2. A small `GeoSearchOptions` record for the shared tail (count/demandClosest/order + the + radius-options/store-distances leg) if the knob count grows again. + +(1) is cheap and real; (2) is only worth it if the server grows more clauses - four optional +parameters is still readable. Suggest doing (1), skipping (2) for now. + +## Outcome + +All three findings implemented (branch `marc/v4-overload-minimalism`): + +- **A**: `SortedSetRange` (`ByRank`/`ByScore`/`ByValue` factories; `default` = whole set by rank) + with `RangeAsync`, `RangeWithScoresAsync`, `RangeAndStoreAsync`, `RemoveRangeAsync`, + `RangeLengthAsync`; `LengthAsync` slimmed to bare `ZCARD` (`ZCOUNT`/`ZLEXCOUNT` live under + `RangeLengthAsync`). The per-axis methods were demoted to internal, still carrying the wire + spelling and the legacy `IDatabase` adapter. A by-rank exclusivity is now unrepresentable + rather than rejected at runtime; by-rank skip/take and the no-server-command cells throw. +- **B**: `RestoreAsync(key, value, Expiration expiry = default)` - relative stays positional + milliseconds, absolute adds `ABSTTL`, and `KEEPTTL`/`PERSIST`/ENX are rejected; the + `TimeSpan.MaxValue`-means-none wart survives only in the internal legacy sibling. +- **C(1)**: `GeoCenter` (implicit from `RedisValue`/`string`/`GeoPosition`; *not* from a tuple - + `SanityChecks.ValueTupleNotReferenced` pins that this library never references + `System.ValueTuple`, which net461 only gets from an extra package) folds `SearchAsync` and + `SearchAndStoreAsync` 4 → 2; a `default` center is rejected. C(2) (`GeoSearchOptions`) + deliberately skipped, as proposed. + +Incidental fix en route: `RangeByScoreCore` and `CombineCore` accepted a `CancellationToken` from +their public callers and silently dropped it before `SendAsync`; both now forward it. The same +drop exists in geo's `SearchCore` (`default` passed where the token should go) - left alone here, +worth a sweep of its own. + +## Considered and deliberately left alone + +- **`Hashes.SetAsync` vs `SetWithExpiryAsync`** (HSET vs HSETEX): folding to one method with + `Expiration expiry = default` would make the expiry argument silently switch the wire command - + different server gate (8.0), different condition semantics (HSETEX's FNX/FXX are per-call over + all fields, HSET's `When` is not), different reply. The name is the honest signal; keep. + Same reasoning for `GetSetExpiryAsync` (HGETEX) beside `GetAsync`. +- **`...LeaseAsync` variants** (`GetLeaseAsync`, `GetLeaseDeleteAsync`, ...): a return-form axis, + documented in Groups.md; not an overload stack. +- **Pop families ×3** (`LeftPopAsync` etc.): single → value, count → lease, multi-key → `LMPOP` + result type. Three shapes, three replies. +- **`Lists.MoveAsync` ×2**: `LMOVE` (one value) vs the counted form (lease); reply split again. +- **`Strings.IncrementAsync` ×4**: `{long,double}` × `{INCRBY,INCREX}`; both axes forced. +- **Hash field-TTL family is span-only** (`ExpireAsync(key, fields, ...)`, no single-field + convenience): the minimal choice already; don't add singles. +- **Context withers**: public surface is `WithDatabase`/`WithCachePolicy`/`WithServices` + + `AppendKeyPrefix` - already policy-shaped (the cache withers were folded into `CachePolicy` in + #3350/#3354). + +## Not examined + +`IServer`'s membership (large, node-scoped, mostly single methods per command - no clusters +surfaced in the scan) and the legacy `IDatabase`/`IDatabaseAsync` mirror (out of scope by design). diff --git a/src/StackExchange.Redis/Database/RedisDatabase.Geospatial.cs b/src/StackExchange.Redis/Database/RedisDatabase.Geospatial.cs index ffffa98ef..21b0a1ca5 100644 --- a/src/StackExchange.Redis/Database/RedisDatabase.Geospatial.cs +++ b/src/StackExchange.Redis/Database/RedisDatabase.Geospatial.cs @@ -133,19 +133,19 @@ public Task GeoSearchAsync(RedisKey key, double longitude, do /// public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => Wait(Blocking.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, member, shape, count, demandClosest, order, storeDistances, flags)); + => Wait(Blocking.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, GeoCenter.Member(member), shape, count, demandClosest, order, storeDistances, flags)); /// public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => _inner.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, member, shape, count, demandClosest, order, storeDistances, flags).AsTask(AsyncState, flags); + => _inner.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, GeoCenter.Member(member), shape, count, demandClosest, order, storeDistances, flags).AsTask(AsyncState, flags); /// public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => Wait(Blocking.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags)); + => Wait(Blocking.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, GeoCenter.Position(longitude, latitude), shape, count, demandClosest, order, storeDistances, flags)); /// public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => _inner.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags).AsTask(AsyncState, flags); + => _inner.Geospatial.SearchAndStoreAsync(destinationKey, sourceKey, GeoCenter.Position(longitude, latitude), shape, count, demandClosest, order, storeDistances, flags).AsTask(AsyncState, flags); /// /// The old surface rejects a numeric member here, because it is almost certainly a longitude that diff --git a/src/StackExchange.Redis/Database/RedisDatabase.Keys.cs b/src/StackExchange.Redis/Database/RedisDatabase.Keys.cs index 9430e902b..208c7cdc1 100644 --- a/src/StackExchange.Redis/Database/RedisDatabase.Keys.cs +++ b/src/StackExchange.Redis/Database/RedisDatabase.Keys.cs @@ -298,11 +298,11 @@ private ValueTask ExpireDeadline(RespDatabaseContext context, RedisKey key /// public void KeyRestore(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) - => Wait(Blocking.Keys.RestoreAsync(key, Required(value, nameof(value)), expiry, flags)); + => Wait(Blocking.Keys.RestoreCore(key, Required(value, nameof(value)), expiry, flags)); /// public Task KeyRestoreAsync(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) - => _inner.Keys.RestoreAsync(key, Required(value, nameof(value)), expiry, flags).AsTask(AsyncState, flags); + => _inner.Keys.RestoreCore(key, Required(value, nameof(value)), expiry, flags).AsTask(AsyncState, flags); /// public void KeyMigrate(RedisKey key, System.Net.EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/Database/RedisDatabase.SortedSets.cs b/src/StackExchange.Redis/Database/RedisDatabase.SortedSets.cs index f683c1436..556669762 100644 --- a/src/StackExchange.Redis/Database/RedisDatabase.SortedSets.cs +++ b/src/StackExchange.Redis/Database/RedisDatabase.SortedSets.cs @@ -153,11 +153,11 @@ public Task SortedSetRemoveAsync(RedisKey key, RedisValue[] members, Comma /// public long SortedSetLength(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => Wait(Blocking.SortedSets.LengthAsync(key, min, max, exclude, flags)); + => Wait(Blocking.SortedSets.LengthCore(key, min, max, exclude, flags)); /// public Task SortedSetLengthAsync(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => _inner.SortedSets.LengthAsync(key, min, max, exclude, flags).AsTask(AsyncState, flags); + => _inner.SortedSets.LengthCore(key, min, max, exclude, flags).AsTask(AsyncState, flags); /// public long SortedSetLengthByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) @@ -251,11 +251,11 @@ public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue mi /// public long SortedSetRangeAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) - => Wait(Blocking.SortedSets.RangeAndStoreAsync(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags)); + => Wait(Blocking.SortedSets.RangeAndStoreCore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags)); /// public Task SortedSetRangeAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) - => _inner.SortedSets.RangeAndStoreAsync(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags).AsTask(AsyncState, flags); + => _inner.SortedSets.RangeAndStoreCore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags).AsTask(AsyncState, flags); // ---- removal by range -------------------------------------------------------------------------- diff --git a/src/StackExchange.Redis/GeoCenter.cs b/src/StackExchange.Redis/GeoCenter.cs new file mode 100644 index 000000000..cfa1a5fe1 --- /dev/null +++ b/src/StackExchange.Redis/GeoCenter.cs @@ -0,0 +1,85 @@ +using System; + +namespace StackExchange.Redis; + +/// +/// Where a geospatial search starts: a member of the set (FROMMEMBER) or a longitude/latitude +/// position (FROMLONLAT). One operand replaces the per-origin method pairs the old surface +/// accumulated: the origin is data, and and +/// each take the same one. +/// +/// +/// Rarely written explicitly: a or member and a +/// each convert implicitly. (Not a tuple: this library deliberately never +/// references System.ValueTuple, which down-level targets only get from an extra package.) +/// +public readonly struct GeoCenter +{ + private enum CenterKind : byte + { + None = 0, // the default instance, which no search accepts + Member = 1, + Position = 2, + } + + private readonly CenterKind _kind; + private readonly RedisValue _member; + private readonly double _longitude; + private readonly double _latitude; + + private GeoCenter(CenterKind kind, in RedisValue member, double longitude, double latitude) + { + _kind = kind; + _member = member; + _longitude = longitude; + _latitude = latitude; + } + + /// Search around an existing member of the set: FROMMEMBER. + /// The member to search around; must not be null. + public static GeoCenter Member(RedisValue member) + => member.IsNull + ? throw new ArgumentNullException(nameof(member)) + : new(CenterKind.Member, member, double.NaN, double.NaN); + + /// Search around a coordinate: FROMLONLAT. + /// The longitude to search around. + /// The latitude to search around. + public static GeoCenter Position(double longitude, double latitude) + => new(CenterKind.Position, RedisValue.Null, longitude, latitude); + + /// Search around an existing member of the set. + /// The member to search around. + public static implicit operator GeoCenter(RedisValue member) => Member(member); + + /// Search around an existing member of the set. + /// The member to search around. + public static implicit operator GeoCenter(string member) => Member(member); + + /// Search around a coordinate. + /// The position to search around. + public static implicit operator GeoCenter(GeoPosition position) => Position(position.Longitude, position.Latitude); + + // the sentinels SearchCore has always used: a null member means "by position", NaN means "no position" + internal RedisValue MemberOrNull => _kind == CenterKind.Member ? _member : RedisValue.Null; + + internal double Longitude => _kind == CenterKind.Position ? _longitude : double.NaN; + + internal double Latitude => _kind == CenterKind.Position ? _latitude : double.NaN; + + internal void Validate(string paramName) + { + if (_kind == CenterKind.None) + { + throw new ArgumentException("A search center is required: a member of the set, or a longitude/latitude position.", paramName); + } + } + + /// + public override string ToString() => _kind switch + { + CenterKind.Member => $"member {_member}", + CenterKind.Position => $"{_longitude},{_latitude}", + _ => "(none)", + }; +} diff --git a/src/StackExchange.Redis/Groups/Geospatial.Methods.cs b/src/StackExchange.Redis/Groups/Geospatial.Methods.cs index 8b57a559b..b70a63325 100644 --- a/src/StackExchange.Redis/Groups/Geospatial.Methods.cs +++ b/src/StackExchange.Redis/Groups/Geospatial.Methods.cs @@ -144,10 +144,11 @@ public static ValueTask RemoveAsync(this RespGeospatial geo, RedisKey key, PositionLeaseHandler.Lease, cancellationToken: cancellationToken); - /// GEOSEARCH from a member of the set. + /// GEOSEARCH: the members inside a shape around a center. /// The geospatial command group. /// The key to read. - /// The member to search around. + /// Where to search from: a member of the set (FROMMEMBER) or a + /// longitude/latitude position (FROMLONLAT); see . /// The area to search. /// How many results to return, or -1 for all of them. /// Whether the results must be the closest ones, rather than the first found. @@ -158,7 +159,7 @@ public static ValueTask RemoveAsync(this RespGeospatial geo, RedisKey key, public static ValueTask> SearchAsync( this RespGeospatial geo, RedisKey key, - RedisValue member, + GeoCenter center, GeoSearchShape shape, int count = -1, bool demandClosest = true, @@ -166,43 +167,20 @@ public static ValueTask> SearchAsync( GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => SearchCore(geo, default, key, member, double.NaN, double.NaN, shape, count, demandClosest, false, order, options, flags, GeoResultHandler.Lease(options)); - - /// GEOSEARCH from a coordinate. - /// The geospatial command group. - /// The key to read. - /// The longitude to search around. - /// The latitude to search around. - /// - /// - /// - /// - /// - /// Command flags. - /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask> SearchAsync( - this RespGeospatial geo, - RedisKey key, - double longitude, - double latitude, - GeoSearchShape shape, - int count = -1, - bool demandClosest = true, - Order? order = null, - GeoRadiusOptions options = GeoRadiusOptions.Default, - CommandFlags flags = CommandFlags.None, - CancellationToken cancellationToken = default) - => SearchCore(geo, default, key, RedisValue.Null, longitude, latitude, shape, count, demandClosest, false, order, options, flags, GeoResultHandler.Lease(options)); + { + center.Validate(nameof(center)); + return SearchCore(geo, default, key, center.MemberOrNull, center.Longitude, center.Latitude, shape, count, demandClosest, false, order, options, flags, GeoResultHandler.Lease(options)); + } - /// GEOSEARCHSTORE from a member: the same search, written to a key. + /// GEOSEARCHSTORE: the same search, written to a key; the reply is how many were stored. /// The geospatial command group. /// The key to write the matches to. /// The key to read. - /// - /// - /// - /// - /// + /// + /// + /// + /// + /// /// Whether to store the distance as the score, rather than the position. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. @@ -210,7 +188,7 @@ public static ValueTask SearchAndStoreAsync( this RespGeospatial geo, RedisKey destination, RedisKey sourceKey, - RedisValue member, + GeoCenter center, GeoSearchShape shape, int count = -1, bool demandClosest = true, @@ -218,35 +196,10 @@ public static ValueTask SearchAndStoreAsync( bool storeDistances = false, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => SearchCore(geo, destination, sourceKey, member, double.NaN, double.NaN, shape, count, demandClosest, storeDistances, order, GeoRadiusOptions.None, flags, RespHandlers.Int64); - - /// GEOSEARCHSTORE from a coordinate. - /// The geospatial command group. - /// - /// The key to read. - /// - /// - /// - /// - /// - /// - /// - /// Command flags. - /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask SearchAndStoreAsync( - this RespGeospatial geo, - RedisKey destination, - RedisKey sourceKey, - double longitude, - double latitude, - GeoSearchShape shape, - int count = -1, - bool demandClosest = true, - Order? order = null, - bool storeDistances = false, - CommandFlags flags = CommandFlags.None, - CancellationToken cancellationToken = default) - => SearchCore(geo, destination, sourceKey, RedisValue.Null, longitude, latitude, shape, count, demandClosest, storeDistances, order, GeoRadiusOptions.None, flags, RespHandlers.Int64); + { + center.Validate(nameof(center)); + return SearchCore(geo, destination, sourceKey, center.MemberOrNull, center.Longitude, center.Latitude, shape, count, demandClosest, storeDistances, order, GeoRadiusOptions.None, flags, RespHandlers.Int64); + } /// /// for the @@ -281,7 +234,7 @@ public static ValueTask SearchAndStoreAsync( cancellationToken: cancellationToken); /// - /// + /// /// for the old shape, with the two origins folded into one signature. /// /// The geospatial command group. @@ -327,7 +280,7 @@ internal static ValueTask SearchArray( /// /// /// Internal, and deliberately not on the group's own surface: - /// + /// /// is what callers want, and it takes a box as readily as a circle. This exists so that a caller /// of the old method keeps working against a server older than 6.2. /// diff --git a/src/StackExchange.Redis/Groups/Geospatial.cs b/src/StackExchange.Redis/Groups/Geospatial.cs index 36a281a51..3ecd44720 100644 --- a/src/StackExchange.Redis/Groups/Geospatial.cs +++ b/src/StackExchange.Redis/Groups/Geospatial.cs @@ -43,7 +43,7 @@ public static partial class Geospatial /// GEOREM, and there never was. /// /// -/// The group's own method is ; +/// The group's own method is ; /// GEORADIUS has no method here at all, exactly as GETSET and RPOPLPUSH have none. /// It survives as an internal sibling so that a caller of the old GeoRadius keeps working on a /// server that predates GEOSEARCH. diff --git a/src/StackExchange.Redis/Groups/Keys.Methods.cs b/src/StackExchange.Redis/Groups/Keys.Methods.cs index e2705e4f7..ef78fc699 100644 --- a/src/StackExchange.Redis/Groups/Keys.Methods.cs +++ b/src/StackExchange.Redis/Groups/Keys.Methods.cs @@ -472,7 +472,9 @@ private static RedisCommand SelectKeyExpireCommand(Expiration expiry) /// The key command group. /// The key to create. /// The serialised value, as DUMP returned it. - /// How long the key should live; no expiry when omitted. + /// When the key should expire - relative, absolute (ABSTTL), or not at + /// all when omitted. and mean + /// nothing here - a restored key has no expiry to keep - and are rejected. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. /// @@ -485,7 +487,44 @@ public static ValueTask RestoreAsync( this RespKeys keys, RedisKey key, scoped ReadOnlySpan value, - TimeSpan? expiry = null, + Expiration expiry = default, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + { + // RESTORE's ttl is a positional millisecond argument - zero for none, with ABSTTL turning it + // into an absolute unix-time deadline - rather than the EX/PXAT token families Expiration + // normally renders, so the modes are spelled out here instead of letting the operand write itself + if (expiry.IsNone) + { + return keys.Context.SendAsync($"{RedisCommand.RESTORE}{key}{0L}{value}", flags, cancellationToken); + } + + if (expiry.IsKeepTtl || expiry.IsPersist || expiry.IsExpireIfNotExists) + { + throw new ArgumentException( + "RESTORE takes a deadline or nothing: KEEPTTL, PERSIST and ENX have no spelling there.", nameof(expiry)); + } + + var millis = expiry.IsMilliseconds ? expiry.Value : expiry.Value * 1000; + return expiry.IsAbsolute + ? keys.Context.SendAsync($"{RedisCommand.RESTORE}{key}{millis}{value}{RespLiterals.AbsTtl}", flags, cancellationToken) + : keys.Context.SendAsync($"{RedisCommand.RESTORE}{key}{millis}{value}", flags, cancellationToken); + } + + /// RESTORE for the old IDatabase surface, whose "no expiry" arrives as + /// or ; the public spelling is + /// . + /// The key command group. + /// The key to create. + /// The serialised value, as DUMP returned it. + /// How long the key should live; no expiry when omitted. + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + internal static ValueTask RestoreCore( + this RespKeys keys, + RedisKey key, + scoped ReadOnlySpan value, + TimeSpan? expiry, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) { diff --git a/src/StackExchange.Redis/Groups/SortedSets.Methods.cs b/src/StackExchange.Redis/Groups/SortedSets.Methods.cs index 6dd56d716..c1fdcd9dc 100644 --- a/src/StackExchange.Redis/Groups/SortedSets.Methods.cs +++ b/src/StackExchange.Redis/Groups/SortedSets.Methods.cs @@ -189,7 +189,22 @@ public static ValueTask RemoveAsync(this RespSortedSets sortedSets, RedisK : sortedSets.Context.SendAsync( $"{RedisCommand.ZMSCORE}{key}{members}", flags, cancellationToken: cancellationToken); - /// ZCARD, or ZCOUNT when a score range is given. + /// ZCARD: how many members the set has. + /// The sorted-set command group. + /// The key to measure. + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + /// How many fall in part of the set is . + public static ValueTask LengthAsync( + this RespSortedSets sortedSets, + RedisKey key, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZCARD}{key}", flags, cancellationToken: cancellationToken); + + /// ZCARD, or ZCOUNT when a score range is given, for the old IDatabase surface; + /// the public spelling is / . /// The sorted-set command group. /// The key to measure. /// The lowest score to count. @@ -202,12 +217,12 @@ public static ValueTask RemoveAsync(this RespSortedSets sortedSets, RedisK /// walk anything - so the default arguments pick a different command, exactly as the old surface /// does. /// - public static ValueTask LengthAsync( + internal static ValueTask LengthCore( this RespSortedSets sortedSets, RedisKey key, - double min = double.NegativeInfinity, - double max = double.PositiveInfinity, - Exclude exclude = Exclude.None, + double min, + double max, + Exclude exclude, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) { @@ -223,7 +238,8 @@ public static ValueTask LengthAsync( cancellationToken: cancellationToken); } - /// ZLEXCOUNT: how many members fall in a lexical range. + /// ZLEXCOUNT: how many members fall in a lexical range, for the old IDatabase + /// surface; the public spelling is . /// The sorted-set command group. /// The key to measure. /// The lowest member to count. @@ -231,7 +247,7 @@ public static ValueTask LengthAsync( /// Which bounds are exclusive. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask LengthByValueAsync( + internal static ValueTask LengthByValueAsync( this RespSortedSets sortedSets, RedisKey key, RedisValue min, @@ -327,8 +343,201 @@ internal static ValueTask RandomMembersWithScoresArray(this Re } // ---- ranges ------------------------------------------------------------------------------------ + // One public method per OPERATION, each taking a SortedSetRange whose axis - rank, score or + // member value - is data rather than a method-name suffix. The per-axis internals below them keep + // the wire spelling (and still serve the old IDatabase surface). Where the server has no command + // for a cell - WITHSCORES over a lexical range, LIMIT over a rank range, a count of a rank range - + // the dispatcher throws rather than quietly answering a different question. + + /// + /// Read a range of the set: ZRANGE/ZREVRANGE, ZRANGEBYSCORE/ZREVRANGEBYSCORE + /// or ZRANGEBYLEX/ZREVRANGEBYLEX, by the range's axis and the order. + /// + /// The sorted-set command group. + /// The key to read. + /// Which part of the set to read; see . + /// Which end to read from. + /// How many to discard from the front; not available for a by-rank range. + /// How many to return, -1 for all; not available for a by-rank range. + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + /// + /// A by-rank range has no skip/take on the server - the window is the rank bounds - so + /// passing one throws rather than silently reading a different range. + /// + public static ValueTask> RangeAsync( + this RespSortedSets sortedSets, + RedisKey key, + SortedSetRange range, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + => range.Axis switch + { + SortedSetRange.RangeAxis.Score => RangeByScoreAsync(sortedSets, key, range.ScoreMin, range.ScoreMax, range.Exclude, order, skip, take, flags, cancellationToken), + SortedSetRange.RangeAxis.Value => RangeByValueAsync(sortedSets, key, range.ValueMin, range.ValueMax, range.Exclude, order, skip, take, flags, cancellationToken), + _ => RangeByRankAsync(sortedSets, key, RankStartNoWindow(range, skip, take), range.RankStop, order, flags, cancellationToken), + }; + + /// + /// Read a range of the set with the scores: ZRANGE/ZREVRANGE or + /// ZRANGEBYSCORE/ZREVRANGEBYSCORE with WITHSCORES, by the range's axis and the + /// order. Not available for a by-value range, whose members all share one score by definition - + /// the server has no WITHSCORES there. + /// + /// The sorted-set command group. + /// The key to read. + /// Which part of the set to read; see . + /// Which end to read from. + /// How many to discard from the front; not available for a by-rank range. + /// How many to return, -1 for all; not available for a by-rank range. + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + /// + public static ValueTask> RangeWithScoresAsync( + this RespSortedSets sortedSets, + RedisKey key, + SortedSetRange range, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + => range.Axis switch + { + SortedSetRange.RangeAxis.Score => RangeByScoreWithScoresAsync(sortedSets, key, range.ScoreMin, range.ScoreMax, range.Exclude, order, skip, take, flags, cancellationToken), + SortedSetRange.RangeAxis.Value => throw new ArgumentException( + "The server has no WITHSCORES for a by-value (lexical) range: every member of such a set has the same score by definition. Use RangeAsync.", nameof(range)), + _ => RangeByRankWithScoresAsync(sortedSets, key, RankStartNoWindow(range, skip, take), range.RankStop, order, flags, cancellationToken), + }; + + /// + /// ZRANGESTORE: copy a range of into + /// , by the range's axis and the order; the reply is the + /// destination's size. + /// + /// The sorted-set command group. + /// The key to read. + /// The key to write the result to. + /// Which part of the source to store; see . + /// Which end to read from. + /// How many to discard from the front; not available for a by-rank range. + /// How many to store, for all; not available for a by-rank range. + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + /// + public static ValueTask RangeAndStoreAsync( + this RespSortedSets sortedSets, + RedisKey sourceKey, + RedisKey destinationKey, + SortedSetRange range, + Order order = Order.Ascending, + long skip = 0, + long? take = null, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + { + switch (range.Axis) + { + case SortedSetRange.RangeAxis.Score: + // with REV the server wants the bounds high-then-low, exclusivity travelling with its + // bound - the same normalization the read path applies inside RangeByScoreCore + var (lo, hi, scoreExclude) = (range.ScoreMin, range.ScoreMax, range.Exclude); + if (order == Order.Descending) + { + (lo, hi) = (hi, lo); + scoreExclude = SwapBounds(scoreExclude); + } + + return RangeAndStoreCore(sortedSets, sourceKey, destinationKey, lo, hi, SortedSetOrder.ByScore, scoreExclude, order, skip, take, flags, cancellationToken); + + case SortedSetRange.RangeAxis.Value: + // composed here rather than via the legacy-shaped internal: an open (null) bound must + // render as -/+, which LexBound spells and the RedisValue-typed internal cannot + var (min, max, exclude) = (range.ValueMin, range.ValueMax, range.Exclude); + SortedSets.ReverseLimits(order, ref exclude, ref min, ref max); + var rev = RespLiterals.Rev.When(order == Order.Descending); + var limit = take is > 0 ? new RespLimitRange(skip, take.GetValueOrDefault()) : RespLimitRange.None; + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANGESTORE}{destinationKey}{sourceKey}{Lex(min, exclude, isStart: true, order)}{Lex(max, exclude, isStart: false, order)}{RespLiterals.ByLex}{rev}{limit}", + flags.WithDefaultCategory(RedisCommand.ZRANGESTORE), + cancellationToken: cancellationToken); + + default: + _ = RankStartNoWindow(range, skip, take ?? -1); + return RangeAndStoreCore(sortedSets, sourceKey, destinationKey, range.RankStart, range.RankStop, SortedSetOrder.ByRank, Exclude.None, order, 0, null, flags, cancellationToken); + } + } + + /// + /// ZREMRANGEBYRANK/ZREMRANGEBYSCORE/ZREMRANGEBYLEX, by the range's axis; the reply is how many were + /// removed. + /// + /// The sorted-set command group. + /// The key to write. + /// Which part of the set to remove; see . + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + public static ValueTask RemoveRangeAsync( + this RespSortedSets sortedSets, + RedisKey key, + SortedSetRange range, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + => range.Axis switch + { + SortedSetRange.RangeAxis.Score => RemoveRangeByScoreAsync(sortedSets, key, range.ScoreMin, range.ScoreMax, range.Exclude, flags, cancellationToken), + SortedSetRange.RangeAxis.Value => RemoveRangeByValueAsync(sortedSets, key, range.ValueMin, range.ValueMax, range.Exclude, flags, cancellationToken), + _ => RemoveRangeByRankAsync(sortedSets, key, range.RankStart, range.RankStop, flags, cancellationToken), + }; - /// ZRANGE/ZREVRANGE by rank. + /// + /// ZCOUNT/ZLEXCOUNT, by the range's axis: how many members fall in the range. Not available for a + /// by-rank range - the server has no such command, because the answer is arithmetic over the rank + /// bounds and . + /// + /// The sorted-set command group. + /// The key to measure. + /// Which part of the set to count; see . + /// Command flags. + /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. + public static ValueTask RangeLengthAsync( + this RespSortedSets sortedSets, + RedisKey key, + SortedSetRange range, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + => range.Axis switch + { + SortedSetRange.RangeAxis.Score => LengthCore(sortedSets, key, range.ScoreMin, range.ScoreMax, range.Exclude, flags, cancellationToken), + SortedSetRange.RangeAxis.Value => LengthByValueAsync(sortedSets, key, range.ValueMin, range.ValueMax, range.Exclude, flags, cancellationToken), + _ => throw new ArgumentException( + "The server has no command that counts a by-rank range: clamp the rank bounds against LengthAsync and the count is (stop - start + 1).", nameof(range)), + }; + + /// The rank start of a range that must not carry a window, because the by-rank commands have no LIMIT. + private static long RankStartNoWindow(in SortedSetRange range, long skip, long take) + { + if (skip != 0 || take != -1) + { + throw new ArgumentException( + "A by-rank range has no skip/take on the server: fold the window into the rank bounds themselves.", nameof(skip)); + } + + return range.RankStart; + } + + /// Swap which end of a range an exclusion applies to, for when the bounds themselves swap. + private static Exclude SwapBounds(Exclude exclude) => exclude switch + { + Exclude.Start => Exclude.Stop, + Exclude.Stop => Exclude.Start, + _ => exclude, + }; + + /// ZRANGE/ZREVRANGE by rank; the public spelling is . /// The sorted-set command group. /// The key to read. /// The first rank to take. @@ -336,7 +545,7 @@ internal static ValueTask RandomMembersWithScoresArray(this Re /// Which end to count from. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask> RangeByRankAsync( + internal static ValueTask> RangeByRankAsync( this RespSortedSets sortedSets, RedisKey key, long start = 0, @@ -382,7 +591,7 @@ internal static ValueTask RangeByRankArray( /// Which end to count from. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask> RangeByRankWithScoresAsync( + internal static ValueTask> RangeByRankWithScoresAsync( this RespSortedSets sortedSets, RedisKey key, long start = 0, @@ -420,7 +629,7 @@ internal static ValueTask RangeByRankWithScoresArray( return sortedSets.Context.SendAsync(ref cmd, flags, cancellationToken: cancellationToken); } - /// ZRANGEBYSCORE/ZREVRANGEBYSCORE. + /// ZRANGEBYSCORE/ZREVRANGEBYSCORE; the public spelling is . /// The sorted-set command group. /// The key to read. /// The lowest score to take. @@ -437,7 +646,7 @@ internal static ValueTask RangeByRankWithScoresArray( /// asked to walk. That is the old builder's rule, kept exactly, because a caller who passed /// (10, 1) descending has always meant the same thing. /// - public static ValueTask> RangeByScoreAsync( + internal static ValueTask> RangeByScoreAsync( this RespSortedSets sortedSets, RedisKey key, double start = double.NegativeInfinity, @@ -448,7 +657,11 @@ public static ValueTask> RangeByScoreAsync( long take = -1, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => RangeByScoreCore>(sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags); + => RangeByScoreCore>(sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags, cancellationToken); + + // (visibility note: the per-axis methods above and below are internal because the public surface + // is the SortedSetRange-operand family; the wire spelling, bound-swapping and documentation all + // stay here, where the per-axis commands are actually composed.) /// RangeByScore, as an array, for the old IDatabase surface. /// @@ -473,7 +686,7 @@ internal static ValueTask RangeByScoreArray( long take = -1, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => RangeByScoreCore(sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags); + => RangeByScoreCore(sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags, cancellationToken); /// /// The sorted-set command group. @@ -486,7 +699,7 @@ internal static ValueTask RangeByScoreArray( /// How many to return; -1 for all. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask> RangeByScoreWithScoresAsync( + internal static ValueTask> RangeByScoreWithScoresAsync( this RespSortedSets sortedSets, RedisKey key, double start = double.NegativeInfinity, @@ -497,7 +710,7 @@ public static ValueTask> RangeByScoreWithScoresAsy long take = -1, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => RangeByScoreCore>(sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags); + => RangeByScoreCore>(sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags, cancellationToken); /// RangeByScoreWithScores, as an array, for the old IDatabase surface. /// @@ -522,9 +735,9 @@ internal static ValueTask RangeByScoreWithScoresArray( long take = -1, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) - => RangeByScoreCore(sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags); + => RangeByScoreCore(sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags, cancellationToken); - /// ZRANGEBYLEX/ZREVRANGEBYLEX. + /// ZRANGEBYLEX/ZREVRANGEBYLEX; the public spelling is . /// The sorted-set command group. /// The key to read. /// The lowest member to take. @@ -540,7 +753,7 @@ internal static ValueTask RangeByScoreWithScoresArray( /// the open bounds then flip too, which is why - and + are chosen by the order /// rather than by the position. /// - public static ValueTask> RangeByValueAsync( + internal static ValueTask> RangeByValueAsync( this RespSortedSets sortedSets, RedisKey key, RedisValue min = default, @@ -600,7 +813,9 @@ internal static ValueTask RangeByValueArray( cancellationToken: cancellationToken); } - /// ZRANGESTORE; the reply is the destination's size. + /// ZRANGESTORE for the old IDatabase surface, whose axis arrives as + /// and whose bounds arrive pre-spelled; the public spelling is + /// . The reply is the destination's size. /// The sorted-set command group. /// The key to read. /// The key to write the result to. @@ -618,7 +833,7 @@ internal static ValueTask RangeByValueArray( /// are rejected - the server has no operand for either in that mode, so silently dropping them /// would store a different range than was asked for. /// - public static ValueTask RangeAndStoreAsync( + internal static ValueTask RangeAndStoreCore( this RespSortedSets sortedSets, RedisKey sourceKey, RedisKey destinationKey, @@ -668,18 +883,18 @@ public static ValueTask RangeAndStoreAsync( // ---- removal by range -------------------------------------------------------------------------- - /// ZREMRANGEBYRANK. + /// ZREMRANGEBYRANK; the public spelling is . /// The sorted-set command group. /// The key to write. /// The first rank to remove. /// The last rank to remove. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask RemoveRangeByRankAsync(this RespSortedSets sortedSets, RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) + internal static ValueTask RemoveRangeByRankAsync(this RespSortedSets sortedSets, RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) => sortedSets.Context.SendAsync( $"{RedisCommand.ZREMRANGEBYRANK}{key}{start}{stop}", flags, cancellationToken: cancellationToken); - /// ZREMRANGEBYSCORE. + /// ZREMRANGEBYSCORE; the public spelling is . /// The sorted-set command group. /// The key to write. /// The lowest score to remove. @@ -687,13 +902,13 @@ public static ValueTask RemoveRangeByRankAsync(this RespSortedSets sortedS /// Which bounds are exclusive. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask RemoveRangeByScoreAsync(this RespSortedSets sortedSets, RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) + internal static ValueTask RemoveRangeByScoreAsync(this RespSortedSets sortedSets, RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) => sortedSets.Context.SendAsync( $"{RedisCommand.ZREMRANGEBYSCORE}{key}{SortedSets.GetRange(start, exclude, isStart: true)}{SortedSets.GetRange(stop, exclude, isStart: false)}", flags, cancellationToken: cancellationToken); - /// ZREMRANGEBYLEX. + /// ZREMRANGEBYLEX; the public spelling is . /// The sorted-set command group. /// The key to write. /// The lowest member to remove. @@ -701,7 +916,7 @@ public static ValueTask RemoveRangeByScoreAsync(this RespSortedSets sorted /// Which bounds are exclusive. /// Command flags. /// Cancels the request: one not yet written is never sent; one already written still runs on the server, and its reply is discarded. - public static ValueTask RemoveRangeByValueAsync(this RespSortedSets sortedSets, RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) + internal static ValueTask RemoveRangeByValueAsync(this RespSortedSets sortedSets, RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) { SortedSets.ReverseLimits(Order.Ascending, ref exclude, ref min, ref max); return sortedSets.Context.SendAsync( @@ -730,7 +945,7 @@ public static ValueTask> CombineAsync( CancellationToken cancellationToken = default) { var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); - return CombineCore>(sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags); + return CombineCore>(sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags, cancellationToken); } /// Combine, as an array, for the old IDatabase surface. @@ -755,7 +970,7 @@ internal static ValueTask CombineArray( CancellationToken cancellationToken = default) { var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); - return CombineCore(sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags); + return CombineCore(sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags, cancellationToken); } /// @@ -776,7 +991,7 @@ public static ValueTask> CombineWithScoresAsync( CancellationToken cancellationToken = default) { var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); - return CombineCore>(sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags); + return CombineCore>(sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags, cancellationToken); } /// CombineWithScores, as an array, for the old IDatabase surface. @@ -801,7 +1016,7 @@ internal static ValueTask CombineWithScoresArray( CancellationToken cancellationToken = default) { var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); - return CombineCore(sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags); + return CombineCore(sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags, cancellationToken); } /// ZUNIONSTORE/ZINTERSTORE/ZDIFFSTORE; the reply is the destination's size. @@ -824,7 +1039,7 @@ public static ValueTask CombineAndStoreAsync( CancellationToken cancellationToken = default) { var command = ValidateCombine(operation.ToSortedSetStoreCommand(), keys, weights, aggregate); - return CombineCore(sortedSets, command, destination, keys, weights, aggregate, withScores: false, flags); + return CombineCore(sortedSets, command, destination, keys, weights, aggregate, withScores: false, flags, cancellationToken); } /// ZINTERCARD: the size of an intersection, without building it. @@ -952,7 +1167,8 @@ private static ValueTask RangeByScoreCore( long skip, long take, bool withScores, - CommandFlags flags) + CommandFlags flags, + CancellationToken cancellationToken) { var command = order == Order.Descending ? RedisCommand.ZREVRANGEBYSCORE : RedisCommand.ZRANGEBYSCORE; @@ -975,7 +1191,8 @@ private static ValueTask RangeByScoreCore( return sortedSets.Context.SendAsync( $"{command}{key}{from}{to}{scores}{new RespLimitRange(skip, take)}", - flags); + flags, + cancellationToken: cancellationToken); } /// The checks the combination commands share, and the command they resolve to. @@ -1016,7 +1233,8 @@ private static ValueTask CombineCore( ReadOnlySpan weights, Aggregate aggregate, bool withScores, - CommandFlags flags) + CommandFlags flags, + CancellationToken cancellationToken) { var argHint = 2 + keys.Length + (weights.IsEmpty ? 0 : 1 + weights.Length) + 3; var cmd = sortedSets.Context.Compose(command, argHint); @@ -1045,7 +1263,7 @@ private static ValueTask CombineCore( } var frame = cmd.Complete(); - return sortedSets.Context.SendAsync(ref frame, flags, RespHandlers.Inbuilt.Require(), default); + return sortedSets.Context.SendAsync(ref frame, flags, RespHandlers.Inbuilt.Require(), cancellationToken); } /// A lexical bound, shared with the MessageWriter path. diff --git a/src/StackExchange.Redis/Protocol/RespLiterals.cs b/src/StackExchange.Redis/Protocol/RespLiterals.cs index d186c49e2..ce8ed1eaa 100644 --- a/src/StackExchange.Redis/Protocol/RespLiterals.cs +++ b/src/StackExchange.Redis/Protocol/RespLiterals.cs @@ -86,6 +86,10 @@ internal static partial class RespLiterals [Resp] internal static partial RespFragment Len { get; } + /// The ABSTTL operand of RESTORE: the ttl argument is an absolute unix-time deadline in milliseconds. + [Resp] + internal static partial RespFragment AbsTtl { get; } + /// The SLOTS sub-command, as used by CLUSTER SLOTS. [Resp] internal static partial RespFragment Slots { get; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 560c29e96..c6349b626 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -186,6 +186,14 @@ static StackExchange.Redis.Downlevel.RespGroups.Strings(this TTarget ta static StackExchange.Redis.Downlevel.RespGroups.VectorSets(this TTarget target) -> StackExchange.Redis.RespVectorSets static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.ReadOnlyLease? bytes, bool ownsLease = true) -> System.IO.Stream? static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? +StackExchange.Redis.GeoCenter +StackExchange.Redis.GeoCenter.GeoCenter() -> void +static StackExchange.Redis.GeoCenter.Member(StackExchange.Redis.RedisValue member) -> StackExchange.Redis.GeoCenter +static StackExchange.Redis.GeoCenter.Position(double longitude, double latitude) -> StackExchange.Redis.GeoCenter +override StackExchange.Redis.GeoCenter.ToString() -> string! +static StackExchange.Redis.GeoCenter.implicit operator StackExchange.Redis.GeoCenter(StackExchange.Redis.GeoPosition position) -> StackExchange.Redis.GeoCenter +static StackExchange.Redis.GeoCenter.implicit operator StackExchange.Redis.GeoCenter(StackExchange.Redis.RedisValue member) -> StackExchange.Redis.GeoCenter +static StackExchange.Redis.GeoCenter.implicit operator StackExchange.Redis.GeoCenter(string! member) -> StackExchange.Redis.GeoCenter StackExchange.Redis.Geospatial static StackExchange.Redis.Geospatial.AddAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, StackExchange.Redis.GeoEntry value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Geospatial.AddAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -195,10 +203,8 @@ static StackExchange.Redis.Geospatial.HashAsync(this StackExchange.Redis.RespGeo static StackExchange.Redis.Geospatial.PositionAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Geospatial.PositionAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.Geospatial.RemoveAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.Geospatial.SearchAndStoreAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisValue member, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, bool storeDistances = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.Geospatial.SearchAndStoreAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey sourceKey, double longitude, double latitude, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, bool storeDistances = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.Geospatial.SearchAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, StackExchange.Redis.GeoRadiusOptions options = StackExchange.Redis.GeoRadiusOptions.Default, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.Geospatial.SearchAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, double longitude, double latitude, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, StackExchange.Redis.GeoRadiusOptions options = StackExchange.Redis.GeoRadiusOptions.Default, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static StackExchange.Redis.Geospatial.SearchAndStoreAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.GeoCenter center, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, bool storeDistances = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.Geospatial.SearchAsync(this StackExchange.Redis.RespGeospatial geo, StackExchange.Redis.RedisKey key, StackExchange.Redis.GeoCenter center, StackExchange.Redis.GeoSearchShape! shape, int count = -1, bool demandClosest = true, StackExchange.Redis.Order? order = null, StackExchange.Redis.GeoRadiusOptions options = StackExchange.Redis.GeoRadiusOptions.Default, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> StackExchange.Redis.Hashes static StackExchange.Redis.Hashes.DeleteAsync(this StackExchange.Redis.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Hashes.DeleteAsync(this StackExchange.Redis.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -271,7 +277,7 @@ static StackExchange.Redis.Keys.PersistAsync(this StackExchange.Redis.RespKeys k static StackExchange.Redis.Keys.RandomAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Keys.RefCountAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Keys.RenameAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisKey newKey, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.Keys.RestoreAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, scoped System.ReadOnlySpan value, System.TimeSpan? expiry = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.Keys.RestoreAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, scoped System.ReadOnlySpan value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Keys.SortAndStoreAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey key, long skip = 0, long take = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.SortType sortType = StackExchange.Redis.SortType.Numeric, StackExchange.Redis.RedisValue by = default(StackExchange.Redis.RedisValue), System.ReadOnlySpan get = default(System.ReadOnlySpan), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Keys.SortAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, long skip = 0, long take = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.SortType sortType = StackExchange.Redis.SortType.Numeric, StackExchange.Redis.RedisValue by = default(StackExchange.Redis.RedisValue), System.ReadOnlySpan get = default(System.ReadOnlySpan), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.Keys.TimeToLiveAsync(this StackExchange.Redis.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -686,6 +692,12 @@ static StackExchange.Redis.Sets.RemoveAsync(this StackExchange.Redis.RespSets se static StackExchange.Redis.Sets.RemoveAsync(this StackExchange.Redis.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.Sets.ScanAsync(this StackExchange.Redis.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue pattern = default(StackExchange.Redis.RedisValue), int? pageSize = null, long cursor = 0, int pageOffset = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! static StackExchange.Redis.Sets.ScanPageAsync(this StackExchange.Redis.RespSets sets, StackExchange.Redis.RedisKey key, long cursor = 0, StackExchange.Redis.RedisValue pattern = default(StackExchange.Redis.RedisValue), int? pageSize = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +StackExchange.Redis.SortedSetRange +static StackExchange.Redis.SortedSetRange.ByRank(long start = 0, long stop = -1) -> StackExchange.Redis.SortedSetRange +static StackExchange.Redis.SortedSetRange.ByScore(double min = -Infinity, double max = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None) -> StackExchange.Redis.SortedSetRange +static StackExchange.Redis.SortedSetRange.ByValue(StackExchange.Redis.RedisValue min = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue max = default(StackExchange.Redis.RedisValue), StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None) -> StackExchange.Redis.SortedSetRange +StackExchange.Redis.SortedSetRange.SortedSetRange() -> void +override StackExchange.Redis.SortedSetRange.ToString() -> string! StackExchange.Redis.SortedSets static StackExchange.Redis.SortedSets.AddAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double score, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.AddAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -694,26 +706,21 @@ static StackExchange.Redis.SortedSets.CombineAsync(this StackExchange.Redis.Resp static StackExchange.Redis.SortedSets.CombineLengthAsync(this StackExchange.Redis.RespSortedSets sortedSets, System.ReadOnlySpan keys, long? limit = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.CombineWithScoresAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.SortedSets.IncrementAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double value, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.LengthAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double min = -Infinity, double max = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.LengthByValueAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.SortedSets.LengthAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.PopAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.PopAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.SortedSets.PopAsync(this StackExchange.Redis.RespSortedSets sortedSets, System.ReadOnlySpan keys, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.RandomMemberAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.RandomMembersAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.SortedSets.RandomMembersWithScoresAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.SortedSets.RangeAndStoreAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.RedisValue start, StackExchange.Redis.RedisValue stop, StackExchange.Redis.SortedSetOrder sortedSetOrder = StackExchange.Redis.SortedSetOrder.ByRank, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long? take = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.RangeByRankAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.SortedSets.RangeByRankWithScoresAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.SortedSets.RangeByScoreAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.SortedSets.RangeByScoreWithScoresAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -static StackExchange.Redis.SortedSets.RangeByValueAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue max = default(StackExchange.Redis.RedisValue), StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static StackExchange.Redis.SortedSets.RangeAndStoreAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.SortedSetRange range, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long? take = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.SortedSets.RangeAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.SortedSetRange range, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static StackExchange.Redis.SortedSets.RangeLengthAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.SortedSetRange range, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.SortedSets.RangeWithScoresAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.SortedSetRange range, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static StackExchange.Redis.SortedSets.RankAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.RemoveAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.RemoveAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.RemoveRangeByRankAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start, long stop, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.RemoveRangeByScoreAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static StackExchange.Redis.SortedSets.RemoveRangeByValueAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static StackExchange.Redis.SortedSets.RemoveRangeAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.SortedSetRange range, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.SortedSets.ScanAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue pattern = default(StackExchange.Redis.RedisValue), int? pageSize = null, long cursor = 0, int pageOffset = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! static StackExchange.Redis.SortedSets.ScanPageAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long cursor = 0, StackExchange.Redis.RedisValue pattern = default(StackExchange.Redis.RedisValue), int? pageSize = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> static StackExchange.Redis.SortedSets.ScoreAsync(this StackExchange.Redis.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/StackExchange.Redis/SortedSetRange.cs b/src/StackExchange.Redis/SortedSetRange.cs new file mode 100644 index 000000000..d356d2ac9 --- /dev/null +++ b/src/StackExchange.Redis/SortedSetRange.cs @@ -0,0 +1,100 @@ +using System; + +namespace StackExchange.Redis; + +/// +/// A range of a sorted set: by rank, by score, or by member value, with the bound exclusivity where the +/// axis has one. One operand replaces the per-axis method families (RangeByRank..., +/// RangeByScore..., RangeByValue...) the old surface accumulated: the axis is data, and +/// every operation that walks a range (, +/// , , +/// ) takes the same one. +/// +/// +/// Create one with , or . A +/// instance is the whole set, by rank. Direction, and where the axis supports +/// them, paging, are not part of the range: they are parameters of the read, because the remove and +/// length forms have no server spelling for them. +/// +public readonly struct SortedSetRange +{ + internal enum RangeAxis : byte + { + Rank = 0, // the default-instance axis, so default(SortedSetRange) means "everything" + Score = 1, + Value = 2, + } + + private readonly RangeAxis _axis; + private readonly Exclude _exclude; + private readonly long _rankStart; + private readonly long _rankStopComplement; // ~stop, so the default instance (0) reads back as -1: the whole set + private readonly double _scoreMin; + private readonly double _scoreMax; + private readonly RedisValue _valueMin; + private readonly RedisValue _valueMax; + + private SortedSetRange(RangeAxis axis, Exclude exclude, long rankStart, long rankStop, double scoreMin, double scoreMax, RedisValue valueMin, RedisValue valueMax) + { + _axis = axis; + _exclude = exclude; + _rankStart = rankStart; + _rankStopComplement = ~rankStop; + _scoreMin = scoreMin; + _scoreMax = scoreMax; + _valueMin = valueMin; + _valueMax = valueMax; + } + + internal RangeAxis Axis => _axis; + + internal Exclude Exclude => _exclude; + + internal long RankStart => _rankStart; + + internal long RankStop => ~_rankStopComplement; + + internal double ScoreMin => _axis == RangeAxis.Score ? _scoreMin : double.NegativeInfinity; + + internal double ScoreMax => _axis == RangeAxis.Score ? _scoreMax : double.PositiveInfinity; + + internal RedisValue ValueMin => _valueMin; + + internal RedisValue ValueMax => _valueMax; + + /// A range of positions: through , both inclusive, negative counting from the end. + /// The first rank to take; negative counts from the end. + /// The last rank to take; negative counts from the end, and the default of -1 is the last member. + /// + /// There is no exclusivity here because ranks have no spelling for it on the server; an exclusive + /// position is simply the next position. + /// + public static SortedSetRange ByRank(long start = 0, long stop = -1) + => new(RangeAxis.Rank, Exclude.None, start, stop, default, default, default, default); + + /// A range of scores: through , inclusive except as says. + /// The lowest score to take; the default is unbounded. + /// The highest score to take; the default is unbounded. + /// Which bounds are exclusive. + public static SortedSetRange ByScore(double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None) + => new(RangeAxis.Score, exclude, default, default, min, max, default, default); + + /// + /// A range of member values, for a set whose members all share one score: + /// through in lexical order, inclusive except as + /// says. A default (null) bound is unbounded at that end. + /// + /// The lowest member to take; default for unbounded. + /// The highest member to take; default for unbounded. + /// Which bounds are exclusive. + public static SortedSetRange ByValue(RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None) + => new(RangeAxis.Value, exclude, default, default, default, default, min, max); + + /// + public override string ToString() => _axis switch + { + RangeAxis.Score => $"scores {_scoreMin}..{_scoreMax} ({_exclude})", + RangeAxis.Value => $"values {_valueMin}..{_valueMax} ({_exclude})", + _ => $"ranks {RankStart}..{RankStop}", + }; +} diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceGeospatialTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceGeospatialTests.cs index 9d406c824..2488ed8d7 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceGeospatialTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceGeospatialTests.cs @@ -109,7 +109,7 @@ public async Task SearchNamesItsOriginAndItsShape() var (ctx, exec) = Target(); using (await ctx.Geospatial.SearchAsync("k", "a", new GeoSearchCircle(5, GeoUnit.Kilometers), options: GeoRadiusOptions.None)) { } - using (await ctx.Geospatial.SearchAsync("k", 1, 2, new GeoSearchBox(3, 4, GeoUnit.Meters), options: GeoRadiusOptions.None)) { } + using (await ctx.Geospatial.SearchAsync("k", GeoCenter.Position(1, 2), new GeoSearchBox(3, 4, GeoUnit.Meters), options: GeoRadiusOptions.None)) { } Assert.Equal( new[] @@ -157,6 +157,19 @@ public async Task AnyOnlyMakesSenseWithACount() Assert.Contains("demandClosest", ex.Message); } + [Fact] + public void ADefaultCenterIsRejected() + { + var (ctx, _) = Target(); + + // default(GeoCenter) is neither a member nor a position; sending it would quietly search + // somewhere, so it throws instead + var ex = Assert.Throws( + () => ctx.Geospatial.SearchAsync("k", default, new GeoSearchCircle(5))); + Assert.Equal("center", ex.ParamName); + Assert.Throws(() => GeoCenter.Member(RedisValue.Null)); + } + [Fact] public async Task StoringNamesTheDestinationFirst() { diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs index 6814ec610..84421eae6 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs @@ -46,6 +46,37 @@ public async Task SingleAndVariadicAreTheSameCommand() exec.Sent); } + [Fact] + public async Task RestoreSpellsItsDeadlineAsMilliseconds() + { + var (ctx, exec) = Target("+OK\r\n"); + + await ctx.Keys.RestoreAsync("k", "abc"u8); + await ctx.Keys.RestoreAsync("k", "abc"u8, TimeSpan.FromSeconds(30)); + await ctx.Keys.RestoreAsync("k", "abc"u8, new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + // RESTORE's ttl is positional milliseconds - zero for none - and an absolute deadline is the + // same argument reinterpreted by the ABSTTL trailer, not a different token family + Assert.Equal( + new[] + { + "*4|$7|RESTORE|$1|k|$1|0|$3|abc|", + "*4|$7|RESTORE|$1|k|$5|30000|$3|abc|", + "*5|$7|RESTORE|$1|k|$13|1893456000000|$3|abc|$6|ABSTTL|", + }, + exec.Sent); + } + + [Fact] + public void RestoreRejectsExpirationsThatAreNotDeadlines() + { + var (ctx, _) = Target(); + + // a restored key has no existing expiry to keep or shed, so KEEPTTL and PERSIST mean nothing + Assert.Throws(() => ctx.Keys.RestoreAsync("k", "abc"u8, Expiration.KeepTtl)); + Assert.Throws(() => ctx.Keys.RestoreAsync("k", "abc"u8, Expiration.Persist)); + } + /// The OBJECT family renders as one command with a fixed subcommand token. [Fact] public async Task TheObjectFamilyRendersItsSubcommand() diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs index bf0223857..e9ec9273b 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs @@ -102,16 +102,22 @@ public async Task AnUnboundedLengthIsADifferentCommand() var (ctx, exec) = Target(); await ctx.SortedSets.LengthAsync("k"); - await ctx.SortedSets.LengthAsync("k", 1, 10); - await ctx.SortedSets.LengthAsync("k", 1, 10, Exclude.Start); - - // the whole set is what ZCARD answers without walking anything; and an exclusive bound is a '(' + await ctx.SortedSets.RangeLengthAsync("k", SortedSetRange.ByScore()); + await ctx.SortedSets.RangeLengthAsync("k", SortedSetRange.ByScore(1, 10)); + await ctx.SortedSets.RangeLengthAsync("k", SortedSetRange.ByScore(1, 10, Exclude.Start)); + await ctx.SortedSets.RangeLengthAsync("k", SortedSetRange.ByValue("a", "j")); + + // the whole set is what ZCARD answers without walking anything - including an unbounded score + // range, which means the same thing; an exclusive bound is a '(', and a lexical count is its + // own command Assert.Equal( new[] { + "*2|$5|ZCARD|$1|k|", "*2|$5|ZCARD|$1|k|", "*4|$6|ZCOUNT|$1|k|$1|1|$2|10|", "*4|$6|ZCOUNT|$1|k|$2|(1|$2|10|", + "*4|$9|ZLEXCOUNT|$1|k|$2|[a|$2|[j|", }, exec.Sent); } @@ -121,21 +127,19 @@ public async Task AScoreRangeIsPutIntoLowThenHighOrder() { var (ctx, exec) = Target("*0\r\n"); - await ctx.SortedSets.RangeByScoreAsync("k", 1, 10); - await ctx.SortedSets.RangeByScoreAsync("k", 10, 1, order: Order.Descending); - await ctx.SortedSets.RangeByScoreAsync("k", 10, 1, Exclude.Start, Order.Descending); - await ctx.SortedSets.RangeByScoreAsync("k", 1, 10, Exclude.Start, Order.Descending); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByScore(1, 10)); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByScore(1, 10), Order.Descending); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByScore(1, 10, Exclude.Start), Order.Descending); - // each command wants its bounds in the direction it walks - ascending low-then-high, descending - // high-then-low - so a caller who wrote them the other way round gets them swapped, and the - // exclusivity swaps with them or the range quietly moves by one member. The first three are - // already in the right order for their command and pass through; the fourth is not. + // the operand always speaks min-then-max; each command wants the bounds in the direction it + // walks - ascending low-then-high, descending high-then-low - so the descending forms get them + // swapped on the way out, and the exclusivity travels with its own bound: Exclude.Start is the + // MIN bound whichever direction the walk goes, or the range quietly moves by one member. Assert.Equal( new[] { "*4|$13|ZRANGEBYSCORE|$1|k|$1|1|$2|10|", "*4|$16|ZREVRANGEBYSCORE|$1|k|$2|10|$1|1|", - "*4|$16|ZREVRANGEBYSCORE|$1|k|$3|(10|$1|1|", "*4|$16|ZREVRANGEBYSCORE|$1|k|$2|10|$2|(1|", }, exec.Sent); @@ -146,9 +150,9 @@ public async Task AWindowIsOnlyWrittenWhenOneWasAskedFor() { var (ctx, exec) = Target("*0\r\n"); - await ctx.SortedSets.RangeByScoreAsync("k", 1, 10); - await ctx.SortedSets.RangeByScoreAsync("k", 1, 10, skip: 5, take: 2); - await ctx.SortedSets.RangeByScoreWithScoresAsync("k", 1, 10, skip: 5, take: 2); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByScore(1, 10)); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByScore(1, 10), skip: 5, take: 2); + await ctx.SortedSets.RangeWithScoresAsync("k", SortedSetRange.ByScore(1, 10), skip: 5, take: 2); Assert.Equal( new[] @@ -165,10 +169,10 @@ public async Task AnOpenLexicalBoundFlipsWithTheDirection() { var (ctx, exec) = Target("*0\r\n"); - await ctx.SortedSets.RangeByValueAsync("k"); - await ctx.SortedSets.RangeByValueAsync("k", order: Order.Descending); - await ctx.SortedSets.RangeByValueAsync("k", "a", "j"); - await ctx.SortedSets.RangeByValueAsync("k", "a", "j", Exclude.Both); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByValue()); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByValue(), Order.Descending); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByValue("a", "j")); + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByValue("a", "j", Exclude.Both)); // '-' and '+' are chosen by the ORDER, not by the position; the bounds themselves stay in // start-then-stop order even for the reversed command @@ -183,15 +187,38 @@ public async Task AnOpenLexicalBoundFlipsWithTheDirection() exec.Sent); } + [Fact] + public async Task ARankRangeIsTheRankCommands() + { + var (ctx, exec) = Target("*0\r\n"); + + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByRank()); + await ctx.SortedSets.RangeAsync("k", default); // a default range is the whole set, by rank + await ctx.SortedSets.RangeAsync("k", SortedSetRange.ByRank(2, 5), Order.Descending); + await ctx.SortedSets.RangeWithScoresAsync("k", SortedSetRange.ByRank()); + + // descending rank bounds pass through unswapped: ZREVRANGE counts its ranks from the other end, + // which is the whole of what the command means + Assert.Equal( + new[] + { + "*4|$6|ZRANGE|$1|k|$1|0|$2|-1|", + "*4|$6|ZRANGE|$1|k|$1|0|$2|-1|", + "*4|$9|ZREVRANGE|$1|k|$1|2|$1|5|", + "*5|$6|ZRANGE|$1|k|$1|0|$2|-1|$10|WITHSCORES|", + }, + exec.Sent); + } + [Fact] public async Task RangeAndStoreBracketsAnInclusiveLexicalBound() { var (ctx, exec) = Target(); - await ctx.SortedSets.RangeAndStoreAsync("src", "dst", 0, -1); - await ctx.SortedSets.RangeAndStoreAsync("src", "dst", "a", "j", SortedSetOrder.ByLex); - await ctx.SortedSets.RangeAndStoreAsync("src", "dst", 1, 10, SortedSetOrder.ByScore); - await ctx.SortedSets.RangeAndStoreAsync("src", "dst", "a", "j", SortedSetOrder.ByLex, take: 3); + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByRank()); + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByValue("a", "j")); + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByScore(1, 10)); + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByValue("a", "j"), take: 3); // the destination comes FIRST, and a by-lex bound is bracketed where a by-score one is bare - // an asymmetry that belongs to the server and is exactly what gets "tidied" into a bug @@ -207,14 +234,41 @@ public async Task RangeAndStoreBracketsAnInclusiveLexicalBound() } [Fact] - public void RangeAndStoreRefusesOperandsItHasNowhereToPut() + public async Task ADescendingStoreSwapsItsBoundsLikeTheReads() + { + var (ctx, exec) = Target(); + + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByScore(1, 10, Exclude.Start), Order.Descending); + await ctx.SortedSets.RangeAndStoreAsync("src", "dst", SortedSetRange.ByValue(), Order.Descending); + + // with REV the server wants the bounds high-then-low; the exclusivity travels with its own + // bound, and an open lexical bound flips to the symbol for its end - the same normalization the + // reads apply, which the SortedSetOrder-shaped legacy surface left to the caller + Assert.Equal( + new[] + { + "*7|$11|ZRANGESTORE|$3|dst|$3|src|$2|10|$2|(1|$7|BYSCORE|$3|REV|", + "*7|$11|ZRANGESTORE|$3|dst|$3|src|$1|+|$1|-|$5|BYLEX|$3|REV|", + }, + exec.Sent); + } + + [Fact] + public void RangesRefuseWhatTheServerCannotSpell() { var (ctx, _) = Target(); - // by rank the server has no operand for either, so dropping them silently would store a - // different range than was asked for - Assert.Throws(() => ctx.SortedSets.RangeAndStoreAsync("s", "d", 0, -1, take: 3)); - Assert.Throws(() => ctx.SortedSets.RangeAndStoreAsync("s", "d", 0, -1, exclude: Exclude.Start)); + // a by-rank range has no LIMIT - the window IS the rank bounds - and no count command; a + // by-value range has no WITHSCORES, because every member of such a set shares one score. Each + // throws rather than quietly answering a different question. (A by-rank exclusivity, which the + // old surface also had to reject at runtime, is no longer representable at all.) + Assert.Throws(() => ctx.SortedSets.RangeAsync("k", SortedSetRange.ByRank(), skip: 1)); + Assert.Throws(() => ctx.SortedSets.RangeAsync("k", SortedSetRange.ByRank(), take: 3)); + Assert.Throws(() => ctx.SortedSets.RangeWithScoresAsync("k", SortedSetRange.ByRank(), skip: 1)); + Assert.Throws(() => ctx.SortedSets.RangeWithScoresAsync("k", SortedSetRange.ByValue("a", "j"))); + Assert.Throws(() => ctx.SortedSets.RangeAndStoreAsync("s", "d", SortedSetRange.ByRank(), take: 3)); + Assert.Throws(() => ctx.SortedSets.RangeAndStoreAsync("s", "d", SortedSetRange.ByRank(), skip: 1)); + Assert.Throws(() => ctx.SortedSets.RangeLengthAsync("k", SortedSetRange.ByRank())); } [Fact]