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..5c801ac 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,32 @@ 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 + ) +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/lib/predicate_converter.ex b/lib/predicate_converter.ex index 6905357..f003f96 100644 --- a/lib/predicate_converter.ex +++ b/lib/predicate_converter.ex @@ -451,28 +451,33 @@ 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) - - # 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) - ) - |> build_sub_query(sub_predicate, meta) + subquery = + try do + safe_call({schema, :build_assoc}, [field, meta], 1) + 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) + ) + end + end + |> build_sub_query(sub_predicate, meta) - dynamic(exists(subquery)) - end + dynamic(exists(subquery)) end defp convert_any({:single, field}, sub_predicate, queryable, meta) do 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 ff724c2..edeed3e 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,22 @@ defmodule PredicatesTest do } ) ) + + def build_assoc(:prizes, _opts), + do: + from(p in Prizes, + where: p.author_id == parent_as(:pred_authors).id + ) 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 +918,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" => "title", + "arg" => "Nobel Prize" + } + } + ) + |> Predicates.Repo.all() + end end describe "conjunctions" do