Mathias Polligkeit
  • Dev
  • Impro
  • Sheet Music
  • Contact
Jul 5, 2022

Zanzibar Goes Elixir, Pt. 2: Read API

In the first article of the series, we set up the project and added create and delete functions for relation tuples. Some time has passed, spring turned to summer, and we are going to implement the read API today.

The read API allows users to query relation tuples. Following the paper, we have a couple of requirements:

  1. We want to be able to query either a single tupleset or multiple tuplesets.
  2. We want to be able to query using:
    • only a single tuple key (object, relation or user/userset)
    • namespace and object ID, optionally with a relation name
    • namespace and user or userset, optionally with a relation name

Userset rewrite rules are not considered in the read API. We are going to skip the zookies for now.

If we sketch out these requirements in our XalapaTest module, we end up with something like this:

describe "read_relation_tuples/1 with Ecto adapter" do
  test "returns tuples with the given object ID"
  test "returns tuples with any of the given object IDs"
  test "returns tuples with the given relation"
  test "returns tuples with the given user ID"
  test "returns tuples with the given userset"
  test "returns tuples with the given namespace and object ID"
  test "returns tuples with the given namespace, object ID and relation"
  test "returns tuples with the given namespace and user ID"
  test "returns tuples with the given namespace, user ID and relation"
  test "returns tuples with the given namespace and userset"
  test "returns tuples with the given namespace, userset and relation"
end

A bit repetitive, but so be it. We could probably test this more thoroughly and less repetitively by using property tests, but that is out of scope.

Tests: Querying by a single tuple key

The tests are straightforward to implement. For querying by an object ID, we can write a test like this:

test "returns tuples with the given object ID" do
  object_id = Ecto.UUID.generate()
  tuple = insert_tuple(:relation_tuple_for_user, object_id: object_id)
  _decoy = insert_tuple(:relation_tuple_for_user)
  assert Xalapa.read_relation_tuples(%{object_id: object_id}) == {:ok, [tuple]}
end

defp insert_tuple(factory, params \\ []) do
  {:ok, relation_tuple} =
    factory
    |> params_for(params)
    |> Xalapa.create_relation_tuple()

  relation_tuple
end

Here we insert two tuples with different object IDs and then run a query using one of the object IDs. Note the addition of the insert_tuple/2 function, which is necessary because our factory functions generate Xalapa.RelationTuple structs, which are not Ecto structs and thereby cannot be used with the insert/1 function of ExMachina. You could choose to define separate factory functions for the Ecto structs instead (def relation_tuple_for_user_ecto_factory do), or even add a separate factory module for them.

The test for querying multiple object IDs at once works similarly, except that we insert three tuples and assert that two of them are returned.

test "returns tuples with any of the given object IDs" do
  object_id_1 = Ecto.UUID.generate()
  object_id_2 = Ecto.UUID.generate()
  tuple_1 = insert_tuple(:relation_tuple_for_user, object_id: object_id_1)
  tuple_2 = insert_tuple(:relation_tuple_for_user, object_id: object_id_2)
  _decoy = insert_tuple(:relation_tuple_for_user)

  assert {:ok, [_, _] = result} =
           Xalapa.read_relation_tuples([
             %{object_id: object_id_1},
             %{object_id: object_id_2}
           ])

  assert tuple_1 in result
  assert tuple_2 in result
end

Callback

To make these tests pass, we need to add another callback to the Adapter module, add the read function to the Xalapa module, and implement the callback function for our Ecto adapter,

Add this to lib/xalapa/adapter/xalapa.ex:

@callback read_relation_tuples([map]) ::
            {:ok, [RelationTuple.t()]} | {:error, any}

Public read function

The read function should accept a list of maps with query parameters and return an :ok tuple with a list of relation tuples on success.

The Xalapa.read_relation_tuples/2 function works similarly to our create and delete functions: It fetches the adapter from the options and then passes the request on to the adapter function.

@spec read_relation_tuples(map | [map], keyword) ::
        {:ok, [RelationTuple.t()]} | {:error, any}
def read_relation_tuples(params, opts \\ []) do
  with {:ok, opts} <- Keyword.validate(opts, adapter: Adapter.Ecto) do
    adapter = Keyword.fetch!(opts, :adapter)
    adapter.read_relation_tuples(params)
  end
end

It would be a good idea to validate the given parameters here as well. Let’s write out all field combinations we want to support as query parameters:

[
  [:object_id],
  [:relation],
  [:user_id],
  [:userset_namespace, :userset_object_id, :userset_relation],
  [:namespace, :object_id],
  [:namespace, :object_id, :relation],
  [:namespace, :user_id],
  [:namespace, :user_id, :relation],
  [:namespace, :userset_namespace, :userset_object_id, :userset_relation],
  [:namespace, :userset_namespace, :userset_object_id, :userset_relation, :relation]
]

Every parameter map has to have one of these combinations as keys. There is no changeset function for this kind of validation, and we wouldn’t be able to add an error to a single field anyway, so we’ll add a validation function that takes parameter map instead of a changeset.

  @read_param_combinations [
    [:object_id],
    [:relation],
    [:user_id],
    [
      :userset_namespace,
      :userset_object_id,
      :userset_relation
    ],
    [:namespace, :object_id],
    [:namespace, :object_id, :relation],
    [:namespace, :user_id],
    [:namespace, :relation, :user_id],
    [
      :namespace,
      :userset_namespace,
      :userset_object_id,
      :userset_relation
    ],
    [
      :namespace,
      :relation,
      :userset_namespace,
      :userset_object_id,
      :userset_relation
    ]
  ]

defp validate_read_param_keys(%{} = params) do
  keys = params |> Map.keys() |> Enum.sort()

  if keys in @read_param_combinations,
    do: {:ok, params},
    else: {:error, :invalid_params}
end

We will still use a changeset to cast the parameters before passing them to this function:

defp read_changeset(%{} = params) do
  data = %{}

  types = %{
    namespace: :string,
    object_id: Ecto.UUID,
    relation: :string,
    user_id: Ecto.UUID,
    userset_namespace: :string,
    userset_object_id: Ecto.UUID,
    userset_relation: :string
  }

  Changeset.cast({data, types}, params, Map.keys(types))
end

We can now add a validation function to put everything together.

defp validate_read_params(params_list) do
  params_list
  |> List.wrap()
  |> Enum.reduce_while({:ok, []}, fn params, {:ok, acc_params} ->
    changeset = read_params_changeset(params)

    with {:ok, cast_params} <- Changeset.apply_action(changeset, :validate),
         {:ok, validated_params} <- validate_read_param_keys(cast_params) do
      {:cont, {:ok, [validated_params | acc_params]}}
    else
      _ ->
        {:halt, {:error, :invalid_params}}
    end
  end)
end

Here, we use Enum.reduce_while/3 to apply the changeset and validation function to every parameter map passed to the function and return early in case we encounter an error. If this was a real application, we should make sure to return a more helpful error message here.

This implementation will reverse the list order, but the order is irrelevant for our query.

Finally, we can update the read function to use our new validation functions.

def read_relation_tuples(params, opts \\ []) do
  with {:ok, opts} <- Keyword.validate(opts, adapter: Adapter.Ecto),
       {:ok, validated_params} <- validate_read_params(params) do
    adapter = Keyword.fetch!(opts, :adapter)
    adapter.read_relation_tuples(validated_params)
  end
end

Let’s add a simple test for the validation:

test "returns error if params are invalid" do
  params = [
    %{object_id: Ecto.UUID.generate()},
    %{userset_relation: "pet"}
  ]

  assert Xalapa.read_relation_tuples(params) == {:error, :invalid_params}
end

Ecto adapter implementation

With the validation out of the way, we can now turn to the Ecto adapter implementation.

First of all, we need a function that turns our validated parameters (tuplesets) into a WHERE clause. Add this function to lib/xalapa/adapter/ecto.ex:

defp build_read_conditions(tuplesets) do
  Enum.reduce(tuplesets, false, fn %{object_id: object_id}, conditions ->
    dynamic([t], ^conditions or t.object_id == ^object_id)
  end)
end

We want to return all relation tuples that match any of the given parameters. We initialize the reducer function with false, and then add an additional condition for each of the parameter maps using or. This should be enough for the two tests we have written so far.

The callback function then only has to pass the parameters it receives to the function above, run the query, and convert the Ecto relation tuple structs into the public structs, as we have done before in the create and delete callback implementations.

@impl Xalapa.Adapter
def read_relation_tuples(tuplesets) do
  conditions = build_read_conditions(tuplesets)

  result =
    Adapter.Ecto.RelationTuple
    |> where([t], ^conditions)
    |> Repo.all()

  {:ok, Enum.map(result, &convert_tuple/1)}
end

And this is all we have to do to let our tests pass. Now all that’s left to do is to write the remaining tests and add more clauses to the reducer function accordingly.

For example, the test for querying an object ID within a namespace could be implemented as follows:

test "returns tuples with the given namespace and object ID" do
  object_id = Ecto.UUID.generate()

  tuple_1 =
    insert_tuple(:relation_tuple_for_user,
      object_id: object_id,
      namespace: "book"
    )

  tuple_2 =
    insert_tuple(:relation_tuple_for_user,
      object_id: object_id,
      namespace: "magazine"
    )

  assert Xalapa.read_relation_tuples(%{
           namespace: "book",
           object_id: object_id
         }) == {:ok, [tuple_1]}

  assert Xalapa.read_relation_tuples(%{
           namespace: "magazine",
           object_id: object_id
         }) == {:ok, [tuple_2]}
end

Unsurprisingly, our current implementation will return both tuples in both cases. To fix this, we can update our condition builder function like this:

defp build_read_conditions(tuplesets) do
  Enum.reduce(tuplesets, false, fn
    %{namespace: namespace, object_id: object_id}, conditions ->
      dynamic(
        [t],
        ^conditions or
          (t.namespace == ^namespace and t.object_id == ^object_id)
      )

    %{object_id: object_id}, conditions ->
      dynamic([t], ^conditions or t.object_id == ^object_id)
  end)
end

I will leave the remainder of the tests and implementation to you. You are old enough, after all! In the next part, we will take care of the check API.

  • elixir
  • zanzibar

See Also

  • Zanzibar Goes Elixir, Pt. 1: Setup and Relation Tuples
  • Elixir Dev Environment With Nix Flakes
  • Announcing LetMe
  • Elixir Dev Environment With Nix
  • Essential Elixir Resources
  • privacy policy