Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
aabb4be
improvement: Add support for returning record metadata in result.
jimsynz Mar 6, 2025
5bfd934
chore(deps-dev): bump ex_doc in the dev-dependencies group (#7)
dependabot[bot] Mar 8, 2025
cc92ebd
chore(deps-dev): bump igniter in the dev-dependencies group (#10)
dependabot[bot] Mar 11, 2025
9b9e338
chore: make text-based assertions case insensitive.
jimsynz Mar 11, 2025
76ed2cc
chore(deps): bump the production-dependencies group across 1 director…
dependabot[bot] Mar 11, 2025
772d851
chore(deps-dev): bump igniter in the dev-dependencies group (#12)
dependabot[bot] Mar 12, 2025
688bb67
chore(deps-dev): bump igniter in the dev-dependencies group (#13)
dependabot[bot] Mar 13, 2025
fda8c60
chore(deps): bump ash in the production-dependencies group (#15)
dependabot[bot] Mar 19, 2025
ba7c5c9
chore(deps-dev): bump igniter in the dev-dependencies group (#18)
dependabot[bot] Mar 25, 2025
7231477
chore(deps): bump the production-dependencies group across 1 director…
dependabot[bot] Mar 25, 2025
c290425
chore(deps-dev): bump igniter in the dev-dependencies group (#20)
dependabot[bot] Mar 26, 2025
2bda28f
chore(deps): bump the production-dependencies group with 2 updates (#19)
dependabot[bot] Mar 26, 2025
ebeebc3
chore(deps): bump ash in the production-dependencies group (#21)
dependabot[bot] Mar 27, 2025
47faf66
chore(deps-dev): bump igniter in the dev-dependencies group (#22)
dependabot[bot] Mar 27, 2025
a2af0c6
chore(deps): bump ash in the production-dependencies group (#23)
dependabot[bot] Mar 30, 2025
1a488da
chore(deps-dev): bump igniter in the dev-dependencies group (#24)
dependabot[bot] Mar 31, 2025
b0245df
chore(deps-dev): bump the dev-dependencies group with 2 updates (#27)
dependabot[bot] Apr 3, 2025
79df65d
chore(deps): bump ash in the production-dependencies group (#25)
dependabot[bot] Apr 3, 2025
5130806
chore(deps-dev): bump sourceror in the dev-dependencies group (#28)
dependabot[bot] Apr 4, 2025
f2b8c11
chore(deps): bump the production-dependencies group with 2 updates (#29)
dependabot[bot] Apr 10, 2025
e476525
chore(deps-dev): bump igniter in the dev-dependencies group (#30)
dependabot[bot] Apr 10, 2025
20af28d
chore(deps): bump ash in the production-dependencies group (#31)
dependabot[bot] Apr 11, 2025
031fe33
chore(deps-dev): bump sourceror in the dev-dependencies group (#33)
dependabot[bot] Apr 14, 2025
81649a6
chore(deps): bump the production-dependencies group with 2 updates (#32)
dependabot[bot] Apr 14, 2025
c37b943
improvement: encode record or records returned from generic actions
zachdaniel Apr 15, 2025
70374ea
chore: release version v0.2.4
zachdaniel Apr 15, 2025
634d452
chore: fix credo complaints.
jimsynz Apr 16, 2025
8e8bfb9
chore(deps-dev): bump igniter in the dev-dependencies group (#35)
dependabot[bot] Apr 16, 2025
1c29846
chore(deps): bump the production-dependencies group with 2 updates (#34)
dependabot[bot] Apr 16, 2025
6221234
chore(deps): bump spark in the production-dependencies group (#36)
dependabot[bot] Apr 18, 2025
56d733d
chore(deps-dev): bump credo in the dev-dependencies group (#37)
dependabot[bot] Apr 18, 2025
e1e4a22
Serialization support for actions returning resources of different ty…
mbuhot Apr 28, 2025
c8468fa
chore: fix credo warning.
jimsynz Apr 28, 2025
6ce96bd
chore(deps-dev): bump igniter in the dev-dependencies group (#39)
dependabot[bot] Apr 28, 2025
da776c1
chore(deps): bump spark in the production-dependencies group (#38)
dependabot[bot] Apr 28, 2025
fc64d6d
wip: this is going to be annoying
jimsynz Apr 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline

<!-- changelog -->

## v0.2.4 (2025-04-15)




### Improvements:

* encode record or records returned from generic actions

## v0.2.3 (2025-03-06)


Expand Down
48 changes: 45 additions & 3 deletions lib/ash_ops/task/action.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule AshOps.Task.Action do

This should only ever be called from the mix task itself.
"""
alias Ash.ActionInput
alias Ash.{ActionInput, Resource.Info}
alias AshOps.Task.ArgSchema
import AshOps.Task.Common

Expand All @@ -14,6 +14,7 @@ defmodule AshOps.Task.Action do
{:ok, actor} <- load_actor(cfg[:actor], cfg[:tenant]),
cfg <- Map.put(cfg, :actor, actor),
{:ok, result} <- run_action(task, cfg),
{:ok, result} <- maybe_load(result, task, cfg),
{:ok, output} <- serialise_result(result, cfg) do
Mix.shell().info(output)

Expand All @@ -23,6 +24,35 @@ defmodule AshOps.Task.Action do
end
end

defp maybe_load(result, task, cfg) do
if record_or_records?(result) do
{load, opts} =
cfg
|> Map.take([:load, :actor, :tenant])
|> Map.put(:domain, task.domain)
|> Keyword.new()
|> Keyword.pop(:load)

if load == [] do
{:ok, result}
else
Ash.load(result, load, opts)
end
else
{:ok, result}
end
end

defp record_or_records?([%struct{} | _]) do
Info.resource?(struct)
end

defp record_or_records?(%struct{}) do
Info.resource?(struct)
end

defp record_or_records?(_), do: false

defp run_action(task, cfg) do
args =
cfg
Expand All @@ -44,7 +74,19 @@ defmodule AshOps.Task.Action do
end
end

defp serialise_result(result, cfg) when cfg.format == :yaml do
defp serialise_result(result, cfg) do
if record_or_records?(result) do
if is_list(result) do
serialise_records(result, hd(result).__struct__, cfg)
else
serialise_record(result, result.__struct__, cfg)
end
else
serialise_generic_result(result, cfg)
end
end

defp serialise_generic_result(result, cfg) when cfg.format == :yaml do
result
|> Ymlr.document()
|> case do
Expand All @@ -53,7 +95,7 @@ defmodule AshOps.Task.Action do
end
end

defp serialise_result(result, cfg) when cfg.format == :json do
defp serialise_generic_result(result, cfg) when cfg.format == :json do
result
|> Jason.encode(pretty: true)
end
Expand Down
151 changes: 116 additions & 35 deletions lib/ash_ops/task/common.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,46 @@ defmodule AshOps.Task.Common do
stop()
end

@doc "Return the filter field for the configured identity, or the primary key"
def identity_or_pk_field(resource, cfg)
when is_atom(cfg.identity) and not is_nil(cfg.identity) do
case Info.identity(resource, cfg.identity) do
%{keys: [field]} -> {:ok, field}
_ -> {:error, "Composite identity error"}
end
end

def identity_or_pk_field(resource, _cfg) do
case Info.primary_key(resource) do
[pk] -> {:ok, pk}
_ -> {:error, "Primary key error"}
end
end

@doc "Serialise the record for display"
def serialise_record(record, task, cfg) do
record
|> filter_record(task, cfg)
|> format_record(cfg[:format] || :yaml)
def serialise_record(record, resource, cfg) do
data = prepare_record(record, resource, cfg)

case cfg.format do
:yaml ->
data
|> Ymlr.document()
|> case do
{:ok, yaml} -> {:ok, String.replace_leading(yaml, "---\n", "")}
{:error, reason} -> {:error, reason}
end

:json ->
data
|> Jason.encode(pretty: true)
end
end

@doc "Serialise a list of records for display"
def serialise_records(records, task, cfg) when cfg.format == :yaml do
def serialise_records(records, resource, cfg) when cfg.format == :yaml do
with {:ok, outputs} <-
Enum.reduce_while(records, {:ok, []}, fn record, {:ok, outputs} ->
case serialise_record(record, task, cfg) do
case serialise_record(record, resource, cfg) do
{:ok, output} -> {:cont, {:ok, [output | outputs]}}
{:error, reason} -> {:halt, {:error, reason}}
end
Expand All @@ -72,56 +100,109 @@ defmodule AshOps.Task.Common do
end
end

def serialise_records(records, task, cfg) when cfg.format == :json do
def serialise_records(records, resource, cfg) when cfg.format == :json do
records
|> Enum.map(&filter_record(&1, task, cfg))
|> Enum.map(&prepare_record(&1, resource, cfg))
|> Jason.encode(pretty: true)
end

def serialise_records(records, task, cfg),
do: serialise_records(records, task, Map.put(cfg, :format, :yaml))
def serialise_records(records, resource, cfg),
do: serialise_records(records, resource, Map.put(cfg, :format, :yaml))

@doc "Return the filter field for the configured identity, or the primary key"
def identity_or_pk_field(task, cfg) when is_atom(cfg.identity) and not is_nil(cfg.identity) do
case Info.identity(task.resource, cfg.identity) do
%{keys: [field]} -> {:ok, field}
_ -> {:error, "Composite identity error"}
end
# Filter and format record fields, but do not encode
defp prepare_record(record, resource, cfg) do
record
|> filter_record(resource, cfg)
|> format_record(resource, cfg)
end

def identity_or_pk_field(task, _cfg) do
case Info.primary_key(task.resource) do
[pk] -> {:ok, pk}
_ -> {:error, "Primary key error"}
# Apply formatting to each field of a filtered record
defp format_record(record, resource, cfg) do
Map.new(record, fn
{key, value} ->
field_info = resource |> Info.field(key)
{key, format_value(value, field_info, cfg)}
end)
end

@doc """
Format a value given the field type info and formatting configuration options
"""
def format_value(value, field_info, cfg)

# NOTE: In future, dispatch on the type, not the value to support new types
def format_value(%Ash.CiString{} = value, field_info, cfg) do
format_value(to_string(value), field_info, cfg)
end

def format_value(nil, _field, %{format: :yaml}) do
"nil"
end

def format_value(value, attribute = %{type: {:array, type}}, cfg) when is_list(value) do
inner_type = type
inner_constraints = attribute.constraints[:items] || []
inner_attribute = %{attribute | type: inner_type, constraints: inner_constraints}
Enum.map(value, &format_value(&1, inner_attribute, cfg))
end

# HasMany or ManyToMany relationships
def format_value(value, attribute = %{cardinality: :many}, cfg) when is_list(value) do
Enum.map(value, &format_value(&1, attribute, cfg))
end

def format_value(%struct{} = value, field_info, cfg) do
if Info.resource?(struct) do
load = cfg[:load][field_info.name] || []
cfg = Map.put(cfg, :load, load)
prepare_record(value, struct, cfg)
else
format_fallback_value(value, cfg)
end
end

defp format_record(record, :yaml) do
record
|> Map.new(fn
{key, nil} -> {key, "nil"}
{key, value} -> {key, value}
end)
|> Ymlr.document()
|> case do
{:ok, yaml} -> {:ok, String.replace_leading(yaml, "---\n", "")}
{:error, reason} -> {:error, reason}
def format_value(value, _field_info, cfg) do
format_fallback_value(value, cfg)
end

defp format_fallback_value(value, %{format: :json}) do
if Jason.Encoder.impl_for(value) do
value
else
"<Failed to encode>"
end
end

defp format_record(record, :json) do
record
|> Jason.encode(pretty: true)
defp format_fallback_value(value, %{format: :yaml}) do
if Ymlr.Encoder.impl_for(value) do
value
else
"<Failed to encode>"
end
end

defp filter_record(record, task, cfg) do
task.resource
# Convert a record to a plain map, excluding private fields
defp filter_record(record, _resource, cfg) do
record
|> Info.public_fields()
|> Enum.map(& &1.name)
|> Enum.concat(cfg[:load] || [])
|> Enum.concat(include_metadata?(record, cfg))
|> do_filter_record(record)
end

defp include_metadata?(record, cfg) when cfg.metadata == true do
dbg(cfg)

[:__metadata__]
end

defp include_metadata?(record, cfg)
when cfg.metadata == true and map_size(record.__metadata__) > 0,
do: [:__metadata__]

defp include_metadata?(_record, _cfg), do: []

defp do_filter_record(fields, record, result \\ %{})
defp do_filter_record([], _record, result), do: result

Expand Down
10 changes: 9 additions & 1 deletion lib/ash_ops/task/create.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule AshOps.Task.Create do
with {:ok, cfg} <- ArgSchema.parse(arg_schema, argv),
{:ok, changeset} <- read_input(task, cfg),
{:ok, record} <- create_record(changeset, task, cfg),
{:ok, output} <- serialise_record(record, task, cfg) do
{:ok, output} <- serialise_record(record, task.resource, cfg) do
Mix.shell().info(output)
:ok
else
Expand Down Expand Up @@ -233,6 +233,14 @@ defmodule AshOps.Task.Create do
],
[:i]
)
|> ArgSchema.add_switch(
:metadata,
:boolean,
type: :boolean,
required: false,
default: false,
doc: "Whether or not to include any record metadata in the result"
)

@shortdoc "Create a `#{inspect(@task.resource)}` record using the `#{@task.action.name}` action"

Expand Down
4 changes: 2 additions & 2 deletions lib/ash_ops/task/destroy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule AshOps.Task.Destroy do
:ok <- destroy_record(task, cfg) do
:ok
else
{:error, reason} -> handle_error(reason)
{:error, reason} -> handle_error({:error, reason})
end
end

Expand All @@ -28,7 +28,7 @@ defmodule AshOps.Task.Destroy do
|> Map.put(:domain, task.domain)
|> Enum.to_list()

with {:ok, field} <- identity_or_pk_field(task, cfg) do
with {:ok, field} <- identity_or_pk_field(task.resource, cfg) do
task.resource
|> Query.new()
|> Query.filter_input(%{field => %{"eq" => cfg.positional_arguments.id}})
Expand Down
12 changes: 10 additions & 2 deletions lib/ash_ops/task/get.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule AshOps.Task.Get do
{:ok, actor} <- load_actor(cfg[:actor], cfg[:tenant]),
cfg <- Map.put(cfg, :actor, actor),
{:ok, record} <- load_record(task, cfg),
{:ok, output} <- serialise_record(record, task, cfg) do
{:ok, output} <- serialise_record(record, task.resource, cfg) do
Mix.shell().info(output)

:ok
Expand All @@ -34,7 +34,7 @@ defmodule AshOps.Task.Get do
|> Map.put(:authorize_with, :error)
|> Enum.to_list()

with {:ok, field} <- identity_or_pk_field(task, cfg) do
with {:ok, field} <- identity_or_pk_field(task.resource, cfg) do
task.resource
|> Query.new()
|> Query.for_read(task.action.name)
Expand All @@ -60,6 +60,14 @@ defmodule AshOps.Task.Get do
],
[:i]
)
|> ArgSchema.add_switch(
:metadata,
:boolean,
type: :boolean,
required: false,
default: false,
doc: "Whether or not to include any record metadata in the result"
)

@shortdoc "Get a single `#{inspect(@task.resource)}` record using the `#{@task.action.name}` action"

Expand Down
10 changes: 9 additions & 1 deletion lib/ash_ops/task/list.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule AshOps.Task.List do
{:ok, query} <- QueryLang.parse(task, query),
{:ok, actor} <- load_actor(cfg[:actor], cfg[:tenant]),
{:ok, records} <- load_records(query, task, Map.put(cfg, :actor, actor)),
{:ok, output} <- serialise_records(records, task, cfg) do
{:ok, output} <- serialise_records(records, task.resource, cfg) do
Mix.shell().info(output)

:ok
Expand Down Expand Up @@ -109,6 +109,14 @@ defmodule AshOps.Task.List do
required: false,
doc: "An optional sort to apply to the query"
)
|> ArgSchema.add_switch(
:metadata,
:boolean,
type: :boolean,
required: false,
default: false,
doc: "Whether or not to include any record metadata in the result"
)

@shortdoc "Query for `#{inspect(@task.resource)}` records using the `#{@task.action.name}` action"

Expand Down
2 changes: 1 addition & 1 deletion lib/ash_ops/task/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ defmodule AshOps.Task.Types do
{:ok, args}
else
{:error,
"Expected #{input_length} positional arguments, but received #{expected_arg_count}"}
"Expected #{expected_arg_count} positional arguments, but received #{input_length}"}
end
end

Expand Down
Loading