Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions lib/json_schemas.ex
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ defmodule Predicates.JSONSchemas do
}
end

def negation_operator do
def negation_operator() do
%{
"type" => "object",
"properties" => %{
Expand All @@ -181,7 +181,7 @@ defmodule Predicates.JSONSchemas do
}
end

def conjunction_operators do
def conjunction_operators() do
%{
"type" => "object",
"properties" => %{
Expand All @@ -205,7 +205,7 @@ defmodule Predicates.JSONSchemas do
}
end

def quantor_operator do
def quantor_operator() do
%{
"type" => "object",
"properties" => %{
Expand Down Expand Up @@ -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",
Expand Down
47 changes: 26 additions & 21 deletions lib/predicate_converter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions test/predicates_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down