From dae39f5fda21c33808a27d550603c4b3c3083a51 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Fri, 4 Jun 2021 01:48:35 -0400 Subject: [PATCH] feat: support expression based calculations feat: support concat + if expressions improvement: various other improvements --- .credo.exs | 2 +- lib/data_layer.ex | 548 ++++++++++++++++-- mix.exs | 2 +- mix.lock | 2 +- .../test_repo/authors/20210602145604.json | 43 ++ .../test_repo/comments/20210603213926.json | 87 +++ .../20210602145604_migrate_resources12.exs | 21 + .../20210603213926_migrate_resources13.exs | 22 + test/calculation_test.exs | 215 +++++++ test/support/api.ex | 1 + test/support/concat.ex | 35 ++ test/support/resources/author.ex | 38 ++ test/support/resources/comment.ex | 1 + test/support/resources/post.ex | 6 + test/test_helper.exs | 1 + 15 files changed, 966 insertions(+), 58 deletions(-) create mode 100644 priv/resource_snapshots/test_repo/authors/20210602145604.json create mode 100644 priv/resource_snapshots/test_repo/comments/20210603213926.json create mode 100644 priv/test_repo/migrations/20210602145604_migrate_resources12.exs create mode 100644 priv/test_repo/migrations/20210603213926_migrate_resources13.exs create mode 100644 test/calculation_test.exs create mode 100644 test/support/concat.ex create mode 100644 test/support/resources/author.ex diff --git a/.credo.exs b/.credo.exs index c0ee316..bc339b9 100644 --- a/.credo.exs +++ b/.credo.exs @@ -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, []}, diff --git a/lib/data_layer.ex b/lib/data_layer.ex index c88beb1..4795eee 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -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)) diff --git a/mix.exs b/mix.exs index 665028c..7b8beb2 100644 --- a/mix.exs +++ b/mix.exs @@ -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}, diff --git a/mix.lock b/mix.lock index 9c8c888..921bb30 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/priv/resource_snapshots/test_repo/authors/20210602145604.json b/priv/resource_snapshots/test_repo/authors/20210602145604.json new file mode 100644 index 0000000..8da2cf1 --- /dev/null +++ b/priv/resource_snapshots/test_repo/authors/20210602145604.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/comments/20210603213926.json b/priv/resource_snapshots/test_repo/comments/20210603213926.json new file mode 100644 index 0000000..40374da --- /dev/null +++ b/priv/resource_snapshots/test_repo/comments/20210603213926.json @@ -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" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20210602145604_migrate_resources12.exs b/priv/test_repo/migrations/20210602145604_migrate_resources12.exs new file mode 100644 index 0000000..1c78acd --- /dev/null +++ b/priv/test_repo/migrations/20210602145604_migrate_resources12.exs @@ -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 \ No newline at end of file diff --git a/priv/test_repo/migrations/20210603213926_migrate_resources13.exs b/priv/test_repo/migrations/20210603213926_migrate_resources13.exs new file mode 100644 index 0000000..2a4fa40 --- /dev/null +++ b/priv/test_repo/migrations/20210603213926_migrate_resources13.exs @@ -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 \ No newline at end of file diff --git a/test/calculation_test.exs b/test/calculation_test.exs new file mode 100644 index 0000000..80faae4 --- /dev/null +++ b/test/calculation_test.exs @@ -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 diff --git a/test/support/api.ex b/test/support/api.ex index 32d00c4..5717917 100644 --- a/test/support/api.ex +++ b/test/support/api.ex @@ -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 diff --git a/test/support/concat.ex b/test/support/concat.ex new file mode 100644 index 0000000..af107ad --- /dev/null +++ b/test/support/concat.ex @@ -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 diff --git a/test/support/resources/author.ex b/test/support/resources/author.ex new file mode 100644 index 0000000..7e0e6db --- /dev/null +++ b/test/support/resources/author.ex @@ -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 diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex index 3e392c5..d93a6fd 100644 --- a/test/support/resources/comment.ex +++ b/test/support/resources/comment.ex @@ -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, diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 138dea7..994b900 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -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) diff --git a/test/test_helper.exs b/test/test_helper.exs index 8db3eb9..4687ee7 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,4 @@ ExUnit.start() +ExUnit.configure(stacktrace_depth: 100) AshPostgres.TestRepo.start_link()