defmodule PS2.API.QueryBuilder do @moduledoc """ A module for creating Census API queries in a clean manner via pipelines. ### Example iex> import PS2.API.QueryBuilder PS2.API.QueryBuilder iex> alias PS2.API.Query PS2.API.Query iex> query = Query.new(collection: "character") ...> |> term("character_id", "5428011263335537297") ...> |> show(["character_id", "name.en", "faction_id"]) ...> |> limit(3) ...> |> exact_match_first(true) %PS2.API.Query{ collection: "character", joins: [], sort: nil, params: %{ "c:exactMatchFirst" => true, "c:limit" => 3, "c:show" => "character_id,name.en,faction_id", "character_id" => {"", "5428011263335537297"} }, tree: nil } iex> PS2.API.encode query {:ok, "character?c:exactMatchFirst=true&c:limit=3&c:show=character_id,name.en,faction_id&character_id=5428011263335537297"} You can then send the query to the api using `PS2.API.query/1`. ## Search Modifiers The Census API provides [search modifiers](https://census.daybreakgames.com/#search-modifier) for filtering query results. You can pass an atom as the third parameter in `term/4` representing one of these search modifiers. The recognized atoms are the following: ``` :greater_than :greater_than_or_equal :less_than :less_than_or_equal :starts_with :contains :not ``` For example: `term(query_or_join, "name.first_lower", "wrel", :starts_with)` ## Joining Queries You can use `Join`s to gather data from multiple collections within one query, like so: ```elixir import PS2.API.QueryBuilder alias PS2.API.{Query, Join} online_status_join = # Note we could use Join.new(collection: "characters_online_status", show: "online_status" ...) %Join{} |> collection("characters_online_status") |> show("online_status") |> inject_at("online_status") |> list(true) query = %Query{} |> collection("character") |> join(online_status_join) ``` When the query `q` is sent to the API, the result with have an extra field, "online_status", which contains the result of the `Join` (the player's online status.) You can create as many adjacent `Join`s as you'd like by repeatedly piping a query through `QueryBuilder.join/2`. You can also nest `Join`s via `QueryBuilder.join/2` when you pass a Join as the first argument instead of a Query. ```elixir import PS2.API.QueryBuilder alias PS2.API.{Query, Join} char_achieve_join = Join.new(collection: "characters_achievement", on: "character_id") char_name_join = Join.new(collection: "character_name", on: "character_id", inject_at: "c_name") online_status_join = Join.new(collection: "characters_online_status") |> join(char_name_join) |> join(char_achieve_join) query = %Query{} |> collection("character") |> term("name.first", "Snowful") |> show(["character_id", "faction_id"]) |> join(online_status_join) ``` ## Trees You can organize the returned data by a field within the data, using the `QueryBuilder.tree/2`. ```elixir import PS2.API.QueryBuilder alias PS2.API.{Query, Tree} %Query{} |> collection("world_event") |> term("type", "METAGAME") |> lang("en") |> tree( %Tree{} |> field("world_id") |> list(true) ) ``` """ @modifier_map %{ greater_than: ">", greater_than_or_equal: "]", less_than: "<", less_than_or_equal: "[", starts_with: "^", contains: "*", not: "!" } @type modifer :: :greater_than | :greater_than_or_equal | :less_than | :less_than_or_equal | :starts_with | :contains | :not | nil @type collection_name :: String.t() @type field_name :: String.t() alias PS2.API.{Query, Join, Tree} @doc """ Set the collection of the query/join. """ @spec collection(Query.t(), collection_name) :: Query.t() @spec collection(Join.t(), collection_name) :: Join.t() def collection(query_or_join, collection_name) def collection(%Query{} = query, collection_name), do: %Query{query | collection: collection_name} def collection(%Join{} = join, collection), do: %Join{join | collection: collection} @doc """ Adds a c:show term. Overwrites previous params of the same name. ### API Documentation: Only include the provided fields from the object within the result. """ @spec show(Query.t(), String.t() | list(String.t())) :: Query.t() @spec show(Join.t(), String.t() | list(String.t())) :: Join.t() def show(query_or_join, value) def show(%Query{} = query, values) when is_list(values), do: show(query, Enum.join(values, ",")) def show(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:show", value)} def show(%Join{} = join, values) when is_list(values), do: show(join, Enum.join(values, "'")) def show(%Join{} = join, value), do: %Join{join | params: Map.put(join.params, "show", value)} @doc """ Adds a c:hide term. Overwrites previous params of the same name. ### API Documentation: Include all field except the provided fields from the object within the result. """ @spec hide(Query.t(), String.t() | list(String.t())) :: Query.t() @spec hide(Join.t(), String.t() | list(String.t())) :: Join.t() def hide(query_or_join, values) def hide(%Query{} = query, values) when is_list(values), do: hide(query, Enum.join(values, ",")) def hide(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:hide", value)} def hide(%Join{} = join, values) when is_list(values), do: hide(join, Enum.join(values, "'")) def hide(%Join{} = join, field), do: %Join{join | params: Map.put(join.params, "hide", field)} @doc """ Add a term to filter query results. i.e. filter a query by character ID: `.../character?character_id=1234123412341234123` """ @spec term(Query.t(), String.t() | atom, any, modifer) :: Query.t() @spec term(Join.t(), String.t() | atom, any, modifer) :: Join.t() def term(query_or_join, field, value, modifier \\ nil) def term(%Query{} = query, field, value, modifier), do: %Query{ query | params: Map.put(query.params, field, {Map.get(@modifier_map, modifier, ""), value}) } def term(%Join{} = join, field, value, modifier) do term_value = {Map.get(@modifier_map, modifier, ""), value} %Join{ join | params: Map.update(join.params, :terms, %{field => term_value}, &Map.put(&1, field, term_value)) } end @doc """ Adds a join to a query. See the "Using c:join to join collections dynamically" section at https://census.daybreakgames.com/#query-commands to learn more about joining queries. ### c:join API Documentation: Meant to replace c:resolve, useful for dynamically joining (resolving) multiple data types in one query. """ @spec join(Query.t(), Join.t()) :: Query.t() @spec join(Join.t(), Join.t()) :: Join.t() def join(query_or_join, join) def join(%Query{} = query, %Join{} = join), do: %Query{query | joins: [join | query.joins]} def join(%Join{} = join, %Join{} = new_join), do: %Join{join | joins: [new_join | join.joins]} @doc """ Adds a sort term (to a Join or Tree). Specifies whether the result should be a list (true) or a single record (false). Defaults to false. """ @spec list(Join.t(), boolean()) :: %Join{} @spec list(Tree.t(), boolean()) :: %Tree{} def list(tree_or_join, boolean) def list(%Join{} = join, boolean), do: %Join{join | params: Map.put(join.params, "list", PS2.Utils.boolean_to_integer(boolean))} def list(%Tree{} = tree, boolean), do: %Tree{tree | terms: Map.put(tree.terms, :list, PS2.Utils.boolean_to_integer(boolean))} # ~~Query specific functions~~ @doc """ Adds a c:sort term. Overwrites previous params of the same name. ### API Documentation: Sort the results by the field(s) provided. """ @spec sort(Query.t(), Query.sort_terms()) :: Query.t() def sort(%Query{} = query, %{} = sort_terms), do: %Query{query | sort: sort_terms} @doc """ Adds a c:has term. Overwrites previous params of the same name. ### API Documentation: Include objects where the specified field exists, regardless of the value within that field. """ @spec has(Query.t(), String.t() | list()) :: Query.t() def has(%Query{} = query, values) when is_list(values), do: has(query, Enum.join(values, ",")) def has(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:has", value)} @doc """ Adds a c:resolve term. Overwrites previous params of the same name. **Note** that `join/3` is recommended over `resolve/2`, as resolve relies on supported collections to work. ### API Documentation: "Resolve" information by merging data from another collection and include the detailed object information for the provided fields from the object within the result (multiple field names separated by a comma).\n *Please note that the resolve will only function if the initial query contains the field to be resolved on. For instance, resolving leader on outfit requires that leader_character_id be in the initial query. """ @spec resolve(Query.t(), String.t() | [String.t()]) :: Query.t() def resolve(%Query{} = query, collection), do: %Query{query | params: Map.put(query.params, "c:resolve", collection)} @doc """ Adds a c:case (sensitivity) term. Overwrites previous params of the same name. ### API Documentation: Set whether a search should be case-sensitive, `true` means case-sensitive. true is the default. Note that using this command may slow down your queries. If a lower case version of a field is available use that instead for faster performance. """ @spec case_sensitive(Query.t(), boolean()) :: Query.t() def case_sensitive(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:case", boolean)} @doc """ Adds a c:limit term. Overwrites previous params of the same name. ### API Documentation: Limit the results to at most N [`value`] objects. """ @spec limit(Query.t(), integer()) :: Query.t() def limit(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:limit", value)} @doc """ Adds a c:limitPerDB term. Overwrites previous params of the same name. ### API Documentation: Limit the results to at most (N * number of databases) objects.\n *The data type ps2/character is distributed randomly across 20 databases. Using c:limitPerDb will have more predictable results on ps2/character than c:limit will. """ @spec limit_per_db(Query.t(), integer()) :: Query.t() def limit_per_db(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:limitPerDB", value)} @doc """ Adds a c:start term. Overwrites previous params of the same name. ### API Documentation: Start with the Nth object within the results of the query.\n *Please note that c:start will have unusual behavior when querying ps2/character which is distributed randomly across 20 databases. """ @spec start(Query.t(), integer()) :: Query.t() def start(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:start", value)} @doc """ Adds a c:includeNull term. Overwrites previous params of the same name. ### API Documentation: Include `NULL` values in the result. By default this is false. For example, if the `name.fr` field of a vehicle is `NULL` the field `name.fr` will not be included in the response by default. Add the c:includeNull=true command if you want the value name.fr : `NULL` to be returned in the result. """ @spec include_null(Query.t(), boolean()) :: Query.t() def include_null(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:includeNull", boolean)} @doc """ Adds a c:lang term. Overwrites previous params of the same name. ### API Documentation: For internationalized strings, remove all translations except the one specified. """ @spec lang(Query.t(), String.t()) :: Query.t() def lang(%Query{} = query, value), do: %Query{query | params: Map.put(query.params, "c:lang", value)} @doc """ Adds a c:tree term ### API Documentaion: Useful for rearranging lists of data into trees of data. See below for details. """ @spec tree(Query.t(), Tree.t()) :: Query.t() def tree(%Query{} = query, %Tree{} = tree), do: %Query{query | tree: tree} @doc """ Adds a c:timing term. Overwrites previous params of the same name. ### API Documentation: Shows the time taken by the involved server-side queries and resolves. """ @spec timing(Query.t(), boolean()) :: Query.t() def timing(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:timing", boolean)} @doc """ Adds a c:exactMatchFirst term. Overwrites previous params of the same name. ### API Documentation: When using a regex search (=^ or =*) c:exactMatchFirst=true will cause exact matches of the regex value to appear at the top of the result list despite the value of c:sort. """ @spec exact_match_first(Query.t(), boolean()) :: Query.t() def exact_match_first(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:exactMatchFirst", boolean)} @doc """ Adds a c:distinct term. Overwrites previous params of the same name. ### API Documentation: Get the distinct values of the given field. For example to get the distinct values of ps2.item.max_stack_size use `http://census.daybreakgames.com/get/ps2/item?c:distinct=max_stack_size`. Results are capped at 20,000 values. """ @spec distinct(Query.t(), boolean()) :: Query.t() def distinct(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:distinct", boolean)} @doc """ Adds a c:retry term. Overwrites previous params of the same name. ### API Documentation: If `true`, query will be retried one time. Default value is true. If you prefer your query to fail quickly pass c:retry=false. """ @spec retry(Query.t(), boolean()) :: Query.t() def retry(%Query{} = query, boolean), do: %Query{query | params: Map.put(query.params, "c:retry", boolean)} # ~~Join specific functions~~ @doc """ Adds an `on:` term. `field` is the field on the parent/leading collection to compare with the join's field (optionally specified with the `to/2` function). ### API Documentation: The field on this type to join on, i.e. item_id. Will default to {this_type}_id or {other_type}_id if not provided. """ @spec on(Join.t(), field_name) :: %Join{} def on(%Join{} = join, field), do: %Join{join | params: Map.put(join.params, "on", field)} @doc """ Adds a `to:` term. `field` is the field on the joined collection to compare with the parent/leading field (optionally specified with the `on/2` function). ### API Documentation: The field on the joined type to join to, i.e. attachment_item_id. Will default to on if on is provide, otherwise will default to {this_type}_id or {other_type}_id if not provided. """ @spec to(Join.t(), field_name) :: %Join{} def to(%Join{} = join, field), do: %Join{join | params: Map.put(join.params, "to", field)} @doc """ Adds an `injected_at:` term. `field` is the name of the new field where the result of the join is inserted. ### API Documentation: The field name where the joined data should be injected into the returned document. """ @spec inject_at(Join.t(), field_name) :: %Join{} def inject_at(%Join{} = join, field), do: %Join{join | params: Map.put(join.params, "inject_at", field)} @doc """ Adds an `outer:` term. Note: where the API docs specify `1`, `true` should be passed, and `false` in place of `0`. ### API Documentation: 1 if you wish to do an outer join (include non-matches), 0 if you wish to do an inner join (exclude non-matches). Defaults to 1- do an outer join, include non-matches. """ @spec outer(Join.t(), boolean()) :: %Join{} def outer(%Join{} = join, boolean), do: %Join{join | params: Map.put(join.params, "outer", PS2.Utils.boolean_to_integer(boolean))} # ~~Tree specific functions~~ @doc """ Adds a `start:` term. ### API Documentaion: Used to tell the tree where to start. By default, the list of objects at the root will be formatted as a tree. """ @spec start_field(Tree.t(), field_name) :: %Tree{} def start_field(%Tree{} = tree, field), do: %Tree{tree | terms: Map.put(tree.terms, :start, field)} @doc """ Adds a `field:` term. ### API Documentation: The field to remove and use as in the data structure, or tree. """ @spec field(Tree.t(), field_name) :: %Tree{} def field(%Tree{} = tree, field), do: %Tree{tree | terms: Map.put(tree.terms, :field, field)} @doc """ Add a `prefix:` term. ### API Documentation: A prefix to add to the field value to make it more readable. For example, if the field is "faction_id" and prefix is "f_", path will be f_1, f_2, f_3 etc. """ @spec prefix(Tree.t(), String.t()) :: %Tree{} def prefix(%Tree{} = tree, prefix), do: %Tree{tree | terms: Map.put(tree.terms, :prefix, prefix)} end