From 2628bf29609184e1786dc865a85b56697711dbb8 Mon Sep 17 00:00:00 2001 From: bdebinska Date: Thu, 27 Nov 2025 09:02:55 +0100 Subject: [PATCH 1/4] WIP: enable hooking into build_assoc functions --- lib/predicate_converter.ex | 48 +++++++++++++++++------------ test/predicates_test.exs | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 19 deletions(-) diff --git a/lib/predicate_converter.ex b/lib/predicate_converter.ex index 6905357..2f9807a 100644 --- a/lib/predicate_converter.ex +++ b/lib/predicate_converter.ex @@ -451,28 +451,38 @@ defmodule Predicates.PredicateConverter do defp convert_any({:assoc, field}, sub_predicate, queryable, meta) do schema = get_schema(queryable) - sub_association = get_association_field(schema, field) - - if is_nil(sub_association) do - raise PredicateError, - message: "Field '#{field}' in schema #{inspect(schema)} is not an association" - else - parent_table_name = get_table_name(sub_association.owner) - - sub_schema = get_schema(sub_association) + subquery = + try do + subquery = safe_call({schema, :build_assoc}, [field, meta], 1) - # define the subquery to execute the given predicates against the defined association - subquery = - from(s in sub_schema, - select: 1, - where: - field(s, ^sub_association.related_key) == - field(parent_as(^parent_table_name), ^sub_association.owner_key) + subquery + |> where( + ^convert_query(subquery, sub_predicate, Map.put(meta, :__nested_virtual_json__, true)) ) - |> build_sub_query(sub_predicate, meta) + rescue + [FunctionClauseError, UndefinedFunctionError] -> + sub_association = get_association_field(schema, field) + + if is_nil(sub_association) do + raise PredicateError, + message: "Field '#{field}' in schema #{inspect(schema)} is not an association" + else + parent_table_name = get_table_name(sub_association.owner) + + sub_schema = get_schema(sub_association) + + # define the subquery to execute the given predicates against the defined association + from(s in sub_schema, + select: 1, + where: + field(s, ^sub_association.related_key) == + field(parent_as(^parent_table_name), ^sub_association.owner_key) + ) + |> build_sub_query(sub_predicate, meta) + end + end - dynamic(exists(subquery)) - end + dynamic(exists(subquery)) end defp convert_any({:single, field}, sub_predicate, queryable, meta) do diff --git a/test/predicates_test.exs b/test/predicates_test.exs index ff724c2..07f950b 100644 --- a/test/predicates_test.exs +++ b/test/predicates_test.exs @@ -6,6 +6,29 @@ defmodule PredicatesTest do alias Predicates.PredicateConverter, as: Converter alias Predicates.PredicateError + defmodule Prizes do + @moduledoc false + use Ecto.Schema + + schema "pred_prizes" do + field :title, :string + field :year, :integer + + belongs_to :author, PredicatesTest.Author + end + + def migrate do + """ + CREATE TABLE #{:pred_prizes} ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title text, + year int, + author_id int REFERENCES pred_authors(id) ON DELETE CASCADE ON UPDATE CASCADE + ) + """ + end + end + defmodule Post do @moduledoc false use Ecto.Schema @@ -62,6 +85,7 @@ defmodule PredicatesTest do field :total_tags, {:array, :map}, virtual: true has_many :posts, Post + has_many :prizes, Prizes end def migrate do @@ -149,14 +173,27 @@ defmodule PredicatesTest do } ) ) + + def build_assoc(:prizes, meta), + do: + subquery( + from(p in Prizes, + where: p.author_id == parent_as(:pred_authors).id, + select: %{ + __element__: fragment("jsonb_build_object('name', ?, 'year', ?)", p.title, p.year) + } + ) + ) end alias __MODULE__.Author alias __MODULE__.Post + alias __MODULE__.Prizes def create_tables(_) do Predicates.Repo.query!(Author.migrate(), []) Predicates.Repo.query!(Post.migrate(), []) + Predicates.Repo.query!(Prizes.migrate(), []) :ok end @@ -886,6 +923,32 @@ defmodule PredicatesTest do ) |> Predicates.Repo.all() end + + test "association with a build_assoc hook" do + {2, [hauptmann, _]} = + Predicates.Repo.insert_all(Author, [%{name: "Hauptmann"}, %{name: "Schiller"}], + returning: true + ) + + Predicates.Repo.insert_all(Prizes, [ + %{title: "Nobel Prize", year: 1912, author_id: hauptmann.id} + ]) + + assert [hauptmann] = + Converter.build_query( + Author, + %{ + "op" => "any", + "path" => "prizes", + "arg" => %{ + "op" => "eq", + "path" => "name", + "arg" => "Nobel Prize" + } + } + ) + |> Predicates.Repo.all() + end end describe "conjunctions" do From 00653f857eb1ae76448360f9071969ccda86b391 Mon Sep 17 00:00:00 2001 From: bdebinska Date: Thu, 27 Nov 2025 09:32:56 +0100 Subject: [PATCH 2/4] PD-3667: enable hooking into build_assoc functions Co-authored-by: Stefan Fochler --- lib/predicate_converter.ex | 9 ++------- test/predicates_test.exs | 12 ++++-------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lib/predicate_converter.ex b/lib/predicate_converter.ex index 2f9807a..f003f96 100644 --- a/lib/predicate_converter.ex +++ b/lib/predicate_converter.ex @@ -453,12 +453,7 @@ defmodule Predicates.PredicateConverter do subquery = try do - subquery = safe_call({schema, :build_assoc}, [field, meta], 1) - - subquery - |> where( - ^convert_query(subquery, sub_predicate, Map.put(meta, :__nested_virtual_json__, true)) - ) + safe_call({schema, :build_assoc}, [field, meta], 1) rescue [FunctionClauseError, UndefinedFunctionError] -> sub_association = get_association_field(schema, field) @@ -478,9 +473,9 @@ defmodule Predicates.PredicateConverter do field(s, ^sub_association.related_key) == field(parent_as(^parent_table_name), ^sub_association.owner_key) ) - |> build_sub_query(sub_predicate, meta) end end + |> build_sub_query(sub_predicate, meta) dynamic(exists(subquery)) end diff --git a/test/predicates_test.exs b/test/predicates_test.exs index 07f950b..461fcd5 100644 --- a/test/predicates_test.exs +++ b/test/predicates_test.exs @@ -176,13 +176,9 @@ defmodule PredicatesTest do def build_assoc(:prizes, meta), do: - subquery( - from(p in Prizes, - where: p.author_id == parent_as(:pred_authors).id, - select: %{ - __element__: fragment("jsonb_build_object('name', ?, 'year', ?)", p.title, p.year) - } - ) + from(p in Prizes, + where: p.author_id == parent_as(:pred_authors).id, + select: p ) end @@ -942,7 +938,7 @@ defmodule PredicatesTest do "path" => "prizes", "arg" => %{ "op" => "eq", - "path" => "name", + "path" => "title", "arg" => "Nobel Prize" } } From 76be050b57c8d54938a97d51b614650bbbc1789c Mon Sep 17 00:00:00 2001 From: bdebinska Date: Thu, 27 Nov 2025 10:17:33 +0100 Subject: [PATCH 3/4] Bump version, update docs and changelog --- CHANGELOG.md | 9 ++++++++- README.md | 27 +++++++++++++++++++++++++++ lib/json_schemas.ex | 8 ++++---- mix.exs | 2 +- test/predicates_test.exs | 2 +- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f818d3..ffddb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +## [0.6.0] - 2025-11-27 + +### Added +- Support for hooking into `build_assoc` functions when building subqueries on associations. +- JSON schemas have been added for all predicates. + ## [0.5.1] - 2025-11-20 ### Bugfixes @@ -46,7 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Renamed `Utils` module to `Predicates.Utils` to avoid module name collision -[unreleased]: https://github.com/box-id/ecto_predicates/compare/0.5.1...HEAD +[unreleased]: https://github.com/box-id/ecto_predicates/compare/0.6.0...HEAD +[0.6.0]: https://github.com/box-id/ecto_predicates/compare/0.5.1...0.6.0 [0.5.1]: https://github.com/box-id/ecto_predicates/compare/0.5.0...0.5.1 [0.5.0]: https://github.com/box-id/ecto_predicates/compare/0.4.0...0.5.0 [0.4.0]: https://github.com/box-id/ecto_predicates/compare/0.3.0...0.4.0 diff --git a/README.md b/README.md index 17f749e..ef08233 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,33 @@ The first path segment is converted to atom and looked-up on the model: entity for which the original predicate evaluates to true?". This behaves the same for both one-to-one and one-to-many relationships. The remaining path is applied to the related entity. +### Hooking into `build_assoc` functions + +When walking associations, PredicateConverter by default creates subqueries that directly query the associated table. However, Ecto schemas may define `build_assoc/2` functions to customize the way associations are queried. + +This function must accept the association name as an atom and an options map, and return an Ecto query that will be used as the basis for applying the predicate on the association. + +The following example shows how to define a custom `build_assoc` function for an association named `:prizes` on the `Author` schema. + +```elixir +defmodule Author do + use Ecto.Schema + + schema "authors" do + # fields … + + has_many :prizes, Prize + end + + def build_assoc(:prizes, _opts), + do: + from(p in Prizes, + where: p.author_id == parent_as(:pred_authors).id, + select: p + ) +end +``` + ## Virtual Fields Virtual fields in Ecto are fields defined in your schema that do not exist in the database (`virtual: true`). They are diff --git a/lib/json_schemas.ex b/lib/json_schemas.ex index 9a6a9de..4e6f7d4 100644 --- a/lib/json_schemas.ex +++ b/lib/json_schemas.ex @@ -165,7 +165,7 @@ defmodule Predicates.JSONSchemas do } end - def negation_operator do + def negation_operator() do %{ "type" => "object", "properties" => %{ @@ -181,7 +181,7 @@ defmodule Predicates.JSONSchemas do } end - def conjunction_operators do + def conjunction_operators() do %{ "type" => "object", "properties" => %{ @@ -205,7 +205,7 @@ defmodule Predicates.JSONSchemas do } end - def quantor_operator do + def quantor_operator() do %{ "type" => "object", "properties" => %{ @@ -235,7 +235,7 @@ defmodule Predicates.JSONSchemas do } end - def plain_value_predicate do + def plain_value_predicate() do %{ "type" => "object", "description" => "These special predicates always evaluate to true or false", diff --git a/mix.exs b/mix.exs index 1459269..dde5457 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Predicates.MixProject do def project do [ app: :ecto_predicates, - version: "0.5.1", + version: "0.6.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/predicates_test.exs b/test/predicates_test.exs index 461fcd5..b932d1e 100644 --- a/test/predicates_test.exs +++ b/test/predicates_test.exs @@ -174,7 +174,7 @@ defmodule PredicatesTest do ) ) - def build_assoc(:prizes, meta), + def build_assoc(:prizes, _opts), do: from(p in Prizes, where: p.author_id == parent_as(:pred_authors).id, From 05996ba10eed930869411d2afcefc857cc4dcfb8 Mon Sep 17 00:00:00 2001 From: bdebinska Date: Thu, 27 Nov 2025 10:40:31 +0100 Subject: [PATCH 4/4] Drop select: p from the build_assoc function --- README.md | 3 +-- test/predicates_test.exs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ef08233..5c801ac 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,7 @@ defmodule Author do def build_assoc(:prizes, _opts), do: from(p in Prizes, - where: p.author_id == parent_as(:pred_authors).id, - select: p + where: p.author_id == parent_as(:pred_authors).id ) end ``` diff --git a/test/predicates_test.exs b/test/predicates_test.exs index b932d1e..edeed3e 100644 --- a/test/predicates_test.exs +++ b/test/predicates_test.exs @@ -177,8 +177,7 @@ defmodule PredicatesTest do def build_assoc(:prizes, _opts), do: from(p in Prizes, - where: p.author_id == parent_as(:pred_authors).id, - select: p + where: p.author_id == parent_as(:pred_authors).id ) end