diff --git a/src/integration-test/momento/cache_client_test.exs b/src/integration-test/momento/cache_client_test.exs index f561e7c..fea8bc0 100644 --- a/src/integration-test/momento/cache_client_test.exs +++ b/src/integration-test/momento/cache_client_test.exs @@ -79,10 +79,10 @@ defmodule CacheClientTest do value = "test_value" {:error, error} = CacheClient.set(cache_client, cache_name, key, value, "sixty") - assert String.contains?(error.message, "The TTL must be a positive float") + assert String.contains?(error.message, "The TTL must be a float") {:error, error} = CacheClient.set(cache_client, cache_name, key, value, -20.0) - assert String.contains?(error.message, "The TTL must be a positive float") + assert String.contains?(error.message, "The TTL must be positive") end test "get/3 returns miss when no value is found for a key", %{ @@ -136,4 +136,178 @@ defmodule CacheClientTest do {:error, error} = CacheClient.delete(cache_client, cache_name, 12345) assert String.contains?(error.message, "The key must be a binary") end + + describe "sorted_set_put_elements/5" do + test "should be able to put elements in a sorted set, overwriting existing elements", %{ + cache_client: cache_client, + cache_name: cache_name + } do + sorted_set_name = random_string(16) + first_elements = %{"key1" => 1.0, "key2" => 2.0} + second_elements = %{"key2" => 5.0, "key3" => 3.0} + + :miss = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + + {:ok, _} = + CacheClient.sorted_set_put_elements( + cache_client, + cache_name, + sorted_set_name, + first_elements + ) + + {:ok, hit} = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + assert hit.value == [{"key1", 1.0}, {"key2", 2.0}] + + {:ok, _} = + CacheClient.sorted_set_put_elements( + cache_client, + cache_name, + sorted_set_name, + second_elements + ) + + {:ok, hit} = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + assert hit.value == [{"key1", 1.0}, {"key3", 3.0}, {"key2", 5.0}] + end + end + + describe "sorted_set_put_element/6" do + test "should be able to put individual elements in a sorted set, overwriting existing elements", + %{ + cache_client: cache_client, + cache_name: cache_name + } do + sorted_set_name = random_string(16) + + :miss = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + + {:ok, _} = + CacheClient.sorted_set_put_element(cache_client, cache_name, sorted_set_name, "key1", 1.0) + + {:ok, hit} = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + assert hit.value == [{"key1", 1.0}] + + {:ok, _} = + CacheClient.sorted_set_put_element(cache_client, cache_name, sorted_set_name, "key1", 5.0) + + {:ok, hit} = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + assert hit.value == [{"key1", 5.0}] + + {:ok, _} = + CacheClient.sorted_set_put_element(cache_client, cache_name, sorted_set_name, "key2", 2.0) + + {:ok, hit} = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + assert hit.value == [{"key2", 2.0}, {"key1", 5.0}] + end + end + + describe "sorted_set_fetch_by_rank/6" do + test "should be able to fetch in ascending and descending order", %{ + cache_client: cache_client, + cache_name: cache_name + } do + sorted_set_name = random_string(16) + elements = %{"key1" => 1.0, "key2" => 2.0, "key3" => 3.0} + + :miss = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + + {:ok, _} = + CacheClient.sorted_set_put_elements(cache_client, cache_name, sorted_set_name, elements) + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank( + cache_client, + cache_name, + sorted_set_name, + sort_order: :asc + ) + + assert hit.value == [{"key1", 1.0}, {"key2", 2.0}, {"key3", 3.0}] + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank( + cache_client, + cache_name, + sorted_set_name, + sort_order: :desc + ) + + assert hit.value == [{"key3", 3.0}, {"key2", 2.0}, {"key1", 1.0}] + end + + test "should be able to fetch a subset of a sorted set", %{ + cache_client: cache_client, + cache_name: cache_name + } do + sorted_set_name = random_string(16) + elements = [{"key1", 1.0}, {"key2", 2.0}, {"key3", 3.0}, {"key4", 4.0}, {"key5", 5.0}] + + :miss = CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name) + + {:ok, _} = + CacheClient.sorted_set_put_elements(cache_client, cache_name, sorted_set_name, elements) + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name, + start_rank: 0 + ) + + assert hit.value == [ + {"key1", 1.0}, + {"key2", 2.0}, + {"key3", 3.0}, + {"key4", 4.0}, + {"key5", 5.0} + ] + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name, + start_rank: 0, + end_rank: 5 + ) + + assert hit.value == [ + {"key1", 1.0}, + {"key2", 2.0}, + {"key3", 3.0}, + {"key4", 4.0}, + {"key5", 5.0} + ] + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name, + start_rank: 0, + end_rank: 100 + ) + + assert hit.value == [ + {"key1", 1.0}, + {"key2", 2.0}, + {"key3", 3.0}, + {"key4", 4.0}, + {"key5", 5.0} + ] + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank(cache_client, cache_name, sorted_set_name, + start_rank: 1, + end_rank: 3 + ) + + assert hit.value == [{"key2", 2.0}, {"key3", 3.0}] + + {:ok, hit} = + CacheClient.sorted_set_fetch_by_rank( + cache_client, + cache_name, + sorted_set_name, + start_rank: 1, + end_rank: 3, + sort_order: :desc + ) + + assert hit.value == [{"key4", 4.0}, {"key3", 3.0}] + end + end end diff --git a/src/integration-test/momento/integration_test_utils.exs b/src/integration-test/momento/integration_test_utils.exs index 82efaf7..abad0a7 100644 --- a/src/integration-test/momento/integration_test_utils.exs +++ b/src/integration-test/momento/integration_test_utils.exs @@ -24,7 +24,7 @@ defmodule Momento.IntegrationTestUtils do } } - cache_client = CacheClient.create!(config, credential_provider) + cache_client = CacheClient.create!(config, credential_provider, 120.0) [cache_name: cache_name, cache_client: cache_client] end diff --git a/src/lib/momento/cache_client.ex b/src/lib/momento/cache_client.ex index 29e8555..72a0c2d 100644 --- a/src/lib/momento/cache_client.ex +++ b/src/lib/momento/cache_client.ex @@ -1,5 +1,8 @@ defmodule Momento.CacheClient do alias Momento.Auth.CredentialProvider + alias Momento.Responses.{CreateCache, DeleteCache, ListCaches, Set, Get, Delete} + alias Momento.Responses.SortedSet + alias Momento.Requests.CollectionTtl alias Momento.Internal.ScsControlClient alias Momento.Internal.ScsDataClient alias Momento.Config.Configuration, as: Configuration @@ -13,12 +16,14 @@ defmodule Momento.CacheClient do @enforce_keys [ :config, :credential_provider, + :default_ttl_seconds, :control_client, :data_client ] defstruct [ :config, :credential_provider, + :default_ttl_seconds, :control_client, :data_client ] @@ -26,6 +31,7 @@ defmodule Momento.CacheClient do @opaque t() :: %__MODULE__{ config: Configuration.t(), credential_provider: CredentialProvider.t(), + default_ttl_seconds: float(), control_client: ScsControlClient.t(), data_client: ScsDataClient.t() } @@ -44,14 +50,16 @@ defmodule Momento.CacheClient do """ @spec create!( config :: Configuration.t(), - credential_provider :: CredentialProvider.t() + credential_provider :: CredentialProvider.t(), + default_ttl_seconds :: float() ) :: t() - def create!(config, credential_provider) do + def create!(config, credential_provider, default_ttl_seconds) do with control_client <- ScsControlClient.create!(credential_provider), data_client <- ScsDataClient.create!(credential_provider) do %__MODULE__{ config: config, credential_provider: credential_provider, + default_ttl_seconds: default_ttl_seconds, control_client: control_client, data_client: data_client } @@ -70,7 +78,7 @@ defmodule Momento.CacheClient do - `{:ok, %Momento.Responses.ListCaches.Ok{caches: caches}}` on a successful listing. - `{:error, error}` tuple if an error occurs. """ - @spec list_caches(client :: t()) :: Momento.Responses.ListCaches.t() + @spec list_caches(client :: t()) :: ListCaches.t() def list_caches(client) do ScsControlClient.list_caches(client.control_client) end @@ -92,7 +100,7 @@ defmodule Momento.CacheClient do @spec create_cache( client :: t(), cache_name :: String.t() - ) :: Momento.Responses.DeleteCache.t() + ) :: CreateCache.t() def create_cache(client, cache_name) do ScsControlClient.create_cache(client.control_client, cache_name) end @@ -113,7 +121,7 @@ defmodule Momento.CacheClient do @spec delete_cache( client :: t(), cache_name :: String.t() - ) :: Momento.Responses.DeleteCache.t() + ) :: DeleteCache.t() def delete_cache(client, cache_name) do ScsControlClient.delete_cache(client.control_client, cache_name) end @@ -139,11 +147,11 @@ defmodule Momento.CacheClient do cache_name :: String.t(), key :: binary(), value :: binary(), - ttl_seconds :: float() - ) :: - Momento.Responses.Set.t() + ttl_seconds :: float() | nil + ) :: Set.t() def set(client, cache_name, key, value, ttl_seconds) do - ScsDataClient.set(client.data_client, cache_name, key, value, ttl_seconds) + ttl = ttl_seconds || client.default_ttl_seconds + ScsDataClient.set(client.data_client, cache_name, key, value, ttl) end @doc """ @@ -161,8 +169,7 @@ defmodule Momento.CacheClient do - `:miss` if the key does not exist. - `{:error, error}` tuple if an error occurs. """ - @spec get(client :: t(), cache_name :: String.t(), key :: binary) :: - Momento.Responses.Get.t() + @spec get(client :: t(), cache_name :: String.t(), key :: binary) :: Get.t() def get(client, cache_name, key) do ScsDataClient.get(client.data_client, cache_name, key) end @@ -181,9 +188,91 @@ defmodule Momento.CacheClient do - `{:ok, %Momento.Responses.Delete.Ok{}}` on a successful deletion. - `{:error, error}` tuple if an error occurs. """ - @spec delete(client :: t(), cache_name :: String.t(), key :: binary) :: - Momento.Responses.Delete.t() + @spec delete(client :: t(), cache_name :: String.t(), key :: binary) :: Delete.t() def delete(client, cache_name, key) do ScsDataClient.delete(client.data_client, cache_name, key) end + + @spec sorted_set_put_element( + client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + value :: binary(), + score :: float(), + opts :: [collection_ttl :: CollectionTtl.t()] + ) :: SortedSet.PutElement.t() + def sorted_set_put_element( + client, + cache_name, + sorted_set_name, + value, + score, + opts \\ [] + ) do + collection_ttl = + Keyword.get(opts, :collection_ttl, CollectionTtl.of(client.default_ttl_seconds)) + + case ScsDataClient.sorted_set_put_elements( + client.data_client, + cache_name, + sorted_set_name, + [{value, score}], + collection_ttl + ) do + {:ok, _} -> {:ok, %Momento.Responses.SortedSet.PutElement.Ok{}} + {:error, error} -> {:error, error} + end + end + + @spec sorted_set_put_elements( + client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + elements :: %{binary() => float()} | [{binary(), float()}], + opts :: [collection_ttl :: CollectionTtl.t()] + ) :: SortedSet.PutElements.t() + def sorted_set_put_elements( + client, + cache_name, + sorted_set_name, + elements, + opts \\ [] + ) do + collection_ttl = + Keyword.get(opts, :collection_ttl, CollectionTtl.of(client.default_ttl_seconds)) + + ScsDataClient.sorted_set_put_elements( + client.data_client, + cache_name, + sorted_set_name, + elements, + collection_ttl + ) + end + + @spec sorted_set_fetch_by_rank( + client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + opts :: [start_rank: integer(), end_rank: integer(), sort_order: :asc | :desc] + ) :: SortedSet.Fetch.t() + def sorted_set_fetch_by_rank( + client, + cache_name, + sorted_set_name, + opts \\ [] + ) do + start_rank = Keyword.get(opts, :start_rank) + end_rank = Keyword.get(opts, :end_rank) + sort_order = Keyword.get(opts, :sort_order, :asc) + + ScsDataClient.sorted_set_fetch_by_rank( + client.data_client, + cache_name, + sorted_set_name, + start_rank, + end_rank, + sort_order + ) + end end diff --git a/src/lib/momento/error.ex b/src/lib/momento/error.ex index ff2db2f..0073493 100644 --- a/src/lib/momento/error.ex +++ b/src/lib/momento/error.ex @@ -4,18 +4,32 @@ defmodule Momento.Error do @type t() :: %__MODULE__{ error_code: Momento.Error.Code.t(), - cause: String.t() | nil, + cause: Exception.t() | nil, message: String.t() } - @spec convert(GRPC.RPCError.t()) :: Momento.Error.t() - def convert(%GRPC.RPCError{status: status, message: message}) do - case status do + @spec convert(error :: Exception.t()) :: Momento.Error.t() + def convert(%Momento.Error{} = error), do: error + def convert(%GRPC.RPCError{} = error), do: convert_grpc_error(error) + + def convert(%Protobuf.EncodeError{} = error), + do: invalid_argument("protobuf encode error", error) + + def convert(error), + do: %Momento.Error{ + error_code: Momento.Error.Code.unknown(), + cause: error, + message: "Momento SDK Failed to process the request." + } + + @spec convert_grpc_error(error :: GRPC.RPCError.t()) :: Momento.Error.t() + defp convert_grpc_error(error) do + case error.status do # Cancelled 1 -> %Momento.Error{ error_code: Momento.Error.Code.cancelled_error(), - cause: message, + cause: error, message: "The request was cancelled by the server; please contact Momento." } @@ -23,7 +37,7 @@ defmodule Momento.Error do 2 -> %Momento.Error{ error_code: Momento.Error.Code.unknown_service_error(), - cause: message, + cause: error, message: "The service returned an unknown response; please contact Momento." } @@ -31,7 +45,7 @@ defmodule Momento.Error do 3 -> %Momento.Error{ error_code: Momento.Error.Code.bad_request_error(), - cause: message, + cause: error, message: "The request was invalid; please contact Momento." } @@ -39,7 +53,7 @@ defmodule Momento.Error do 4 -> %Momento.Error{ error_code: Momento.Error.Code.timeout_error(), - cause: message, + cause: error, message: "The client's configured timeout was exceeded; you may need to use a Configuration with more lenient timeouts." } @@ -48,7 +62,7 @@ defmodule Momento.Error do 5 -> %Momento.Error{ error_code: Momento.Error.Code.not_found_error(), - cause: message, + cause: error, message: "A cache with the specified name does not exist. To resolve this error, make sure you have created the cache before attempting to use it." } @@ -57,7 +71,7 @@ defmodule Momento.Error do 6 -> %Momento.Error{ error_code: Momento.Error.Code.already_exists_error(), - cause: message, + cause: error, message: "A cache with the specified name already exists. To resolve this error, either delete the existing cache and make a new one, or use a different name." } @@ -66,7 +80,7 @@ defmodule Momento.Error do 7 -> %Momento.Error{ error_code: Momento.Error.Code.permission_error(), - cause: message, + cause: error, message: "Insufficient permissions to perform an operation on a cache." } @@ -74,7 +88,7 @@ defmodule Momento.Error do 8 -> %Momento.Error{ error_code: Momento.Error.Code.limit_exceeded_error(), - cause: message, + cause: error, message: "Request rate, bandwidth, or object size exceeded the limits for this account. To resolve this error, reduce your usage as appropriate or contact Momento to request a limit increase." } @@ -83,7 +97,7 @@ defmodule Momento.Error do 9 -> %Momento.Error{ error_code: Momento.Error.Code.bad_request_error(), - cause: message, + cause: error, message: "The request was invalid; please contact Momento." } @@ -91,7 +105,7 @@ defmodule Momento.Error do 10 -> %Momento.Error{ error_code: Momento.Error.Code.internal_server_error(), - cause: message, + cause: error, message: "An unexpected error occurred while trying to fulfill the request; please contact Momento." } @@ -100,7 +114,7 @@ defmodule Momento.Error do 11 -> %Momento.Error{ error_code: Momento.Error.Code.bad_request_error(), - cause: message, + cause: error, message: "The request was invalid; please contact Momento." } @@ -108,7 +122,7 @@ defmodule Momento.Error do 12 -> %Momento.Error{ error_code: Momento.Error.Code.bad_request_error(), - cause: message, + cause: error, message: "The request was invalid; please contact Momento." } @@ -116,7 +130,7 @@ defmodule Momento.Error do 13 -> %Momento.Error{ error_code: Momento.Error.Code.internal_server_error(), - cause: message, + cause: error, message: "An unexpected error occurred while trying to fulfill the request; please contact Momento." } @@ -125,7 +139,7 @@ defmodule Momento.Error do 14 -> %Momento.Error{ error_code: Momento.Error.Code.server_unavailable(), - cause: message, + cause: error, message: "The server was unable to handle the request; consider retrying. If the error persists, please contact Momento." } @@ -134,7 +148,7 @@ defmodule Momento.Error do 15 -> %Momento.Error{ error_code: Momento.Error.Code.internal_server_error(), - cause: message, + cause: error, message: "An unexpected error occurred while trying to fulfill the request; please contact Momento." } @@ -143,18 +157,18 @@ defmodule Momento.Error do 16 -> %Momento.Error{ error_code: Momento.Error.Code.authentication_error(), - cause: message, + cause: error, message: "Invalid authentication credentials to connect to the cache service." } end end - @spec invalid_argument(String.t()) :: Momento.Error.t() - def invalid_argument(message) do + @spec invalid_argument(message :: String.t(), cause :: Exception.t() | nil) :: Momento.Error.t() + def invalid_argument(message, cause \\ nil) do %Momento.Error{ error_code: Momento.Error.Code.invalid_argument_error(), - cause: nil, - message: "Invalid argument passed to Momento client: " <> message + cause: cause, + message: "Invalid argument passed to Momento client: #{message}" } end end diff --git a/src/lib/momento/internal/scs_data_client.ex b/src/lib/momento/internal/scs_data_client.ex index c1f00d2..fa85d5b 100644 --- a/src/lib/momento/internal/scs_data_client.ex +++ b/src/lib/momento/internal/scs_data_client.ex @@ -1,6 +1,7 @@ defmodule Momento.Internal.ScsDataClient do alias Momento.Auth.CredentialProvider alias Momento.Responses.{Set, Get, Delete} + alias Momento.Requests.CollectionTtl import Momento.Validation @enforce_keys [:auth_token, :channel] @@ -104,4 +105,191 @@ defmodule Momento.Internal.ScsDataClient do error -> error end end + + @spec sorted_set_put_elements( + data_client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + elements :: %{binary() => float()} | [{binary(), float()}], + collection_ttl :: CollectionTtl.t() + ) :: Momento.Responses.SortedSet.PutElements.t() + def sorted_set_put_elements( + data_client, + cache_name, + sorted_set_name, + elements, + collection_ttl + ) do + with :ok <- validate_cache_name(cache_name), + :ok <- validate_sorted_set_name(sorted_set_name), + :ok <- validate_sorted_set_elements(elements), + :ok <- validate_collection_ttl(collection_ttl) do + try do + send_sorted_set_put_elements( + data_client, + cache_name, + sorted_set_name, + elements, + collection_ttl + ) + rescue + e -> {:error, Momento.Error.convert(e)} + end + else + error -> error + end + end + + @spec send_sorted_set_put_elements( + data_client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + elements :: %{binary() => float()} | [{binary(), float()}], + collection_ttl :: CollectionTtl.t() + ) :: Momento.Responses.SortedSet.PutElements.t() + defp send_sorted_set_put_elements( + data_client, + cache_name, + sorted_set_name, + elements, + collection_ttl + ) do + ttl_milliseconds = collection_ttl.ttl_seconds |> Kernel.*(1000) |> round() + metadata = %{cache: cache_name, Authorization: data_client.auth_token} + + transformed_elements = + Enum.map(elements, fn {value, score} -> + %Momento.Protos.CacheClient.SortedSetElement{ + value: value, + score: score + } + end) + + sorted_set_put_request = %Momento.Protos.CacheClient.SortedSetPutRequest{ + set_name: sorted_set_name, + elements: transformed_elements, + ttl_milliseconds: ttl_milliseconds, + refresh_ttl: collection_ttl.refresh_ttl + } + + case Momento.Protos.CacheClient.Scs.Stub.sorted_set_put( + data_client.channel, + sorted_set_put_request, + metadata: metadata + ) do + {:ok, _} -> {:ok, %Momento.Responses.SortedSet.PutElements.Ok{}} + {:error, error_response} -> {:error, Momento.Error.convert(error_response)} + end + end + + @spec sorted_set_fetch_by_rank( + data_client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + start_rank :: integer() | nil, + end_rank :: integer() | nil, + sort_order :: :asc | :desc + ) :: Momento.Responses.SortedSet.Fetch.t() + def sorted_set_fetch_by_rank( + data_client, + cache_name, + sorted_set_name, + start_rank \\ nil, + end_rank \\ nil, + sort_order \\ :asc + ) do + with :ok <- validate_cache_name(cache_name), + :ok <- validate_sorted_set_name(sorted_set_name), + :ok <- validate_index_range(start_rank, end_rank), + :ok <- validate_sort_order(sort_order) do + try do + send_sorted_set_fetch_by_rank( + data_client, + cache_name, + sorted_set_name, + start_rank, + end_rank, + sort_order + ) + rescue + e -> {:error, Momento.Error.convert(e)} + end + else + error -> error + end + end + + @spec send_sorted_set_fetch_by_rank( + data_client :: t(), + cache_name :: String.t(), + sorted_set_name :: String.t(), + start_rank :: integer() | nil, + end_rank :: integer() | nil, + sort_order :: :asc | :desc + ) :: Momento.Responses.SortedSet.Fetch.t() + defp send_sorted_set_fetch_by_rank( + data_client, + cache_name, + sorted_set_name, + start_rank, + end_rank, + sort_order + ) do + metadata = %{cache: cache_name, Authorization: data_client.auth_token} + + start_index = + case start_rank do + nil -> {:unbounded_start, %Momento.Protos.CacheClient.Unbounded{}} + _ -> {:inclusive_start_index, start_rank} + end + + end_index = + case end_rank do + nil -> {:unbounded_end, %Momento.Protos.CacheClient.Unbounded{}} + _ -> {:exclusive_end_index, end_rank} + end + + order = + case sort_order do + :asc -> 0 + _ -> 1 + end + + fetch_request = %Momento.Protos.CacheClient.SortedSetFetchRequest{ + set_name: sorted_set_name, + order: order, + with_scores: true, + range: + {:by_index, + %Momento.Protos.CacheClient.SortedSetFetchRequest.ByIndex{ + start: start_index, + end: end_index + }} + } + + case Momento.Protos.CacheClient.Scs.Stub.sorted_set_fetch( + data_client.channel, + fetch_request, + metadata: metadata + ) do + {:ok, response} -> + case response.sorted_set do + {:found, found} -> + {:values_with_scores, values_with_scores} = found.elements + + scored_values = + Enum.map(values_with_scores.elements, fn element -> + {element.value, element.score} + end) + + {:ok, %Momento.Responses.SortedSet.Fetch.Hit{value: scored_values}} + + {:missing, _} -> + :miss + end + + {:error, error_response} -> + {:error, Momento.Error.convert(error_response)} + end + end end diff --git a/src/lib/momento/requests/collection_ttl.ex b/src/lib/momento/requests/collection_ttl.ex new file mode 100644 index 0000000..7e02a86 --- /dev/null +++ b/src/lib/momento/requests/collection_ttl.ex @@ -0,0 +1,17 @@ +defmodule Momento.Requests.CollectionTtl do + @enforce_keys [:ttl_seconds, :refresh_ttl] + defstruct [:ttl_seconds, :refresh_ttl] + + @type t() :: %__MODULE__{ + ttl_seconds: float() | nil, + refresh_ttl: boolean() + } + + @spec of(ttl_seconds :: float()) :: t() + def of(ttl_seconds) do + %Momento.Requests.CollectionTtl{ + ttl_seconds: ttl_seconds, + refresh_ttl: true + } + end +end diff --git a/src/lib/momento/responses/sorted_set/fetch.ex b/src/lib/momento/responses/sorted_set/fetch.ex new file mode 100644 index 0000000..e4c9405 --- /dev/null +++ b/src/lib/momento/responses/sorted_set/fetch.ex @@ -0,0 +1,13 @@ +defmodule Momento.Responses.SortedSet.Fetch do + defmodule Hit do + @enforce_keys [:value] + defstruct [:value] + + @type t() :: %__MODULE__{ + value: [{binary(), float()}] + } + end + + @type t() :: + {:ok, Momento.Responses.SortedSet.Fetch.Hit.t()} | :miss | {:error, Momento.Error.t()} +end diff --git a/src/lib/momento/responses/sorted_set/put_element.ex b/src/lib/momento/responses/sorted_set/put_element.ex new file mode 100644 index 0000000..cec6b83 --- /dev/null +++ b/src/lib/momento/responses/sorted_set/put_element.ex @@ -0,0 +1,9 @@ +defmodule Momento.Responses.SortedSet.PutElement do + defmodule Ok do + @enforce_keys [] + defstruct [] + @type t() :: %__MODULE__{} + end + + @type t() :: {:ok, Momento.Responses.SortedSet.PutElement.Ok.t()} | {:error, Momento.Error.t()} +end diff --git a/src/lib/momento/responses/sorted_set/put_elements.ex b/src/lib/momento/responses/sorted_set/put_elements.ex new file mode 100644 index 0000000..3462f72 --- /dev/null +++ b/src/lib/momento/responses/sorted_set/put_elements.ex @@ -0,0 +1,9 @@ +defmodule Momento.Responses.SortedSet.PutElements do + defmodule Ok do + @enforce_keys [] + defstruct [] + @type t() :: %__MODULE__{} + end + + @type t() :: {:ok, Momento.Responses.SortedSet.PutElements.Ok.t()} | {:error, Momento.Error.t()} +end diff --git a/src/lib/momento/validation.ex b/src/lib/momento/validation.ex index 5c90a88..64fda18 100644 --- a/src/lib/momento/validation.ex +++ b/src/lib/momento/validation.ex @@ -1,27 +1,156 @@ defmodule Momento.Validation do import Momento.Error - @spec validate_cache_name(String.t()) :: :ok | {:error, Momento.Error.t()} - def validate_cache_name(nil), do: {:error, invalid_argument("The cache name cannot be nil")} - + @spec validate_cache_name(cache_name :: String.t()) :: :ok | {:error, Momento.Error.t()} def validate_cache_name(cache_name) do - if String.valid?(cache_name), + validate_string(cache_name, "cache name") + end + + @spec validate_sorted_set_name(sorted_set_name :: String.t()) :: + :ok | {:error, Momento.Error.t()} + def validate_sorted_set_name(sorted_set_name) do + validate_string(sorted_set_name, "sorted set name") + end + + @spec validate_sorted_set_elements(elements :: %{binary() => float()} | [{binary(), float()}]) :: + :ok | {:error, Momento.Error.t()} + def validate_sorted_set_elements(nil), + do: {:error, invalid_argument("Sorted set elements cannot be nil")} + + def validate_sorted_set_elements(elements) do + try do + case Enum.all?(elements, fn {value, score} -> + is_binary(value) and is_float(score) + end) do + true -> + :ok + + false -> + {:error, + invalid_argument( + "Sorted set elements must contain only binary values and float scores" + )} + end + rescue + e -> + {:error, + invalid_argument( + "Sorted set elements must be a map or list of tuples of values and scores", + e + )} + end + end + + @spec validate_sort_order(sort_order :: atom()) :: :ok | {:error, Momento.Error.t()} + def validate_sort_order(sort_order) when sort_order in [:asc, :desc], do: :ok + + def validate_sort_order(_), + do: {:error, invalid_argument("The sort order must be either :asc or :desc")} + + @spec validate_key(key :: binary()) :: :ok | {:error, Momento.Error.t()} + def validate_key(key), do: validate_binary(key, "key") + + @spec validate_value(value :: binary()) :: :ok | {:error, Momento.Error.t()} + def validate_value(value), do: validate_binary(value, "value") + + @spec validate_score(score :: float()) :: :ok | {:error, Momento.Error.t()} + def validate_score(score), do: validate_float(score, "score") + + @spec validate_ttl(ttl :: float()) :: :ok | {:error, Momento.Error.t()} + def validate_ttl(ttl), do: validate_positive_float(ttl, "TTL") + + @spec validate_collection_ttl(collection_ttl :: Momento.Requests.CollectionTtl.t()) :: + :ok | {:error, Momento.Error.t()} + def validate_collection_ttl(collection_ttl) do + with :ok <- + validate_struct( + collection_ttl, + "collection_ttl", + Elixir.Momento.Requests.CollectionTtl + ), + :ok <- validate_positive_float(collection_ttl.ttl_seconds, "TTL") do + :ok + else + error -> error + end + end + + @spec validate_index_range(start_index :: integer() | nil, end_index :: integer() | nil) :: + :ok | {:error, Momento.Error.t()} + def validate_index_range(nil, _), do: :ok + def validate_index_range(_, nil), do: :ok + + def validate_index_range(start_index, _) when not is_integer(start_index), + do: {:error, invalid_argument("#{start_index} is not an integer")} + + def validate_index_range(_, end_index) when not is_integer(end_index), + do: {:error, invalid_argument("#{end_index} is not an integer")} + + def validate_index_range(start_index, end_index) when start_index < end_index, do: :ok + + def validate_index_range(_, _), + do: + {:error, + invalid_argument("start_index (inclusive) must be less than end_index (exclusive)")} + + @spec validate_not_nil(any(), String.t()) :: :ok | {:error, Momento.Error.t()} + def validate_not_nil(nil, name), do: {:error, invalid_argument("#{name} cannot be nil")} + def validate_not_nil(_, _), do: :ok + + @spec validate_string(string :: String.t(), name_type :: String.t()) :: + :ok | {:error, Momento.Error.t()} + defp validate_string(nil, string_name), + do: {:error, invalid_argument("The #{string_name} cannot be nil")} + + defp validate_string(string, string_name) do + if String.valid?(string), do: :ok, - else: {:error, invalid_argument("The cache name must be a string")} + else: {:error, invalid_argument("The #{string_name} must be a string")} + end + + @spec validate_binary(binary :: binary(), binary_name :: String.t()) :: + :ok | {:error, Momento.Error.t()} + defp validate_binary(nil, binary_name), + do: {:error, invalid_argument("The #{binary_name} cannot be nil")} + + defp validate_binary(binary, _) when is_binary(binary), do: :ok + + defp validate_binary(_, binary_name), + do: {:error, invalid_argument("The #{binary_name} must be a binary")} + + @spec validate_positive_float(float :: float(), float_name :: String.t()) :: + :ok | {:error, Momento.Error.t()} + defp validate_positive_float(float, float_name) do + case validate_float(float, float_name) do + :ok -> + if float > 0.0 do + :ok + else + {:error, invalid_argument("The #{float_name} must be positive")} + end + + error -> + error + end end - @spec validate_key(binary()) :: :ok | {:error, Momento.Error.t()} - def validate_key(nil), do: {:error, invalid_argument("The key cannot be nil")} - def validate_key(key) when is_binary(key), do: :ok - def validate_key(_), do: {:error, invalid_argument("The key must be a binary")} + @spec validate_float(float :: float(), float_name :: String.t()) :: + :ok | {:error, Momento.Error.t()} + defp validate_float(nil, float_name), + do: {:error, invalid_argument("The #{float_name} cannot be nil")} + + defp validate_float(float, _) when is_float(float), do: :ok + + defp validate_float(_, float_name), + do: {:error, invalid_argument("The #{float_name} must be a float")} + + @spec validate_struct(struct :: struct(), struct_name :: String.t(), struct_type :: atom()) :: + :ok | {:error, Momento.Error.t()} + defp validate_struct(nil, struct_name, _), + do: {:error, invalid_argument("The #{struct_name} cannot be nil")} - @spec validate_value(binary()) :: :ok | {:error, Momento.Error.t()} - def validate_value(nil), do: {:error, invalid_argument("The value cannot be nil")} - def validate_value(value) when is_binary(value), do: :ok - def validate_value(_), do: {:error, invalid_argument("The value must be a binary")} + defp validate_struct(struct, _, struct_type) when struct.__struct__ == struct_type, do: :ok - @spec validate_ttl(float()) :: :ok | {:error, Momento.Error.t()} - def validate_ttl(nil), do: {:error, invalid_argument("The TTL cannot be nil")} - def validate_ttl(ttl) when is_float(ttl) and ttl > 0.0, do: :ok - def validate_ttl(_), do: {:error, invalid_argument("The TTL must be a positive float")} + defp validate_struct(_, struct_name, struct_type), + do: {:error, invalid_argument("#{struct_name} must be an #{Atom.to_string(struct_type)}")} end diff --git a/src/test/momento/cache_client_test.exs b/src/test/momento/cache_client_test.exs new file mode 100644 index 0000000..a44d0c5 --- /dev/null +++ b/src/test/momento/cache_client_test.exs @@ -0,0 +1,465 @@ +defmodule Momento.CacheClientTest do + use ExUnit.Case + + alias Momento.CacheClient + + @fake_cache_client %Momento.CacheClient{ + config: %Momento.Config.Configuration{ + transport_strategy: %Momento.Config.Transport.TransportStrategy{ + grpc_config: %Momento.Config.Transport.GrpcConfiguration{ + deadline_millis: 5000 + } + } + }, + credential_provider: %Momento.Auth.CredentialProvider{ + control_endpoint: "fake", + cache_endpoint: "fake", + auth_token: "fake" + }, + default_ttl_seconds: 100.0, + control_client: "fake", + data_client: "fake" + } + + describe "sorted_set_put_element/6" do + test "returns an error with a bad cache name" do + sorted_set_name = "sorted set name" + value = "value" + score = 1.0 + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + nil, + sorted_set_name, + value, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The cache name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + 12345, + sorted_set_name, + value, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The cache name must be a string") + end + + test "returns an error with a bad sorted set name" do + cache_name = "cache name" + value = "value" + score = 1.0 + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + nil, + value, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The sorted set name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + 12345, + value, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The sorted set name must be a string") + end + + test "returns an error with a bad value" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + score = 1.0 + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + sorted_set_name, + nil, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + sorted_set_name, + 12345, + score, + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + end + + test "returns an error with a bad score" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + value = "value" + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + sorted_set_name, + value, + nil, + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + sorted_set_name, + value, + "one", + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + end + + test "returns an error with a bad collection ttl" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + value = "value" + score = 1.0 + + {:error, error} = + CacheClient.sorted_set_put_element( + @fake_cache_client, + cache_name, + sorted_set_name, + value, + score, + collection_ttl: "ttl" + ) + + assert String.contains?( + error.message, + "collection_ttl must be an Elixir.Momento.Requests.CollectionTtl" + ) + end + end + + describe "sorted_set_put_elements/5" do + test "returns an error with a bad cache name" do + sorted_set_name = "sorted set name" + elements = [{"key1", 1.0}] + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + nil, + sorted_set_name, + elements, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The cache name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + 12345, + sorted_set_name, + elements, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The cache name must be a string") + end + + test "returns an error with a bad sorted set name" do + cache_name = "cache name" + elements = [{"key1", 1.0}] + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + nil, + elements, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The sorted set name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + 12345, + elements, + collection_ttl: collection_ttl + ) + + assert String.contains?(error.message, "The sorted set name must be a string") + end + + test "returns an error with bad elements" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + collection_ttl = Momento.Requests.CollectionTtl.of(60.0) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + nil, + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements cannot be nil" + ) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + [{nil, 1.0}], + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + [{12345, 1.0}], + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + [{"key1", nil}], + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + [{"key1", "one"}], + collection_ttl: collection_ttl + ) + + assert String.contains?( + error.message, + "Sorted set elements must contain only binary values and float scores" + ) + end + + test "returns an error with a bad collection ttl" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + elements = [{"key1", 1.0}] + + {:error, error} = + CacheClient.sorted_set_put_elements( + @fake_cache_client, + cache_name, + sorted_set_name, + elements, + collection_ttl: "ttl" + ) + + assert String.contains?( + error.message, + "collection_ttl must be an Elixir.Momento.Requests.CollectionTtl" + ) + end + end + + describe "sorted_set_fetch_by_rank/6" do + test "returns an error with a bad cache name" do + sorted_set_name = "sorted set name" + start_rank = 1 + end_rank = 10 + sort_order = :asc + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + nil, + sorted_set_name, + start_rank: start_rank, + end_rank: end_rank, + sort_order: sort_order + ) + + assert String.contains?(error.message, "The cache name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + 12345, + sorted_set_name, + start_rank: start_rank, + end_rank: end_rank, + sort_order: sort_order + ) + + assert String.contains?(error.message, "The cache name must be a string") + end + + test "returns an error with a bad sorted set name" do + cache_name = "cache name" + start_rank = 1 + end_rank = 10 + sort_order = :asc + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + nil, + start_rank: start_rank, + end_rank: end_rank, + sort_order: sort_order + ) + + assert String.contains?(error.message, "The sorted set name cannot be nil") + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + 12345, + start_rank: start_rank, + end_rank: end_rank, + sort_order: sort_order + ) + + assert String.contains?(error.message, "The sorted set name must be a string") + end + + test "returns an error with bad ranks" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + sort_order = :asc + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + sorted_set_name, + start_rank: "start", + end_rank: 10, + sort_order: sort_order + ) + + assert String.contains?(error.message, "start is not an integer") + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + sorted_set_name, + start_rank: 1, + end_rank: "end", + sort_order: sort_order + ) + + assert String.contains?(error.message, "end is not an integer") + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + sorted_set_name, + start_rank: 100, + end_rank: 10, + sort_order: sort_order + ) + + assert String.contains?( + error.message, + "start_index (inclusive) must be less than end_index (exclusive)" + ) + end + + test "returns an error with a sort order" do + cache_name = "cache name" + sorted_set_name = "sorted set name" + start_rank = 1 + end_rank = 10 + + {:error, error} = + CacheClient.sorted_set_fetch_by_rank( + @fake_cache_client, + cache_name, + sorted_set_name, + start_rank: start_rank, + end_rank: end_rank, + sort_order: :bad_order + ) + + assert String.contains?(error.message, "The sort order must be either :asc or :desc") + end + end +end