defmodule Cachex.Query do @moduledoc """ Utility module based around creation of cache queries. Queries are essentially just some minor convenience wrappers around the internal match specification used by ETS. This module is exposed to make it easier to query a cache (via `Cachex.stream/3`) without having to take care of filtering for expirations by hand. Note that there is almost no validation in here, so test thoroughly and store compile-time versions of your queries when possible (as performance is not taken into account inside this module; it can be slow to generate). """ import Cachex.Spec # raw query header @header entry() |> entry() |> Enum.with_index(1) |> Enum.map(fn {_, idx} -> :"$#{idx}" end) |> Enum.reverse() |> Enum.concat([:_]) |> Enum.reverse() |> List.to_tuple() ############## # Public API # ############## @doc """ Create a query specification for a cache. This is a convenience binding to ETS select specifications, so please see the appropriate documentation for any additional information on how to (e.g.) format filter clauses and outputs. ## Options * `:output` The query output format, which defaults to `:entry` (retrieving the entire cache entry). You can provide any of the entry record fields, with bindings such as tuples (`{:key, :value}`) or lists `[:key, :value]` or as a single value (`:key`). * `:where` An ETS filter condition used to locate records in the table. This defaults to retrieving all records, and is automatically joined with the value of the `:expired` flag for convenience. """ @spec build(options :: Keyword.t()) :: [{tuple, [tuple], [any]}] def build(options \\ []), do: [ { @header, [ options |> Keyword.get(:where, true) |> map_clauses ], [ options |> Keyword.get(:output, :entry) |> map_clauses |> clean_return ] } ] @doc """ Create a filter against expired records in a cache. This function accepts a subfilter to join to create more complex filters. """ @spec expired(filter :: boolean | tuple | nil) :: tuple def expired(filter \\ nil), do: wrap_condition(filter, {:not, unexpired()}) @doc """ Create a filter against unexpired records in a cache. This function accepts a subfilter to join to create more complex filters. """ @spec unexpired(filter :: boolean | tuple | nil) :: tuple def unexpired(filter \\ nil), do: wrap_condition( filter, {:orelse, {:==, map_clauses(:expiration), nil}, {:>, {:+, map_clauses(:modified), map_clauses(:expiration)}, now()}} ) ############### # Private API # ############### # Sanitizes a returning value clause. # # This will just wrap any non-single element Tuples being returned as # this is required in order to provide valid return formats. defp clean_return(tpl) when tuple_size(tpl) > 1, do: {tpl} defp clean_return(val), do: val # Recursively replaces all entry tags in a clause value. # # This allows the use of entry fields, such as `:key` as references in # query clauses (even if ETS doesn't). The fields will be mapped to the # index equivalent and returned in a sanitized clause value. defp map_clauses(tpl) when is_tuple(tpl) do tpl |> Tuple.to_list() |> map_clauses |> List.to_tuple() end defp map_clauses(list) when is_list(list), do: Enum.map(list, &map_clauses/1) # basic entry field name substitution for key <- Keyword.keys(entry(entry())) do defp map_clauses(unquote(key)), do: :"$#{entry(unquote(key))}" end # whole cache entry defp map_clauses(:entry), do: :"$_" # no-op, already valid defp map_clauses(value), do: value # Wrap a where clause with a new condition only if the whre clause # isn't simply true; if so, defer to the provided condition. defp wrap_condition(filter, subfilter) when filter in [nil, true], do: subfilter defp wrap_condition(filter, subfilter), do: {:andalso, subfilter, filter} end