Seems like that could be it, because when I eliminate all the other tests, the one in question passes. But I am in Livebook and am not sure how to translate that config. I mean, I’ve done configs in Livebook before, but I also see some put_env
stuff happening with Ash that I don’t understand. So I tried both.
I appreciate your help, and I’ll probably keep digging when Friday rolls around again. But if you are curious, or overly-generous with your time, here’s the entire Livebook:
# Ticketing core
```elixir
# Copy from Ash Livebook Tutorial
Application.put_env(:ash, :validate_api_resource_inclusion?, false)
Application.put_env(:ash, :validate_api_config_inclusion?, false)
# Attempt to make tests pass
Application.put_env(:ash, :disable_async?, true)
Mix.install([:ash, :kino, {:tz, "~> 0.26.5"}],
config: [
elixir: [time_zone_database: Tz.TimeZoneDatabase],
# Separate attempt to make tests pass
ash: [disable_async?: true]
],
# Copy from Ash Livebook Tutorial
consolidate_protocols: false
)
```
## Core definitions in Ash
```elixir
defmodule BoxOffice.Core.Patron do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions(do: defaults([:create, :read, :update, :destroy]))
attributes do
uuid_primary_key(:id)
attribute(:name, :string)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
# Added the inverse relationship of belongs_to in the reservation.
# This way we can reference :reservations inside the aggregates.
relationships do
has_many(:reservations, BoxOffice.Core.Reservation)
end
code_interface do
define_for(BoxOffice.Core)
define(:create, args: [:name])
end
# <- Add the aggregates here
aggregates do
count(:count_of_reservations, :reservations)
end
end
defmodule BoxOffice.Core.Seat do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions(do: defaults([:create, :read, :update, :destroy]))
attributes do
uuid_primary_key(:id)
attribute(:name, :string)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
relationships do
has_many(:reservations, BoxOffice.Core.Reservation)
many_to_many :seating_charts, BoxOffice.Core.SeatingChart do
through(BoxOffice.Core.SeatSeatingChart)
source_attribute_on_join_resource(:seat_id)
destination_attribute_on_join_resource(:seating_chart_id)
end
end
code_interface do
define_for(BoxOffice.Core)
define(:create, args: [:name])
end
end
defmodule BoxOffice.Core.SeatSeatingChart do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
relationships do
belongs_to(:seat, BoxOffice.Core.Seat, primary_key?: true, allow_nil?: false)
belongs_to(:seating_chart, BoxOffice.Core.SeatingChart, primary_key?: true, allow_nil?: false)
end
actions do
defaults([:create, :read, :update, :destroy])
end
end
defmodule BoxOffice.Core.SeatingChart do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
defaults([:create, :read, :update, :destroy])
update :add_seat do
accept([])
argument(:seat_id, :uuid, allow_nil?: false)
change(manage_relationship(:seat_id, :seats, type: :append))
end
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
relationships do
many_to_many :seats, BoxOffice.Core.Seat do
through(BoxOffice.Core.SeatSeatingChart)
source_attribute_on_join_resource(:seating_chart_id)
destination_attribute_on_join_resource(:seat_id)
end
has_many(:performances, BoxOffice.Core.Performance)
end
code_interface do
define_for(BoxOffice.Core)
define(:create, args: [:name])
define(:add_seat, args: [:seat_id])
end
aggregates do
count(:count_of_seats, :seats)
end
end
defmodule BoxOffice.Core.Production do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key(:id)
attribute(:title, :string)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
actions(do: defaults([:create, :read, :update, :destroy]))
relationships do
has_many(:performances, BoxOffice.Core.Performance)
end
code_interface do
define_for(BoxOffice.Core)
define(:create, args: [:title])
end
end
defmodule BoxOffice.Core.Performance do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key(:id)
attribute(:datetime, :datetime)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
actions(do: defaults([:create, :read, :update, :destroy]))
relationships do
belongs_to(:production, BoxOffice.Core.Production) do
attribute_writable?(true)
end
belongs_to(:seating_chart, BoxOffice.Core.SeatingChart) do
attribute_writable?(true)
end
has_many(:reservations, BoxOffice.Core.Reservation)
end
code_interface do
define_for(BoxOffice.Core)
define(:create, args: [:production_id, :datetime, :seating_chart_id])
end
aggregates do
count(:count_of_reserved_seats, :reservations)
end
calculations do
calculate(
:count_of_unreserved_seats,
:integer,
expr(seating_chart.count_of_seats - count_of_reserved_seats)
)
end
end
defmodule BoxOffice.Core.Reservation do
require Ash.Query
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
defaults([:read, :update])
# On creation set the patron by providing the id
create(:make, do: accept([:patron_id, :performance_id, :seat_id]))
end
attributes do
uuid_primary_key(:id)
create_timestamp(:created_at)
update_timestamp(:updated_at)
end
relationships do
belongs_to(:patron, BoxOffice.Core.Patron) do
# Set to writable so you can directly set the patron_id inside the :open action
attribute_writable?(true)
end
belongs_to(:seat, BoxOffice.Core.Seat) do
attribute_writable?(true)
end
belongs_to(:performance, BoxOffice.Core.Performance) do
attribute_writable?(true)
end
end
code_interface do
define_for(BoxOffice.Core)
define(:make, args: [:patron_id, :performance_id, :seat_id])
end
identities do
# pre_check_with might only be necessary for ETS data layer.
# see https://hexdocs.pm/ash/identities.html#pre-checking
identity(:reservation_identity, [:seat_id, :performance_id], pre_check_with: BoxOffice.Core)
end
validations do
validate(fn changeset ->
seat_id = Ash.Changeset.get_attribute(changeset, :seat_id)
performance_id = Ash.Changeset.get_attribute(changeset, :performance_id)
previous_reservation =
__MODULE__
|> Ash.Query.filter(performance_id == ^performance_id and seat_id == ^seat_id)
|> BoxOffice.Core.read!()
|> dbg
case previous_reservation do
[] -> :ok
[_] -> {:error, field: :seat_id, message: "This seat is already reserved"}
end
end)
end
end
defmodule BoxOffice.Core do
use Ash.Api
resources do
resource(BoxOffice.Core.Reservation)
resource(BoxOffice.Core.Patron)
resource(BoxOffice.Core.Production)
resource(BoxOffice.Core.Performance)
resource(BoxOffice.Core.Seat)
resource(BoxOffice.Core.SeatingChart)
resource(BoxOffice.Core.SeatSeatingChart)
end
end
require Ash.Query
```
## Test Setup
<!-- livebook:{"reevaluate_automatically":true} -->
```elixir
defmodule BoxOfficeCoreTest.Helpers do
def reset() do
BoxOffice.Core
|> Ash.Api.Info.resources()
|> Enum.reject(fn resource -> :ets.whereis(resource) == :undefined end)
|> Enum.each(fn resource -> :ets.delete_all_objects(resource) end)
end
def create_patron(name \\ "Sally") do
BoxOffice.Core.Patron.create!(name)
end
def create_production(name \\ "Some Great Show") do
BoxOffice.Core.Production.create!(name)
end
def create_seating_chart(name \\ "main chart") do
BoxOffice.Core.SeatingChart.create!(name)
end
def create_seats(names \\ ["A1"]) when is_list(names) do
names
|> Enum.map(fn name -> %{name: name} end)
|> BoxOffice.Core.bulk_create!(BoxOffice.Core.Seat, :create, return_records?: true)
end
def create_performance(date \\ DateTime.from_naive!(~N[2024-01-01 20:00:00], "America/Chicago")) do
[production] =
BoxOffice.Core.Production
|> Ash.Query.sort(created_at: :desc)
|> Ash.Query.limit(1)
|> BoxOffice.Core.read!()
[seating_chart] =
BoxOffice.Core.SeatingChart
|> Ash.Query.sort(created_at: :desc)
|> Ash.Query.limit(1)
|> BoxOffice.Core.read!()
create_performance(production, seating_chart, date)
end
def create_performance(
production,
seating_chart,
date \\ DateTime.from_naive!(~N[2024-01-01 20:00:00], "America/Chicago")
) do
BoxOffice.Core.Performance.create!(production.id, date, seating_chart.id)
end
def populate_seating_chart() do
[seating_chart] =
BoxOffice.Core.SeatingChart
|> Ash.Query.sort(created_at: :desc)
|> Ash.Query.limit(1)
|> BoxOffice.Core.read!()
seats =
BoxOffice.Core.Seat
|> Ash.Query.sort(created_at: :desc)
|> BoxOffice.Core.read!()
populate_seating_chart(seating_chart, seats)
end
def populate_seating_chart(%BoxOffice.Core.SeatingChart{} = seating_chart, seats) do
seats
|> Enum.map(fn seat ->
BoxOffice.Core.SeatingChart.add_seat!(seating_chart, seat.id)
end)
end
end
```
## Test
<!-- livebook:{"reevaluate_automatically":true} -->
```elixir
ExUnit.start(auto_run: false)
defmodule BoxOfficeCoreTest do
use ExUnit.Case
import BoxOfficeCoreTest.Helpers
setup do
BoxOfficeCoreTest.Helpers.reset()
end
describe "Reservations" do
test "we can reserve a seat" do
create_seating_chart()
[seat] = create_seats().records
populate_seating_chart()
create_production()
performance = create_performance()
assert {:ok, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
end
test "we can query a reservation" do
create_seating_chart()
[seat] = create_seats().records
populate_seating_chart()
create_production()
performance = create_performance()
assert {:ok, reservation} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
assert [_] =
BoxOffice.Core.Reservation
|> Ash.Query.filter(id == ^reservation.id)
|> BoxOffice.Core.read!()
end
test "a reserved seat cannot be reserved again for the same performance" do
create_seating_chart()
[seat] = create_seats().records
populate_seating_chart()
create_production()
performance = create_performance()
assert {:ok, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
assert {:error, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
end
test "a seat outside the performance's chart is not valid when reserving" do
create_seating_chart()
create_seats()
populate_seating_chart()
create_production()
performance = create_performance()
[seat] = create_seats(["Z99"]).records
assert {:error, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
end
test "we can see how many reservations exist" do
create_seating_chart()
[seat | _] = create_seats(["1", "2"]).records
populate_seating_chart()
create_production()
performance = create_performance()
performance =
BoxOffice.Core.load!(performance, [
:count_of_reserved_seats,
:count_of_unreserved_seats
])
assert 0 = performance.count_of_reserved_seats
assert 2 = performance.count_of_unreserved_seats
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
performance =
BoxOffice.Core.load!(performance, [
:count_of_reserved_seats,
:count_of_unreserved_seats
])
assert 1 = performance.count_of_reserved_seats
assert 1 = performance.count_of_unreserved_seats
end
end
end
results = ExUnit.run()
```
## Play with the resources
```elixir
defmodule WhoTestsTheTesters do
import BoxOfficeCoreTest.Helpers
def run() do
create_seating_chart()
[seat] = create_seats().records
populate_seating_chart()
create_production()
performance = create_performance()
{:ok, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
# This match works (which is correct), but the same thing fails in the test
{:error, _} =
BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
end
end
BoxOfficeCoreTest.Helpers.reset()
WhoTestsTheTesters.run()
```