feat: support expression based calculations

feat: support concat + if expressions
improvement: various other improvements
This commit is contained in:
Zach Daniel 2021-06-04 01:48:35 -04:00
parent 32fdfe354a
commit dae39f5fda
15 changed files with 966 additions and 58 deletions

View file

@ -99,7 +99,7 @@
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesInCondition, false},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},

View file

@ -273,7 +273,7 @@ defmodule AshPostgres.DataLayer do
alias Ash.Filter
alias Ash.Query.{BooleanExpression, Not, Ref}
alias Ash.Query.Function.{Ago, Contains}
alias Ash.Query.Function.{Ago, Contains, If}
alias Ash.Query.Operator.IsNil
alias AshPostgres.Functions.{Fragment, TrigramSimilarity, Type}
@ -284,6 +284,13 @@ defmodule AshPostgres.DataLayer do
@sections [@postgres]
# This creates the atoms 0..500, which are used for calculations
# If you know of a way to get around the fact that subquery select statement keys
# *must* be atoms, please let me know so I can remove this :)
for i <- 0..500 do
:"#{i}"
end
@moduledoc """
A postgres data layer that levereges Ecto's postgres capabilities.
@ -353,6 +360,7 @@ defmodule AshPostgres.DataLayer do
def can?(_, {:aggregate, :list}), do: true
def can?(_, :aggregate_filter), do: true
def can?(_, :aggregate_sort), do: true
def can?(_, :expression_calculation), do: true
def can?(_, :create), do: true
def can?(_, :select), do: true
def can?(_, :read), do: true
@ -389,15 +397,21 @@ defmodule AshPostgres.DataLayer do
@impl true
def set_context(resource, data_layer_query, context) do
if context[:data_layer][:table] do
{:ok,
%{
data_layer_query
| from: %{data_layer_query.from | source: {context[:data_layer][:table], resource}}
}}
else
{:ok, data_layer_query}
end
data_layer_query =
if context[:data_layer][:table] do
%{
data_layer_query
| from: %{data_layer_query.from | source: {context[:data_layer][:table], resource}}
}
else
data_layer_query
end
data_layer_query =
data_layer_query
|> default_bindings(resource, context)
{:ok, data_layer_query}
end
@impl true
@ -1050,10 +1064,12 @@ defmodule AshPostgres.DataLayer do
end
end
defp default_bindings(query, resource) do
defp default_bindings(query, resource, context \\ %{}) do
Map.put_new(query, :__ash_bindings__, %{
current: Enum.count(query.joins) + 1,
calculations: %{},
aggregates: %{},
context: context,
bindings: %{0 => %{path: [], type: :root, source: resource}}
})
end
@ -1136,7 +1152,7 @@ defmodule AshPostgres.DataLayer do
defp can_inner_join?(_, _, _), do: false
@impl true
def add_aggregate(query, aggregate, _resource) do
def add_aggregate(query, aggregate, _resource, add_base? \\ true) do
resource = aggregate.resource
query = default_bindings(query, resource)
@ -1200,7 +1216,7 @@ defmodule AshPostgres.DataLayer do
new_query =
query_with_aggregate_binding
|> add_aggregate_to_subquery(resource, aggregate, binding)
|> select_aggregate(resource, aggregate)
|> select_aggregate(resource, aggregate, add_base?)
{:ok, new_query}
@ -1209,8 +1225,9 @@ defmodule AshPostgres.DataLayer do
end
end
defp select_aggregate(query, resource, aggregate) do
binding = get_binding(resource, aggregate.relationship_path, query, :aggregate)
@impl true
def add_calculation(query, calculation, expression, resource) do
query = default_bindings(query, resource)
query =
if query.select do
@ -1218,15 +1235,125 @@ defmodule AshPostgres.DataLayer do
else
from(row in query,
select: row,
select_merge: %{aggregates: %{}}
select_merge: %{aggregates: %{}, calculations: %{}}
)
end
%{query | select: add_to_select(query.select, binding, aggregate)}
{params, expr} =
do_filter_to_expr(
expression,
query.__ash_bindings__,
query.select.params
)
{:ok,
query
|> Map.update!(:select, &add_to_calculation_select(&1, expr, List.wrap(params), calculation))}
end
defp add_to_select(
%{expr: {:merge, _, [first, {:%{}, _, [{:aggregates, {:%{}, [], fields}}]}]}} = select,
defp select_aggregate(query, resource, aggregate, add_base?) do
binding = get_binding(resource, aggregate.relationship_path, query, :aggregate)
query =
if query.select do
query
else
if add_base? do
from(row in query,
select: row,
select_merge: %{aggregates: %{}, calculations: %{}}
)
else
from(row in query, select: row)
end
end
%{query | select: add_to_aggregate_select(query.select, binding, aggregate)}
end
defp add_to_calculation_select(
%{
expr:
{:merge, _,
[
first,
{:%{}, _,
[{:aggregates, {:%{}, [], agg_fields}}, {:calculations, {:%{}, [], fields}}]}
]}
} = select,
expr,
params,
%{load: nil} = calculation
) do
field =
{:type, [],
[
expr,
Ash.Type.ecto_type(calculation.type)
]}
name =
if calculation.sequence == 0 do
calculation.name
else
String.to_existing_atom("#{calculation.sequence}")
end
new_fields = [
{name, field}
| fields
]
%{
select
| expr:
{:merge, [],
[
first,
{:%{}, [],
[{:aggregates, {:%{}, [], agg_fields}}, {:calculations, {:%{}, [], new_fields}}]}
]},
params: params
}
end
defp add_to_calculation_select(
%{expr: select_expr} = select,
expr,
params,
%{load: load_as} = calculation
) do
field =
{:type, [],
[
expr,
Ash.Type.ecto_type(calculation.type)
]}
load_as =
if calculation.sequence == 0 do
load_as
else
"#{load_as}_#{calculation.sequence}"
end
%{
select
| expr: {:merge, [], [select_expr, {:%{}, [], [{load_as, field}]}]},
params: params
}
end
defp add_to_aggregate_select(
%{
expr:
{:merge, _,
[
first,
{:%{}, _,
[{:aggregates, {:%{}, [], fields}}, {:calculations, {:%{}, [], calc_fields}}]}
]}
} = select,
binding,
%{load: nil} = aggregate
) do
@ -1259,10 +1386,19 @@ defmodule AshPostgres.DataLayer do
| fields
]
%{select | expr: {:merge, [], [first, {:%{}, [], [{:aggregates, {:%{}, [], new_fields}}]}]}}
%{
select
| expr:
{:merge, [],
[
first,
{:%{}, [],
[{:aggregates, {:%{}, [], new_fields}}, {:calculations, {:%{}, [], calc_fields}}]}
]}
}
end
defp add_to_select(
defp add_to_aggregate_select(
%{expr: expr} = select,
binding,
%{load: load_as} = aggregate
@ -1415,7 +1551,7 @@ defmodule AshPostgres.DataLayer do
{params, expr} =
filter_to_expr(
aggregate.query.filter,
query.__ash_bindings__.bindings,
query.__ash_bindings__,
query.select.params
)
@ -1492,7 +1628,7 @@ defmodule AshPostgres.DataLayer do
{params, expr} =
filter_to_expr(
aggregate.query.filter,
query.__ash_bindings__.bindings,
query.__ash_bindings__,
query.select.params
)
@ -1522,7 +1658,7 @@ defmodule AshPostgres.DataLayer do
{params, expr} =
filter_to_expr(
aggregate.query.filter,
query.__ash_bindings__.bindings,
query.__ash_bindings__,
query.select.params
)
@ -1669,9 +1805,9 @@ defmodule AshPostgres.DataLayer do
join_relationship = Ash.Resource.Info.relationship(source, relationship.join_relationship)
with {:ok, relationship_through} <-
maybe_get_resource_query(relationship.through, join_relationship),
maybe_get_resource_query(relationship.through, join_relationship, query),
{:ok, relationship_destination} <-
maybe_get_resource_query(relationship.destination, relationship) do
maybe_get_resource_query(relationship.destination, relationship, query) do
relationship_through =
relationship_through
|> Ecto.Queryable.to_query()
@ -1689,12 +1825,19 @@ defmodule AshPostgres.DataLayer do
end
end)
used_aggregates = Ash.Filter.used_aggregates(filter, path ++ [relationship.name])
used_calculations =
Ash.Filter.used_calculations(
filter,
relationship.destination,
path ++ [relationship.name]
)
used_aggregates = used_aggregates(filter, relationship, used_calculations, path)
Enum.reduce_while(used_aggregates, {:ok, relationship_destination}, fn agg, {:ok, query} ->
agg = %{agg | load: agg.name}
case add_aggregate(query, agg, relationship.destination) do
case add_aggregate(query, agg, relationship.destination, false) do
{:ok, query} ->
{:cont, {:ok, query}}
@ -1704,6 +1847,15 @@ defmodule AshPostgres.DataLayer do
end)
|> case do
{:ok, relationship_destination} ->
relationship_destination =
case used_aggregates do
[] ->
relationship_destination
_ ->
subquery(relationship_destination)
end
new_query =
case kind do
{:aggregate, _, subquery} ->
@ -1773,7 +1925,7 @@ defmodule AshPostgres.DataLayer do
end
defp do_join_relationship(query, relationship, path, kind, source, filter) do
case maybe_get_resource_query(relationship.destination, relationship) do
case maybe_get_resource_query(relationship.destination, relationship, query) do
{:error, error} ->
{:error, error}
@ -1790,13 +1942,20 @@ defmodule AshPostgres.DataLayer do
end
end)
used_aggregates = Ash.Filter.used_aggregates(filter, path ++ [relationship.name])
used_calculations =
Ash.Filter.used_calculations(
filter,
relationship.destination,
path ++ [relationship.name]
)
used_aggregates = used_aggregates(filter, relationship, used_calculations, path)
Enum.reduce_while(used_aggregates, {:ok, relationship_destination}, fn agg,
{:ok, query} ->
agg = %{agg | load: agg.name}
case add_aggregate(query, agg, relationship.destination) do
case add_aggregate(query, agg, relationship.destination, false) do
{:ok, query} ->
{:cont, {:ok, query}}
@ -1812,7 +1971,7 @@ defmodule AshPostgres.DataLayer do
relationship_destination
_ ->
subquery(clean_subquery_select(relationship_destination))
subquery(relationship_destination)
end
new_query =
@ -1876,6 +2035,68 @@ defmodule AshPostgres.DataLayer do
end
end
defp used_aggregates(filter, relationship, used_calculations, path) do
Ash.Filter.used_aggregates(filter, path ++ [relationship.name]) ++
Enum.flat_map(
used_calculations,
fn calculation ->
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation.context),
%{
resource: relationship.destination,
aggregates: %{},
calculations: %{},
public?: false
}
) do
{:ok, hydrated} ->
Ash.Filter.used_aggregates(hydrated)
_ ->
[]
end
end
)
end
# defp add_calculations_to_destination(
# {:ok, relationship_destination},
# used_calculations,
# relationship
# ) do
# Enum.reduce_while(used_calculations, {:ok, relationship_destination}, fn calculation,
# {:ok, query} ->
# calculation = %{calculation | load: calculation.name}
# calculation_context = calculation.context
# with {:ok, hydrated} <-
# Ash.Filter.hydrate_refs(
# calculation.module.expression(calculation.opts, calculation_context),
# %{
# resource: relationship.destination,
# aggregates: %{},
# calculations: %{},
# public?: false
# }
# ),
# {:ok, relationship_destination} <-
# add_calculation(
# query,
# calculation,
# hydrated,
# relationship.destination
# ) do
# {:cont, {:ok, relationship_destination}}
# else
# {:error, error} ->
# {:halt, {:error, error}}
# end
# end)
# end
# defp add_calculations_to_destination({:error, error}, _, _), do: {:error, error}
defp set_join_prefix(join_query, query, resource) do
if Ash.Resource.Info.multitenancy_strategy(resource) == :context do
%{join_query | prefix: query.prefix}
@ -1884,28 +2105,31 @@ defmodule AshPostgres.DataLayer do
end
end
defp clean_subquery_select(
%{
select:
%Ecto.Query.SelectExpr{
expr:
{:merge, [],
[
_,
select
]}
} = expr
} = query
) do
%{query | select: %{expr | expr: {:merge, [], [{:&, [], [0]}, select]}}}
end
# defp clean_subquery_select(
# %{
# select:
# %Ecto.Query.SelectExpr{
# expr:
# {:merge, [],
# [
# left,
# select
# ]}
# } = expr
# } = query
# ) do
# do_clean(left)
# %{query | select: %{expr | expr: {:merge, [], [{:&, [], [0]}, select]}}}
# end
# defp clean_subquery_select(query), do: query
defp add_filter_expression(query, filter) do
wheres =
filter
|> split_and_statements()
|> Enum.map(fn filter ->
{params, expr} = filter_to_expr(filter, query.__ash_bindings__.bindings, [])
{params, expr} = filter_to_expr(filter, query.__ash_bindings__, [])
%Ecto.Query.BooleanExpr{
expr: expr,
@ -1958,7 +2182,7 @@ defmodule AshPostgres.DataLayer do
do_filter_to_expr(expression, bindings, params, embedded?, type)
end
defp do_filter_to_expr(expr, bindings, params, embedded?, type \\ nil)
defp do_filter_to_expr(expr, bindings, params, embedded? \\ false, type \\ nil)
defp do_filter_to_expr(
%BooleanExpression{op: op, left: left, right: right},
@ -2038,11 +2262,35 @@ defmodule AshPostgres.DataLayer do
embedded?,
_type
) do
arguments =
case arguments do
[{:raw, _} | _] ->
arguments
arguments ->
[{:raw, ""} | arguments]
end
arguments =
case List.last(arguments) do
nil ->
arguments
{:raw, _} ->
arguments
_ ->
arguments ++ [{:raw, ""}]
end
{params, fragment_data} =
Enum.reduce(arguments, {params, []}, fn
{:raw, str}, {params, fragment_data} ->
{params, fragment_data ++ [{:raw, str}]}
{:casted_expr, expr}, {params, fragment_data} ->
{params, fragment_data ++ [{:expr, expr}]}
{:expr, expr}, {params, fragment_data} ->
{params, expr} = do_filter_to_expr(expr, bindings, params, pred_embedded? || embedded?)
{params, fragment_data ++ [{:expr, expr}]}
@ -2131,6 +2379,88 @@ defmodule AshPostgres.DataLayer do
)
end
defp do_filter_to_expr(
%If{arguments: [condition, when_true, when_false], embedded?: pred_embedded?},
bindings,
params,
embedded?,
type
) do
[condition_type, when_true_type, when_false_type] =
determine_types(If, [condition, when_true, when_false])
{params, condition} =
do_filter_to_expr(condition, bindings, params, pred_embedded? || embedded?, condition_type)
{params, when_true} =
do_filter_to_expr(when_true, bindings, params, pred_embedded? || embedded?, when_true_type)
{params, when_false} =
do_filter_to_expr(
when_false,
bindings,
params,
pred_embedded? || embedded?,
when_false_type
)
do_filter_to_expr(
%Fragment{
embedded?: pred_embedded?,
arguments: [
raw: "CASE WHEN ",
casted_expr: condition,
raw: " THEN ",
casted_expr: when_true,
raw: " ELSE ",
casted_expr: when_false,
raw: " END"
]
},
bindings,
params,
embedded?,
type
)
end
defp do_filter_to_expr(
%mod{
__predicate__?: _,
left: left,
right: right,
embedded?: pred_embedded?,
operator: :<>
},
bindings,
params,
embedded?,
type
) do
[left_type, right_type] = determine_types(mod, [left, right])
{params, left_expr} =
do_filter_to_expr(left, bindings, params, pred_embedded? || embedded?, left_type)
{params, right_expr} =
do_filter_to_expr(right, bindings, params, pred_embedded? || embedded?, right_type)
do_filter_to_expr(
%Fragment{
embedded?: pred_embedded?,
arguments: [
casted_expr: left_expr,
raw: " || ",
casted_expr: right_expr
]
},
bindings,
params,
embedded?,
type
)
end
defp do_filter_to_expr(
%mod{
__predicate__?: _,
@ -2160,6 +2490,90 @@ defmodule AshPostgres.DataLayer do
]}}
end
defp do_filter_to_expr(
%Ref{
attribute: %Ash.Query.Calculation{} = calculation,
relationship_path: [],
resource: resource
},
bindings,
params,
embedded?,
type
) do
calculation = %{calculation | load: calculation.name}
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation.context),
%{
resource: resource,
aggregates: %{},
calculations: %{},
public?: false
}
) do
{:ok, expression} ->
do_filter_to_expr(
expression,
bindings,
params,
embedded?,
type
)
{:error, _error} ->
{params, nil}
end
end
defp do_filter_to_expr(
%Ref{
attribute: %Ash.Query.Calculation{} = calculation,
relationship_path: relationship_path
} = ref,
bindings,
params,
embedded?,
type
) do
binding_to_replace =
Enum.find_value(bindings.bindings, fn {i, binding} ->
if binding.path == relationship_path do
i
end
end)
temp_bindings =
bindings.bindings
|> Map.delete(0)
|> Map.update!(binding_to_replace, &Map.merge(&1, %{path: [], type: :root}))
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation.context),
%{
resource: ref.resource,
aggregates: %{},
calculations: %{},
public?: false
}
) do
{:ok, hydrated} ->
hydrated
|> Ash.Filter.update_aggregates(fn aggregate, _ ->
%{aggregate | relationship_path: []}
end)
|> do_filter_to_expr(
%{bindings | bindings: temp_bindings},
params,
embedded?,
type
)
_ ->
{params, nil}
end
end
defp do_filter_to_expr(
%Ref{attribute: %{name: name}} = ref,
bindings,
@ -2180,7 +2594,7 @@ defmodule AshPostgres.DataLayer do
embedded?: embedded?,
arguments: [
raw: "",
expr: string,
casted_expr: string,
raw: "::citext"
]
},
@ -2233,7 +2647,18 @@ defmodule AshPostgres.DataLayer do
end
defp determine_types(mod, values) do
mod.types()
Code.ensure_compiled(mod)
cond do
:erlang.function_exported(mod, :types, 0) ->
mod.types()
:erlang.function_exported(mod, :args, 0) ->
mod.args()
true ->
[:any]
end
|> Enum.map(fn types ->
case types do
:same ->
@ -2332,19 +2757,31 @@ defmodule AshPostgres.DataLayer do
%{attribute: %Ash.Query.Aggregate{} = aggregate, relationship_path: []},
bindings
) do
Enum.find_value(bindings, fn {binding, data} ->
Enum.find_value(bindings.bindings, fn {binding, data} ->
data.path == aggregate.relationship_path && data.type == :aggregate && binding
end) ||
Enum.find_value(bindings.bindings, fn {binding, data} ->
data.path == aggregate.relationship_path && data.type in [:inner, :left, :root] && binding
end)
end
defp ref_binding(
%{attribute: %Ash.Query.Calculation{}} = ref,
bindings
) do
Enum.find_value(bindings.bindings, fn {binding, data} ->
data.path == ref.relationship_path && data.type in [:inner, :left, :root] && binding
end)
end
defp ref_binding(%{attribute: %Ash.Resource.Attribute{}} = ref, bindings) do
Enum.find_value(bindings, fn {binding, data} ->
Enum.find_value(bindings.bindings, fn {binding, data} ->
data.path == ref.relationship_path && data.type in [:inner, :left, :root] && binding
end)
end
defp ref_binding(%{attribute: %Ash.Query.Aggregate{}} = ref, bindings) do
Enum.find_value(bindings, fn {binding, data} ->
Enum.find_value(bindings.bindings, fn {binding, data} ->
data.path == ref.relationship_path && data.type in [:inner, :left, :root] && binding
end)
end
@ -2372,9 +2809,10 @@ defmodule AshPostgres.DataLayer do
repo(resource).rollback(term)
end
defp maybe_get_resource_query(resource, relationship) do
defp maybe_get_resource_query(resource, relationship, root_query) do
resource
|> Ash.Query.new()
|> Map.put(:context, root_query.__ash_bindings__.context)
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.sort(Map.get(relationship, :sort))

View file

@ -95,7 +95,7 @@ defmodule AshPostgres.MixProject do
{:ecto_sql, "~> 3.5"},
{:jason, "~> 1.0"},
{:postgrex, ">= 0.0.0"},
{:ash, ash_version("~> 1.44")},
{:ash, ash_version("~> 1.45.0-rc0")},
{:git_ops, "~> 2.4.2", only: :dev},
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
{:ex_check, "~> 0.11.0", only: :dev},

View file

@ -1,5 +1,5 @@
%{
"ash": {:hex, :ash, "1.44.0", "fa52feb1410cb18f6df64bc4d90c0c2c456a73348069719be5a680c420d7d630", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.5", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:timex, ">= 3.0.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "d9e37e40b46b1073c70a544cd0dea9d93000441f16e97f9973a17391fa932aa8"},
"ash": {:hex, :ash, "1.45.0-rc0", "aa59fea5329fffe1a6624bfce5b9ad87ad7690b5d012700fdd7b610aa5db572f", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.5", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:timex, ">= 3.0.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "ba924085c0312a1501443a81139988ac60ea29fa39eae02106b599600d436b14"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},

View file

@ -0,0 +1,43 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "first_name",
"primary_key?": false,
"references": null,
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "last_name",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "538DC242254A39070CF9A5D032A9336A2270F8C7F07BEAE35F15BF5EB4D90F20",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "authors"
}

View file

@ -0,0 +1,87 @@
{
"attributes": [
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "author_id",
"primary_key?": false,
"references": {
"destination_field": "id",
"destination_field_default": null,
"destination_field_generated": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "comments_author_id_fkey",
"on_delete": null,
"on_update": null,
"table": "authors"
},
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "post_id",
"primary_key?": false,
"references": {
"destination_field": "id",
"destination_field_default": null,
"destination_field_generated": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "special_name_fkey",
"on_delete": "delete",
"on_update": "update",
"table": "posts"
},
"type": "uuid"
},
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "title",
"primary_key?": false,
"references": null,
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "likes",
"primary_key?": false,
"references": null,
"type": "bigint"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "F4CCCB7DA640B4C4E8C543CEE6D1F9C3A724E3F8DBE5AC69C4A175A6085599E0",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "comments"
}

View file

@ -0,0 +1,21 @@
defmodule AshPostgres.TestRepo.Migrations.MigrateResources12 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:authors, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
add :first_name, :text
add :last_name, :text
end
end
def down do
drop table(:authors)
end
end

View file

@ -0,0 +1,22 @@
defmodule AshPostgres.TestRepo.Migrations.MigrateResources13 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:comments) do
add :author_id,
references(:authors, column: :id, name: "comments_author_id_fkey", type: :uuid)
end
end
def down do
alter table(:comments) do
remove :author_id
end
end
end

215
test/calculation_test.exs Normal file
View file

@ -0,0 +1,215 @@
defmodule AshPostgres.CalculationTest do
use AshPostgres.RepoCase, async: false
alias AshPostgres.Test.{Api, Author, Comment, Post}
require Ash.Query
test "an expression calculation can be filtered on" do
post =
Post
|> Ash.Changeset.new(%{title: "match"})
|> Api.create!()
post2 =
Post
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
post3 =
Post
|> Ash.Changeset.new(%{title: "title3"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "_"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "_"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "_"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
post
|> Ash.Changeset.new()
|> Ash.Changeset.replace_relationship(:linked_posts, [post2, post3])
|> Api.update!()
post2
|> Ash.Changeset.new()
|> Ash.Changeset.replace_relationship(:linked_posts, [post3])
|> Api.update!()
assert [%{c_times_p: 6, title: "match"}] =
Post
|> Ash.Query.load(:c_times_p)
|> Api.read!()
|> Enum.filter(&(&1.c_times_p == 6))
Application.put_env(:foo, :bar, true)
assert [
%{c_times_p: %Ash.NotLoaded{}, title: "match"}
] =
Post
|> Ash.Query.filter(c_times_p == 6)
|> Api.read!()
end
test "calculations can be used in related filters" do
post =
Post
|> Ash.Changeset.new(%{title: "match"})
|> Api.create!()
post2 =
Post
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
post3 =
Post
|> Ash.Changeset.new(%{title: "title3"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no_match"})
|> Ash.Changeset.replace_relationship(:post, post2)
|> Api.create!()
post
|> Ash.Changeset.new()
|> Ash.Changeset.replace_relationship(:linked_posts, [post2, post3])
|> Api.update!()
post2
|> Ash.Changeset.new()
|> Ash.Changeset.replace_relationship(:linked_posts, [post3])
|> Api.update!()
posts_query =
Post
|> Ash.Query.load(:c_times_p)
assert %{post: %{c_times_p: 6}} =
Comment
|> Ash.Query.load(post: posts_query)
|> Api.read!()
|> Enum.filter(&(&1.post.c_times_p == 6))
|> Enum.at(0)
query =
Comment
|> Ash.Query.filter(post.c_times_p == 6)
|> Ash.Query.load(post: posts_query)
|> Ash.Query.limit(1)
Application.put_env(:foo, :bar, true)
assert [
%{post: %{c_times_p: 6, title: "match"}}
] = Api.read!(query)
end
test "concat calculation can be filtered on" do
author =
Author
|> Ash.Changeset.new(%{first_name: "is", last_name: "match"})
|> Api.create!()
Author
|> Ash.Changeset.new(%{first_name: "not", last_name: "match"})
|> Api.create!()
author_id = author.id
assert %{id: ^author_id} =
Author
|> Ash.Query.filter(full_name == "is match")
|> Api.read_one!()
end
test "conditional calculations can be filtered on" do
author =
Author
|> Ash.Changeset.new(%{first_name: "tom"})
|> Api.create!()
Author
|> Ash.Changeset.new(%{first_name: "tom", last_name: "holland"})
|> Api.create!()
author_id = author.id
assert %{id: ^author_id} =
Author
|> Ash.Query.filter(conditional_full_name == "(none)")
|> Api.read_one!()
end
test "parameterized calculations can be filtered on" do
Author
|> Ash.Changeset.new(%{first_name: "tom", last_name: "holland"})
|> Api.create!()
assert %{param_full_name: "tom holland"} =
Author
|> Ash.Query.load(:param_full_name)
|> Api.read_one!()
assert %{param_full_name: "tom~holland"} =
Author
|> Ash.Query.load(param_full_name: [separator: "~"])
|> Api.read_one!()
assert %{} =
Author
|> Ash.Query.filter(param_full_name(separator: "~") == "tom~holland")
|> Api.read_one!()
end
test "parameterized related calculations can be filtered on" do
author =
Author
|> Ash.Changeset.new(%{first_name: "tom", last_name: "holland"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "match"})
|> Ash.Changeset.replace_relationship(:author, author)
|> Api.create!()
assert %{title: "match"} =
Comment
|> Ash.Query.filter(author.param_full_name(separator: "~") == "tom~holland")
|> Api.read_one!()
assert %{title: "match"} =
Comment
|> Ash.Query.filter(
author.param_full_name(separator: "~") == "tom~holland" and
author.param_full_name(separator: " ") == "tom holland"
)
|> Api.read_one!()
end
end

View file

@ -8,5 +8,6 @@ defmodule AshPostgres.Test.Api do
resource(AshPostgres.Test.IntegerPost)
resource(AshPostgres.Test.Rating)
resource(AshPostgres.Test.PostLink)
resource(AshPostgres.Test.Author)
end
end

35
test/support/concat.ex Normal file
View file

@ -0,0 +1,35 @@
defmodule AshPostgres.Test.Concat do
@moduledoc false
use Ash.Calculation
require Ash.Query
def init(opts) do
if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do
{:ok, opts}
else
{:error, "Expected a `keys` option for which keys to concat"}
end
end
def expression(opts, %{separator: separator}) do
Enum.reduce(opts[:keys], nil, fn key, expr ->
if expr do
if separator do
Ash.Query.expr(^expr <> ^separator <> ref(^key))
else
Ash.Query.expr(^expr <> ref(^key))
end
else
Ash.Query.expr(ref(^key))
end
end)
end
def calculate(records, opts, %{separator: separator}) do
Enum.map(records, fn record ->
Enum.map_join(opts[:keys], separator, fn key ->
to_string(Map.get(record, key))
end)
end)
end
end

View file

@ -0,0 +1,38 @@
defmodule AshPostgres.Test.Author do
@moduledoc false
use Ash.Resource,
data_layer: AshPostgres.DataLayer
postgres do
table("authors")
repo(AshPostgres.TestRepo)
end
attributes do
uuid_primary_key(:id, writable?: true)
attribute(:first_name, :string)
attribute(:last_name, :string)
end
calculations do
calculate(:full_name, :string, expr(first_name <> " " <> last_name))
calculate(
:conditional_full_name,
:string,
expr(
if(
is_nil(first_name) or is_nil(last_name),
"(none)",
first_name <> " " <> last_name
)
)
)
calculate :param_full_name,
:string,
{AshPostgres.Test.Concat, keys: [:first_name, :last_name]} do
argument(:separator, :string, default: " ", constraints: [allow_empty?: true, trim?: false])
end
end
end

View file

@ -30,6 +30,7 @@ defmodule AshPostgres.Test.Comment do
relationships do
belongs_to(:post, AshPostgres.Test.Post)
belongs_to(:author, AshPostgres.Test.Author)
has_many(:ratings, AshPostgres.Test.Rating,
destination_field: :resource_id,

View file

@ -69,6 +69,12 @@ defmodule AshPostgres.Test.Post do
)
end
calculations do
calculate(:c_times_p, :integer, expr(count_of_comments * count_of_linked_posts),
load: [:count_of_comments, :count_of_linked_posts]
)
end
aggregates do
count(:count_of_comments, :comments)
count(:count_of_linked_posts, :linked_posts)

View file

@ -1,3 +1,4 @@
ExUnit.start()
ExUnit.configure(stacktrace_depth: 100)
AshPostgres.TestRepo.start_link()