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