My Favorite Elixir Discovery - Access
A while ago, I read Hillel Wayne’ssource suggestion to “Search less, browse more”. Specifically to systemically go through the the information available for your chosen tools, rather than searching for specific answers to questions as they arise. This is the story of what I found when I started browsing through Elixir’s documentation.As found on HexDocs
But first, a diversion into:
GraphQL queries
One of the advantages of GraphQL is that you specify the structure that you’re querying with your query. You specify the edges and nodes of the data that you want, and it’s delivered in JSON with exactly that schema. However, this can get unwieldy if the accessible starting point is far away in the graph from the data you need.
For example, here is a query accessing <meetup.com>’s API searching for Online Elixir Events hosted by GroupsA lot of Elixir meetups went online during the pandemic, but Meetup’s search made them hard to find.Latitude: -48.876667
and Longitude: -123.393333
points to Point Nemo the most distant point of ocean from land.:
{
keywordSearch(
input: { first: 1000 }
filter: {
query: "elixir"
lat: -48.876667
lon: -123.393333
source: GROUPS
radius: 100
eventType: ONLINE
}
) {
edges {
node {
result {
... on Group {
name
unifiedEvents {
count
edges {
node {
title
isOnline
eventUrl
description
dateTime
timezone
group {
name
}
}
}
}
}
}
}
}
}
}
Notice the nested curly braces at the end. I can get the data I need, and I know the structure that it’s in, but the data I care about is hidden by a nest of indirection.
Here’s how I first attempted to unpack the data I needed:
Unpacking GraphQL in Elixir
def main() do
Neuron.Config.set(url: "https://api.meetup.com/gql")
{:ok, query_results} = File.read!("elixir.gql") |> Neuron.query()
results = query_results.body["data"]["keywordSearch"]["edges"]
|> Enum.filter(fn elem -> elem["node"]["result"]["unifiedEvents"]["count"] > 0 end)
|> Enum.map(fn elem -> elem["node"]["result"]["unifiedEvents"]["edges"] end)
|> Enum.concat()
|> Enum.map(fn elem -> elem["node"] end)
end
This is only getting the maps of data that I care about, it doesn’t include additional work to do filtering that the API didn’t provide sufficient granularity to do before the queryNeuron is an Elixir GraphQL client: https://hexdocs.pm/neuron/readme.html.
Part of what makes it awkward is that we alternate between nodes, which are parsed as maps, and edges, which deserialize as lists.
Access, a better alternative?
This brings me back to browsing the Elixir documentation. My first inkling that there might be a better way arrived with Kernel.get_in/2
Read more here. get_in/2
gets a value from a nested structure, I have one of those! It takes a data structure and a list of keys, and uses the Access behaviourI wondered why Access was implemented as a Behaviour and not a Protocol. It turns out that it comes down to performance, Access is invoked far more than any protocol functionality in Elixir, and it was becoming a bottleneck. So they switched it to a Behaviour, which is built right into the Erlang/OTP ecosystem, and is optimized with first-class support. to extract a value.
Access is what powers the thing["squareBracket"]
syntax, so we were already using it. The real power of the module comes from special functions in the Access moduleread more about Access here. The get_in/2
function takes more than just atom or string keys, it can also take functions that can process our GraphQL Edges. Let’s talk about three:
Access.all/0
Access.all/0
accesses all elements of a list.
For example:
iex()> books = [
%{
name: "Naming Things",
url: "https://www.namingthings.co/"
},
%{
name: "Essence of Software",
url: "https://essenceofsoftware.com/"
}
]
iex()> get_in(books, [Access.all(), :name])
["Naming Things", "Essence of Software"]
Access.filter/1
Access.filter/1
access all elements of a list that match a provided predicate function.
For example:
iex()> books = [
%{
name: "Naming Things",
url: "https://www.namingthings.co/"
},
%{
name: "Essence of Software",
url: "https://essenceofsoftware.com/"
}
]
iex()> get_in(
books,
[
Access.filter(
&String.starts_with?(&1.name, "N")
),
:name
]
)
["Naming Things"]
Access.key/2
Access.key/2
allows you to access the given key in a map or struct. This seems superfluous, but in Elixir, Structs don’t implement the Access behaviour by default. This means that you access keys with the .
syntax, but not the []
syntax:
defmodule MyStruct do
defstruct [:name]
end
iex()> my_struct = %MyStruct{name: "A name"}
iex()> my_struct.name
"A name"
iex()> my_struct[:name]
** (UndefinedFunctionError) function
MyStruct.fetch/2 is undefined (MyStruct does not
implement the Access behaviour. If you are using
get_in/put_in/update_in, you can specify the
field to be accessed using Access.key!/1)
The error message hints at the usefulness of Access.key/2
, though it recommends the version that can throw an error: Access.key!/1
iex() get_in(my_struct, [Access.key(:name)])
"A name"
This is extremely useful for structs generated by other Elixir librariesI personally found this useful for access nested structures preloaded from Ecto.
Returning to GraphQL
With Access in hand, I can refactor my nested access call from earlierI can skip my earlier use of filter since Enum.concat/1
behaves nicely with empty lists.:
def main() do
Neuron.Config.set(url: "https://api.meetup.com/gql")
{:ok, query_results} = File.read!("elixir.gql") |> Neuron.query()
results = get_in(
query_results,
[
Access.key(:body),
"data",
"keywordSearch",
"edges",
Access.all(),
"node",
"result",
"unifiedEvents",
"edges",
Access.all(),
"node"
]
) |> Enum.concat()
end
This shows us exactly how indirect the original data was. Now the structure is easy to see and modify.
Takeaways
Access
provides a clean way to access data in nested data structures. Access.key
provides a way to access structs. Access.all
and Access.filter/1
provide ways to handle lists in the middle of a data structureI’ve heard that this style of accessors is referred to as Functional Lenses, and other programming languages have implementations or libraries supporting this kind access..
Here, I only needed to access the nested data, but Elixir’s Kernel
provides functions for get_and_update_in/3
, pop_in/2
, put_in/3
for changing the data in that nested structure. Check them out: https://hexdocs.pm/elixir/1.15.0/Kernel.html