defmodule Aerospike.Key do @moduledoc """ Record key: namespace, set, optional user key, and server digest. The digest is **RIPEMD-160** over `set_name || particle_type_byte || encoded_user_key`. The namespace is **not** part of the digest (standard Aerospike digest rules). Use `new/3` when you have a user key. Use `from_digest/3` when you already hold the 20-byte digest (no user key material). Supported user keys for `new/3`: * `integer()` — encoded as particle type `INTEGER` (1) and 8-byte big-endian signed int64 * `String.t()` (binary) — encoded as particle type `STRING` (3) and UTF-8 bytes Binaries are treated as **string** keys, not blob keys. ## Examples iex> k = Aerospike.Key.new("test", "users", "user:42") iex> byte_size(k.digest) 20 iex> is_binary(k.namespace) true """ import Bitwise @particle_integer 1 @particle_string 3 @partitions 4096 # Signed 64-bit integer range bounds @min_int64 -9_223_372_036_854_775_808 @max_int64 9_223_372_036_854_775_807 @enforce_keys [:namespace, :digest] defstruct namespace: nil, set: "", user_key: nil, digest: nil @doc """ Returns true if the integer fits in the signed 64-bit range. ## Examples iex> Aerospike.Key.int64?(0) true iex> Aerospike.Key.int64?(9_223_372_036_854_775_807) true iex> Aerospike.Key.int64?(9_223_372_036_854_775_808) false """ @spec int64?(integer()) :: boolean() def int64?(n) when is_integer(n), do: n >= @min_int64 and n <= @max_int64 def int64?(_), do: false @doc """ Raises ArgumentError if the integer is outside the signed 64-bit range. """ @spec validate_int64!(integer(), String.t()) :: :ok def validate_int64!(n, context \\ "value") when is_integer(n) do unless int64?(n) do raise ArgumentError, "#{context} must fit in signed 64-bit range" end :ok end @type t :: %__MODULE__{ namespace: String.t(), set: String.t(), user_key: String.t() | integer() | nil, digest: <<_::160>> } @doc """ Returns the partition id (0..4095) derived from the digest. Uses the first four bytes of the digest as a little-endian unsigned 32-bit integer, then masks to 12 bits (standard partition mapping). ## Examples iex> k = Aerospike.Key.new("namespace", "set", 0) iex> Aerospike.Key.partition_id(k) 2451 """ @spec partition_id(t()) :: 0..4095 def partition_id(%__MODULE__{digest: digest}) when byte_size(digest) == 20 do <> = digest band(u, @partitions - 1) end @doc """ Builds a key and computes the RIPEMD-160 digest. `namespace` must be a non-empty string. `set` may be an empty string. `user_key` must be an integer in the int64 range or a binary (string key). Raises `ArgumentError` if the arguments are invalid. ## Examples iex> k = Aerospike.Key.new("ns", "s", 1) iex> byte_size(k.digest) 20 """ @spec new(String.t(), String.t(), String.t() | integer()) :: t() def new(namespace, set, user_key) when is_binary(namespace) and namespace != "" and is_binary(set) and is_integer(user_key) do validate_int64!(user_key, "user_key") digest = compute_digest_integer(set, user_key) %__MODULE__{ namespace: namespace, set: set, user_key: user_key, digest: digest } end def new(namespace, set, user_key) when is_binary(namespace) and namespace != "" and is_binary(set) and is_binary(user_key) do digest = compute_digest_string(set, user_key) %__MODULE__{ namespace: namespace, set: set, user_key: user_key, digest: digest } end def new(_namespace, _set, _user_key) do raise ArgumentError, "namespace must be a non-empty string, set must be a string, user_key must be string or int64 integer" end @doc """ Builds a key from an existing 20-byte digest (no user key material). `set` may be an empty string. The digest must already match the server's record identity for the given namespace and set. ## Examples iex> d = :crypto.hash(:ripemd160, <<>>) iex> k = Aerospike.Key.from_digest("ns", "users", d) iex> k.digest == d true iex> k.user_key nil """ @spec from_digest(String.t(), String.t(), <<_::160>>) :: t() def from_digest(namespace, set, digest) when is_binary(namespace) and namespace != "" and is_binary(set) and is_binary(digest) and byte_size(digest) == 20 do %__MODULE__{ namespace: namespace, set: set, user_key: nil, digest: digest } end def from_digest(_namespace, _set, _digest) do raise ArgumentError, "namespace must be a non-empty string, set must be a string, digest must be a 20-byte binary" end defp compute_digest_integer(set, user_key) when is_integer(user_key) do data = <> :crypto.hash(:ripemd160, data) end defp compute_digest_string(set, user_key) when is_binary(user_key) do data = <> :crypto.hash(:ripemd160, data) end end