diff --git a/lib/ash/expr/expr.ex b/lib/ash/expr/expr.ex index d22bb3e9a..14c4744f6 100644 --- a/lib/ash/expr/expr.ex +++ b/lib/ash/expr/expr.ex @@ -64,6 +64,7 @@ defmodule Ash.Expr do is_struct(value, Ash.Query.Exists) or is_struct(value, Ash.Query.Parent) or is_struct(value, Ash.Query.UpsertConflict) or + is_struct(value, Ash.Query.EmbeddedArrayElementField) or is_struct(value, Ash.CustomExpression) or (is_struct(value) and is_map_key(value, :__predicate__?)) do true diff --git a/lib/ash/filter/filter.ex b/lib/ash/filter/filter.ex index 071d506b4..8502011b8 100644 --- a/lib/ash/filter/filter.ex +++ b/lib/ash/filter/filter.ex @@ -1543,6 +1543,31 @@ defmodule Ash.Filter do add_ref_to_relevant_paths({rest, ref}, new_acc, new_trail) end + @doc false + # Rewrites references inside an `exists/2` predicate so that fields of the + # embedded resource currently being iterated are represented as + # `Ash.Query.EmbeddedArrayElementField` markers. SQL data layers translate + # these markers into per-element JSONB extractions (e.g., + # `jsonb_extract_path_text(elem, 'field')::type`). + # + # Only refs whose `relationship_path` is empty and whose attribute belongs + # to `embedded_resource` are rewritten — outer-scope refs (e.g., via + # `parent/1`) are left intact. + def rewrite_for_embedded_array_scope(expr, embedded_resource) when is_atom(embedded_resource) do + map(expr, fn + %Ref{relationship_path: [], attribute: %Ash.Resource.Attribute{} = attr, resource: resource} + when resource == embedded_resource -> + %Ash.Query.EmbeddedArrayElementField{ + field: attr.name, + type: attr.type, + constraints: attr.constraints || [] + } + + other -> + other + end) + end + def map(%__MODULE__{expression: nil} = filter, _) do filter end @@ -2163,8 +2188,19 @@ defmodule Ash.Filter do defp shortest_path_to_changed_data_layer(_resource, [], _acc), do: :error defp shortest_path_to_changed_data_layer(resource, [relationship | rest], acc) do - relationship = Ash.Resource.Info.relationship(resource, relationship) + case Ash.Resource.Info.relationship(resource, relationship) do + nil -> + # Segment is not a relationship (e.g., an embedded array attribute). + # Embedded arrays live in the same data layer as the parent, so they + # never change data layers; signal "no split needed." + :error + relationship -> + shortest_path_to_changed_data_layer_for_relationship(relationship, rest, acc, resource) + end + end + + defp shortest_path_to_changed_data_layer_for_relationship(relationship, rest, acc, resource) do if relationship.type == :many_to_many do if Ash.DataLayer.data_layer_can?(resource, {:join, relationship.through}) do shortest_path_to_changed_data_layer(relationship.destination, rest, [ @@ -2909,6 +2945,42 @@ defmodule Ash.Filter do end end + # Like `related/2`, but a path segment may also be an attribute whose type is + # `{:array, EmbeddedResource}` (with `EmbeddedResource` declared as + # `use Ash.Resource, data_layer: :embedded`). In that case the next segment is + # resolved against the embedded resource. Used to power `exists/2` over + # embedded array attributes. + # + # Returns the destination resource, or `nil` if no segment matched. + defp related_or_embedded(context, segments) when not is_list(segments) do + related_or_embedded(context, [segments]) + end + + defp related_or_embedded(context, []), do: context.resource + + defp related_or_embedded(context, [segment | rest]) do + case relationship(context, segment) do + %{destination: destination} -> + related_or_embedded(%{context | resource: destination}, rest) + + nil -> + case embedded_array_attribute(context.resource, segment) do + nil -> nil + embedded_resource -> related_or_embedded(%{context | resource: embedded_resource}, rest) + end + end + end + + defp embedded_array_attribute(resource, segment) do + with %{type: {:array, item_type}} <- Ash.Resource.Info.attribute(resource, segment), + true <- is_atom(item_type), + true <- Ash.Resource.Info.embedded?(item_type) do + item_type + else + _ -> nil + end + end + defp parse_expression(%__MODULE__{expression: expression}, context), do: {:ok, move_to_relationship_path(expression, context[:relationship_path] || [])} @@ -3036,7 +3108,7 @@ defmodule Ash.Filter do related = if related? do - related(context, at_path ++ path) + related_or_embedded(context, at_path ++ path) else resource end @@ -4175,7 +4247,7 @@ defmodule Ash.Filter do when not is_nil(resource) do case Ash.Resource.Info.related(resource, relationship_path || []) do nil -> - {:error, "Invalid reference #{inspect(ref)}"} + {:error, invalid_reference_error(ref, resource, relationship_path || [])} related -> do_hydrate_refs( @@ -4206,8 +4278,7 @@ defmodule Ash.Filter do case related(context, ref.relationship_path) do nil -> - {:error, - "Invalid reference #{inspect(ref)} at relationship_path #{inspect(ref.relationship_path)}"} + {:error, invalid_reference_error(ref, context.resource, ref.relationship_path)} related -> context = %{context | resource: related} @@ -4624,10 +4695,27 @@ defmodule Ash.Filter do Ash.Resource.Info.related(context[:resource], expanded_at_path) end - expanded_path = expand_through_path_names(at_path_resource, path) + # `path` may start with an embedded-array attribute (Phase 1+). Only + # apply relationship `through`-expansion when the path is purely + # relationships; otherwise leave it as-is and resolve via the + # embedded-aware walker. + {expanded_path, new_resource} = + case unscoped_related_or_embedded(at_path_resource, path) do + nil -> + {path, nil} + + resource -> + # If `path` is pure-relationship, normalize via through-expansion; + # otherwise keep as-is (embedded-array segments don't go through joins). + expanded_path = + try do + expand_through_path_names(at_path_resource, path) + rescue + Ash.Error.Query.NoSuchRelationship -> path + end - new_resource = - Ash.Resource.Info.related(context[:resource], expanded_at_path ++ expanded_path) + {expanded_path, resource} + end if new_resource do context = %{ @@ -4701,6 +4789,48 @@ defmodule Ash.Filter do {:ok, val} end + # Build a Ref-resolution error message. When a non-relationship path segment + # is an embedded array attribute, point the user at `exists/2` rather than + # leaving them with the generic "Invalid reference" string. + defp invalid_reference_error(ref, resource, relationship_path) do + embedded_array_segment = + Enum.find(relationship_path, fn segment -> + embedded_array_attribute(resource, segment) != nil + end) + + if embedded_array_segment do + "Cannot reference fields through an embedded array attribute directly. " <> + "Use `exists/2` to filter over `#{embedded_array_segment}`, e.g. " <> + "`exists(#{embedded_array_segment}, ...)`. " <> + "Got: #{inspect(ref)}" + else + "Invalid reference #{inspect(ref)}" + end + end + + # Variant of `related/2` for `do_hydrate_refs`, which previously used + # `Ash.Resource.Info.related/2` and therefore did not filter by `public?`. + # Recognizes `{:array, EmbeddedResource}` attributes in addition to + # relationships. + defp unscoped_related_or_embedded(resource, segments) when not is_list(segments) do + unscoped_related_or_embedded(resource, [segments]) + end + + defp unscoped_related_or_embedded(resource, []), do: resource + + defp unscoped_related_or_embedded(resource, [segment | rest]) do + case Ash.Resource.Info.relationship(resource, segment) do + %{destination: destination} -> + unscoped_related_or_embedded(destination, rest) + + nil -> + case embedded_array_attribute(resource, segment) do + nil -> nil + embedded_resource -> unscoped_related_or_embedded(embedded_resource, rest) + end + end + end + defp combination_calc(first_combination, attribute) do with {:ok, calc} <- Map.fetch(first_combination.calculations, attribute) do case calc do diff --git a/lib/ash/query/embedded_array_element_field.ex b/lib/ash/query/embedded_array_element_field.ex new file mode 100644 index 000000000..ac19e7cf6 --- /dev/null +++ b/lib/ash/query/embedded_array_element_field.ex @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Query.EmbeddedArrayElementField do + @moduledoc """ + An internal expression node representing a reference to a field of the + *current element* being iterated inside an `exists/2` over an embedded + array attribute. + + Not constructed by users. Produced by `Ash.Filter` as part of the + AST-rewrite step that prepares an `Ash.Query.Exists` whose first path + segment is an embedded-array attribute, so that downstream compilers + (e.g. AshPostgres) can translate the field reference into a + `jsonb_extract_path_text(elem.value, 'field')::type` style expression. + """ + + defstruct [:field, :type, :constraints] + + @type t :: %__MODULE__{ + field: atom(), + type: Ash.Type.t() | nil, + constraints: keyword() + } + + defimpl Inspect do + import Inspect.Algebra + + def inspect(%{field: field}, _opts) do + concat(["#elem.", to_string(field)]) + end + end +end diff --git a/lib/ash/query/function/exists.ex b/lib/ash/query/function/exists.ex index 63f6c1537..d006769a2 100644 --- a/lib/ash/query/function/exists.ex +++ b/lib/ash/query/function/exists.ex @@ -5,6 +5,41 @@ defmodule Ash.Query.Exists do @moduledoc """ Determines if a given related entity exists. + + ## Path forms + + The `path` argument is a dotted chain of segments. Each segment may be: + + * a relationship — the usual case (`exists(comments, ...)`, + `exists(posts.comments, ...)`). + * an attribute whose type is `{:array, EmbeddedResource}`, where + `EmbeddedResource` is declared with `use Ash.Resource, data_layer: :embedded`. + Each element of the array is iterated and the predicate is evaluated + against it. Mixed paths (`exists(invoices.options, ...)`) compose + naturally — the relationship prefix is joined first, the embedded + array attribute is unnested afterwards. + + ## `parent/1` semantics + + Each `exists/2` call pushes the *calling* scope onto the parent stack + once, regardless of how many segments are in `path`. Inside the + predicate, `parent/1` therefore always refers to the resource the + `exists/2` call was made from, not to any intermediate scope along + the path. + + > Note: `exists(a, exists(b, ...))` is auto-flattened by + > `Ash.Query.Exists.new/3` into `exists(a.b, ...)`, so explicit + > nesting does **not** create separate intermediate scopes for + > `parent/1` to reach. + + ## Data layer support for embedded arrays + + The embedded-array form requires the data layer to declare the + `{:exists, :embedded_array}` capability. AshPostgres supports it + (lowering to `jsonb_array_elements`). Other SQL data layers and any + in-memory layer with a generic record walker (e.g. `Ash.DataLayer.Ets`) + work as well. If the data layer does not support this form, a clear + error is raised at query build time. """ defstruct [:path, :expr, :resource, at_path: [], related?: true, input?: false] diff --git a/test/embedded_array/edge_cases_test.exs b/test/embedded_array/edge_cases_test.exs new file mode 100644 index 000000000..01d42ab87 --- /dev/null +++ b/test/embedded_array/edge_cases_test.exs @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.EdgeCasesTest do + @moduledoc """ + Phase 5 — edge cases for `exists/2` over embedded array attributes. + + Covers in-memory (ETS) behavior. The AshPostgres equivalents (where + semantics may differ for nil/empty arrays due to SQL NULL handling) + live in `ash_postgres/test/embedded_array_exists_test.exs`. + + See `specs/embedded-array-exists.md`. + """ + use ExUnit.Case, async: true + + require Ash.Query + + alias Ash.Test.EmbeddedArray.Estimate + + describe "empty / nil arrays" do + setup do + [ + empty: + Estimate + |> Ash.Changeset.for_create(:create, %{title: "empty", options: []}) + |> Ash.create!(), + nil_options: + Estimate + |> Ash.Changeset.for_create(:create, %{title: "nil_options"}) + |> Ash.create!(), + populated: + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "populated", + options: [%{name: "x", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + ] + end + + test "exists/2 returns false for empty array", %{empty: empty} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + refute empty.id in results + end + + test "exists/2 returns false for nil array", %{nil_options: nil_opts} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + refute nil_opts.id in results + end + + test "not exists/2 returns true for empty and nil arrays", %{ + empty: empty, + nil_options: nil_opts, + populated: populated + } do + results = + Estimate + |> Ash.Query.filter(not exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert empty.id in results + assert nil_opts.id in results + refute populated.id in results + end + end + + describe "multiple references to the same field in one predicate" do + setup do + [ + single_match: + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "single_match", + options: [%{name: "matchy", total_amt: Decimal.new("50")}] + }) + |> Ash.create!() + ] + end + + test "same attribute referenced twice in the same predicate", %{ + single_match: single_match + } do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0 and total_amt < 1000)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert single_match.id in results + end + end + +end diff --git a/test/embedded_array/mixed_path_test.exs b/test/embedded_array/mixed_path_test.exs new file mode 100644 index 000000000..0e060ec6d --- /dev/null +++ b/test/embedded_array/mixed_path_test.exs @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.MixedPathTest do + @moduledoc """ + Phase 4 — mixed `exists/2` paths that traverse relationships *and* + embedded-array attributes in the same expression. + + Covers in-memory (ETS) behavior. AshPostgres equivalents live in + `ash_postgres/test/embedded_array_exists_test.exs`. + + See `specs/embedded-array-exists.md`. + """ + use ExUnit.Case, async: true + + require Ash.Query + + alias Ash.Test.EmbeddedArray.Company + alias Ash.Test.EmbeddedArray.Estimate + + setup do + cheap_company = create_company("CheapCo") + pricey_company = create_company("PriceyCo") + + create_estimate(%{ + title: "cheap", + company_id: cheap_company.id, + options: [%{name: "basic", total_amt: Decimal.new("50")}] + }) + + create_estimate(%{ + title: "expensive", + company_id: pricey_company.id, + options: [%{name: "premium", total_amt: Decimal.new("150")}] + }) + + %{cheap_company: cheap_company, pricey_company: pricey_company} + end + + describe "exists/2 over mixed (relationship → embedded array) paths" do + test "exists(estimates.options, total_amt > 100) on Company", %{ + cheap_company: cheap, + pricey_company: pricey + } do + results = + Company + |> Ash.Query.filter(exists(estimates.options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert pricey.id in results + refute cheap.id in results + end + + test "string equality through mixed path", %{cheap_company: cheap, pricey_company: pricey} do + results = + Company + |> Ash.Query.filter(exists(estimates.options, name == "basic")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + refute pricey.id in results + end + + test "parent(...) reaches the calling Company scope", %{pricey_company: pricey} do + results = + Company + |> Ash.Query.filter( + exists(estimates.options, total_amt > 100 and parent(name) == "PriceyCo") + ) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert pricey.id in results + end + end + + defp create_company(name) do + Company + |> Ash.Changeset.for_create(:create, %{name: name}) + |> Ash.create!() + end + + defp create_estimate(attrs) do + Estimate + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() + end +end diff --git a/test/embedded_array/nested_test.exs b/test/embedded_array/nested_test.exs new file mode 100644 index 000000000..11658b549 --- /dev/null +++ b/test/embedded_array/nested_test.exs @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.NestedTest do + @moduledoc """ + Phase 3 — nested `exists/2` over `{:array, EmbeddedResource}` attributes and + `parent/1` scoping. + + Covers in-memory (ETS) behavior. AshPostgres equivalents live in + `ash_postgres/test/embedded_array_exists_test.exs`. + + See `specs/embedded-array-exists.md`. + """ + use ExUnit.Case, async: true + + require Ash.Query + + alias Ash.Test.EmbeddedArray.Estimate + + setup do + [ + with_tea: + create(%{ + title: "drinks", + options: [ + %{ + name: "tea-bundle", + total_amt: Decimal.new("30"), + line_items: [ + %{name: "tea", quantity: 1, unit_price: Decimal.new("3")}, + %{name: "biscuit", quantity: 2, unit_price: Decimal.new("2")} + ] + } + ] + }), + with_coffee: + create(%{ + title: "drinks", + options: [ + %{ + name: "coffee-bundle", + total_amt: Decimal.new("80"), + line_items: [ + %{name: "coffee", quantity: 1, unit_price: Decimal.new("4")}, + %{name: "muffin", quantity: 1, unit_price: Decimal.new("5")} + ] + } + ] + }), + with_matching_names: + create(%{ + title: "twins", + options: [ + %{ + name: "matchy", + total_amt: Decimal.new("10"), + # innermost name == outer option name + line_items: [%{name: "matchy", quantity: 1, unit_price: Decimal.new("1")}] + } + ] + }) + ] + end + + describe "nested exists/2 over embedded arrays" do + test "exists(options.line_items, name == \"tea\")", %{ + with_tea: with_tea, + with_coffee: with_coffee + } do + results = + Estimate + |> Ash.Query.filter(exists(options.line_items, name == "tea")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert with_tea.id in results + refute with_coffee.id in results + end + + test "predicate combines fields of innermost element", %{with_coffee: with_coffee} do + results = + Estimate + |> Ash.Query.filter(exists(options.line_items, name == "muffin" and quantity == 1)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert with_coffee.id in results + end + + test "explicit nested exists composes: exists(options, exists(line_items, ...))", %{ + with_tea: with_tea, + with_coffee: with_coffee + } do + results = + Estimate + |> Ash.Query.filter(exists(options, exists(line_items, name == "biscuit"))) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert with_tea.id in results + refute with_coffee.id in results + end + end + + describe "parent/1 refers to the calling scope of the `exists/2` call" do + # Matches existing Ash convention: `exists/2` pushes the *calling* scope + # onto the parent stack once, regardless of path length. Note that + # `exists(a, exists(b, ...))` is auto-flattened by `Ash.Query.Exists.new/3` + # into `exists(a.b, ...)`, so there is no separate intermediate scope to + # reach via additional `parent/1` calls — `parent` always lands at the + # outermost calling resource. + + test "dotted form: parent(...) reaches the calling resource (Estimate)", + %{with_tea: tea, with_coffee: coffee} do + results = + Estimate + |> Ash.Query.filter( + exists(options.line_items, name == "tea" and parent(title) == "drinks") + ) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert tea.id in results + refute coffee.id in results + end + + test "explicit nested form auto-flattens; parent still reaches Estimate", %{with_tea: tea} do + # Behaviorally equivalent to the dotted form thanks to Exists flattening. + results = + Estimate + |> Ash.Query.filter( + exists(options, exists(line_items, name == "tea" and parent(title) == "drinks")) + ) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert tea.id in results + end + + # Mirrors the relationship-exists pattern from + # `test/filter/parent_test.exs:164` — exercising that `parent/1` can + # traverse a `belongs_to` relationship from the calling resource and + # access an attribute on the related row, even when the exists target + # is an embedded array. + test "parent can refer to a belongs_to relationship's field" do + acme = + Ash.Test.EmbeddedArray.Company + |> Ash.Changeset.for_create(:create, %{name: "acme"}) + |> Ash.create!() + + other = + Ash.Test.EmbeddedArray.Company + |> Ash.Changeset.for_create(:create, %{name: "other"}) + |> Ash.create!() + + matching = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "matchy", + company_id: acme.id, + options: [%{name: "acme", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + _non_matching_co_name = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "no-match-1", + company_id: other.id, + options: [%{name: "acme", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + _non_matching_option_name = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "no-match-2", + company_id: acme.id, + options: [%{name: "something-else", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + results = + Estimate + |> Ash.Query.filter(exists(options, name == parent(company.name))) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert matching.id in results + assert length(results) == 1 + end + end + + defp create(attrs) do + Estimate + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() + end +end diff --git a/test/embedded_array/single_segment_test.exs b/test/embedded_array/single_segment_test.exs new file mode 100644 index 000000000..7fc7d255d --- /dev/null +++ b/test/embedded_array/single_segment_test.exs @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.SingleSegmentTest do + @moduledoc """ + Phase 1 — single-segment `exists/2` over `{:array, EmbeddedResource}` attribute. + + Verifies in-memory (ETS) filtering, the auto-any shorthand rejection, and + that paths leading nowhere still produce a usable error. + + See `specs/embedded-array-exists.md`. + """ + use ExUnit.Case, async: true + + require Ash.Query + + alias Ash.Test.EmbeddedArray.Estimate + + setup do + [ + cheap: + create(%{ + title: "cheap", + options: [%{name: "basic", total_amt: Decimal.new("50")}] + }), + expensive: + create(%{ + title: "expensive", + options: [%{name: "premium", total_amt: Decimal.new("150")}] + }), + mixed: + create(%{ + title: "mixed", + options: [ + %{name: "a", total_amt: Decimal.new("10")}, + %{name: "b", total_amt: Decimal.new("200")} + ] + }), + empty: create(%{title: "empty", options: []}) + ] + end + + describe "exists/2 over embedded array, single segment" do + test "filters in-memory", %{cheap: cheap, expensive: expensive, mixed: mixed, empty: empty} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + assert mixed.id in results + refute cheap.id in results + refute empty.id in results + end + + test "supports equality on string fields", %{cheap: cheap} do + results = + Estimate + |> Ash.Query.filter(exists(options, name == "basic")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + end + + test "combines with outer predicates", %{expensive: expensive, mixed: mixed} do + results = + Estimate + |> Ash.Query.filter(active == true and exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + assert mixed.id in results + end + + test "negation: not exists", %{cheap: cheap, empty: empty} do + results = + Estimate + |> Ash.Query.filter(not exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + assert empty.id in results + end + + test "empty array does not satisfy exists", %{empty: empty} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + refute empty.id in results + end + + test "interpolation via ^ works", %{expensive: expensive, mixed: mixed} do + threshold = Decimal.new("100") + + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > ^threshold)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + assert mixed.id in results + end + end + + describe "auto-any shorthand is rejected" do + test "options.total_amt > 100 returns a helpful error pointing to exists/2" do + err = + assert_raise Ash.Error.Unknown, fn -> + Estimate + |> Ash.Query.filter(options.total_amt > 100) + |> Ash.read!() + end + + message = Exception.message(err) + assert message =~ "embedded array" + assert message =~ "exists(" + assert message =~ "options" + end + end + + defp create(attrs) do + Estimate + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() + end +end diff --git a/test/support/embedded_array/company.ex b/test/support/embedded_array/company.ex new file mode 100644 index 000000000..a635b7db6 --- /dev/null +++ b/test/support/embedded_array/company.ex @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.Company do + @moduledoc false + use Ash.Resource, + domain: Ash.Test.EmbeddedArray.Domain, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + attributes do + uuid_primary_key :id, writable?: true + attribute :name, :string, public?: true + end + + relationships do + has_many :estimates, Ash.Test.EmbeddedArray.Estimate do + public? true + destination_attribute :company_id + end + end +end diff --git a/test/support/embedded_array/domain.ex b/test/support/embedded_array/domain.ex new file mode 100644 index 000000000..d01c4d925 --- /dev/null +++ b/test/support/embedded_array/domain.ex @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.Domain do + @moduledoc false + use Ash.Domain + + resources do + resource Ash.Test.EmbeddedArray.Company + resource Ash.Test.EmbeddedArray.Estimate + end +end diff --git a/test/support/embedded_array/estimate.ex b/test/support/embedded_array/estimate.ex new file mode 100644 index 000000000..fddad9ba7 --- /dev/null +++ b/test/support/embedded_array/estimate.ex @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.Estimate do + @moduledoc false + use Ash.Resource, + domain: Ash.Test.EmbeddedArray.Domain, + data_layer: Ash.DataLayer.Ets + + alias Ash.Test.EmbeddedArray.Option + + ets do + private?(true) + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + attributes do + uuid_primary_key :id, writable?: true + attribute :title, :string, public?: true + attribute :active, :boolean, public?: true, default: true + attribute :options, {:array, Option}, public?: true, default: [] + attribute :company_id, :uuid, public?: true + end + + relationships do + belongs_to :company, Ash.Test.EmbeddedArray.Company do + public? true + end + end +end diff --git a/test/support/embedded_array/line_item.ex b/test/support/embedded_array/line_item.ex new file mode 100644 index 000000000..11aec355c --- /dev/null +++ b/test/support/embedded_array/line_item.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.LineItem do + @moduledoc false + use Ash.Resource, data_layer: :embedded + + attributes do + attribute :name, :string, public?: true + attribute :quantity, :integer, public?: true, default: 1 + attribute :unit_price, :decimal, public?: true + end +end diff --git a/test/support/embedded_array/option.ex b/test/support/embedded_array/option.ex new file mode 100644 index 000000000..ad662c86c --- /dev/null +++ b/test/support/embedded_array/option.ex @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Test.EmbeddedArray.Option do + @moduledoc false + use Ash.Resource, data_layer: :embedded + + alias Ash.Test.EmbeddedArray.LineItem + + attributes do + attribute :name, :string, public?: true + attribute :total_amt, :decimal, public?: true + attribute :line_items, {:array, LineItem}, public?: true, default: [] + end +end