Geo: A database-backed country chooser built with Ash

A slightly more than beginner project for learning Ash. Maybe Zach will critique! https://github.com/dev-guy/geo/blob/main/README.md
3 Replies
Terris
TerrisOP4mo ago
No description
Terris
TerrisOP4mo ago
I deployed the demo to fly.io. There were some things that I had to figure out. I left notes in the README. fly.io is fantastic and nearly free for hosting open source demos in Elixir -- even those that require Postgres. https://geo-demo.fly.dev/ Today I refactored the generic search action on the Country resource and learned more about Ash. The “search” action accepts an optional query parameter that is used to search for countries based on name and ISO code. It returns two lists of Country records: one in which country names match the query and the other in which country codes match the query. The reason for returning two lists is to allow users to sort them separately. This UX makes more sense when country names are localized.
action :search, :tuple do
argument :query, :string, allow_nil?: true, default: nil

run fn input, _context ->
query = input.arguments.query
{iso_code_results, name_results} = Geo.Geography.Country.Cache.search!(query)
{:ok, {iso_code_results, name_results}}
end
end
action :search, :tuple do
argument :query, :string, allow_nil?: true, default: nil

run fn input, _context ->
query = input.arguments.query
{iso_code_results, name_results} = Geo.Geography.Country.Cache.search!(query)
{:ok, {iso_code_results, name_results}}
end
end
The output of the above search action is a classic DTO that is alien to the Ash ecosystem. The search action's return value as a tuple would have been an endless source of confusion. We need something better than documentation when it comes to correctly using APIs How do we specify the module for the return value of a custom action when it is a struct? Can we replace :tuple what a specific struct? Claude said I could use Module.StructName instead of :struct but it didn't work. It's worth asking the real experts. It's probably possible. I didn't pursue it. Model your domain Instead, I modeled the return value of a search operation as an Ash resource (Geography.Country.SearchResult). Since search results are not persistent objects, perhaps defining an Ash resource is the Wrong Thing to Do aka Overkill. Is it better to declare the return value of a generic action as a regular struct? Simpler is usually best. But... Will Ash future-proof search functionality? My intuition said yes. Ash has a lot of functionality that would be called YAGNI until You Actually Need It. Staying in the Ash ecosystem seems like a Smart Thing To Do. Let's try it ... The SearchResult resource has two attributes, both of which are lists of Country records:
attribute :countries_by_iso_code, {:array, :struct} do
allow_nil? false
public? true
description "Countries matching the search query by ISO code"
constraints instance_of: Geo.Geography.Country
end

attribute :countries_by_name, {:array, :struct} do
allow_nil? false
public? true
description "Countries matching the search query by name"
constraints instance_of: Geo.Geography.Country
end
attribute :countries_by_iso_code, {:array, :struct} do
allow_nil? false
public? true
description "Countries matching the search query by ISO code"
constraints instance_of: Geo.Geography.Country
end

attribute :countries_by_name, {:array, :struct} do
allow_nil? false
public? true
description "Countries matching the search query by name"
constraints instance_of: Geo.Geography.Country
end
Note the constraints keywords. This is how we get type safety. As with structs, now we have the ability to Add More Later without disturbing existing clients. Should I have used Ash references instead of lists of records? I tried. It didn't pan out. How do we specify the fact that the return value of a custom action is a resource struct (aka Record)? This turned out to be the wrong question. Instead of adding a function to the Country that returns Country.SearchResult, Claude suggested I add a read action to Country.SearchResult. A read operation is more "Ashy" than a generic action, IMO. The implementation is ready for graceful improvements. Ash makes you think. Ash is deep. I like that. On the other hand, this probably scares away a lot of would-be users of Ash. Start Simple!
Terris
TerrisOP4mo ago
The SearchResult resource's read action is defined as a manual action (since it doesn’t run a database query but instead calls an internal cache) that accepts one argument named query:
read :search do
argument :query, :string, allow_nil?: true, default: nil
get? true # Returns at most one item
manual Country.SearchResult.Manual.Search
end
read :search do
argument :query, :string, allow_nil?: true, default: nil
get? true # Returns at most one item
manual Country.SearchResult.Manual.Search
end
How do we add the search action to a domain? The search_countries function was added to the domain within a resource block:
resource Country.SearchResult do
define :search_countries, action: :search, args: [{:optional, :query}]
end
resource Country.SearchResult do
define :search_countries, action: :search, args: [{:optional, :query}]
end
This demonstrates one advantage of domains: it doesn't necessarily matter how you organize your actions since domains hide those details. Read actions implicitly return resource records. It’s not explicitly stated in the above definition - you just have to know. By the way, I could have defined the search action as the primary read action, which implicitly adds a “get” function to the domain: Geography.get!(Geography.Country.SearchResult, %{query: “…”}) However, I think search_countries is a better name. There’s nothing stopping us from supporting both, but it gets a little magical. The PR is at https://github.com/dev-guy/geo/pull/54
GitHub
Refactor search results and country cache by dev-guy · Pull Reques...
Closes #53 and #36 Define country GenServer state with a struct Use poolboy instead of dynamic supervisor Define return value of Geography.search_countries.do_search as a resource

Did you find this page helpful?