Skip to content
Open
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
6 changes: 5 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ if Mix.env() == :test do

config :ash_postgres, :ash_domains, [AshPostgres.Test.Domain]

config :ash, :custom_expressions, [AshPostgres.Expressions.TrigramWordSimilarity]
config :ash, :custom_expressions, [
AshPostgres.Expressions.TrigramWordSimilarity,
AshPostgres.Expressions.Required,
AshPostgres.Expressions.AshRequired
]

config :ash, :known_types, [AshPostgres.Timestamptz, AshPostgres.TimestamptzUsec]

Expand Down
44 changes: 44 additions & 0 deletions documentation/topics/advanced/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,47 @@ For example:
```elixir
Ash.Query.filter(User, trigram_similarity(first_name, "fred") > 0.8)
```

## required!/1 and ash_required/1

`required!/1` (and the equivalent `ash_required/1`) express that a value must be present (not nil). They are equivalent to `not is_nil(expr)`. In SQL they compile to `(expr) IS NOT NULL` or, when using the ash-functions extension, to the stored function `ash_required(expr)`.

**Setup:** Add AshPostgres’s custom expressions to your Ash config so the expression parser knows about them (no changes to the main Ash repo needed):

```elixir
# config/config.exs (or config/dev.exs, config/runtime.exs)
config :ash, :custom_expressions, [
AshPostgres.Expressions.Required,
AshPostgres.Expressions.AshRequired
]
```

The **ash-functions extension** (installed via `mix ash_postgres.install_extensions` or migrations) includes an `ash_required(value)` SQL function that returns true when the value is not null. You can use it in raw SQL (e.g. fragments) as well.

Use them in filters, calculations, aggregates, and `exists/2` when you want clearer intent than `not is_nil(...)`.

### Examples

```elixir
# Filter: only records where an optional attribute is set
Ash.Query.filter(Post, required!(post_category))

# Same using the explicit name
Ash.Query.filter(Post, ash_required(post_category))

# In aggregate query filters
Post
|> Ash.Query.aggregate(:count, :comments, query: [filter: expr(required!(title))])

# In calculations (e.g. "has value" flag)
calculate :has_rating, :boolean, expr(required!(latest_rating_score))

# In exists
Ash.Query.filter(Comment, exists(post, required!(id)))
```

### Semantics and SQL

- **Semantics:** True when the argument is not nil; false when it is nil.
- **SQL:** Compiled to `(expression) IS NOT NULL` or to the extension function `ash_required(expression)` when the ash-functions extension is installed.
- **Edge cases:** Behaves like `not is_nil(expr)` over joins, nullable relationships, and in calculations. Use `required!(expr)` wherever you would use `not is_nil(expr)` for readability.
9 changes: 7 additions & 2 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,9 @@ defmodule AshPostgres.DataLayer do
def can?(resource, :expr_error),
do: not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?()

def can?(resource, :required_error),
do: not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?()

def can?(resource, {:filter_expr, %Ash.Query.Function.Error{}}) do
not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?() &&
"ash-functions" in AshPostgres.DataLayer.Info.repo(resource, :read).installed_extensions() &&
Expand Down Expand Up @@ -877,7 +880,8 @@ defmodule AshPostgres.DataLayer do
functions = [
AshPostgres.Functions.Like,
AshPostgres.Functions.ILike,
AshPostgres.Functions.Binding
AshPostgres.Functions.Binding,
AshPostgres.Functions.RequiredError
]

functions =
Expand Down Expand Up @@ -2669,6 +2673,7 @@ defmodule AshPostgres.DataLayer do
case Ecto.Adapters.Postgres.Connection.to_constraints(error, []) do
[] ->
constraints = maybe_foreign_key_violation_constraints(error)

if constraints != [] do
{:error,
changeset
Expand All @@ -2695,7 +2700,7 @@ defmodule AshPostgres.DataLayer do
code = postgres[:code] || postgres["code"]
constraint = postgres[:constraint] || postgres["constraint"]

if code in ["23503", 23503, :foreign_key_violation] and is_binary(constraint) do
if code in ["23503", 23_503, :foreign_key_violation] and is_binary(constraint) do
[{:foreign_key, constraint}]
else
[]
Expand Down
26 changes: 26 additions & 0 deletions lib/expressions/ash_required.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshPostgres.Expressions.AshRequired do
@moduledoc """
Same as `Required` but with the explicit name `ash_required`.

Use in filters as `ash_required(field)`. Equivalent to `required!(field)` and `not is_nil(field)`.

Register in your config:

config :ash, :custom_expressions, [
AshPostgres.Expressions.Required,
AshPostgres.Expressions.AshRequired
]
"""
use Ash.CustomExpression,
name: :ash_required,
arguments: [[:any]],
predicate?: true

def expression(data_layer, args) do
AshPostgres.Expressions.Required.expression(data_layer, args)
end
end
31 changes: 31 additions & 0 deletions lib/expressions/required.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshPostgres.Expressions.Required do
@moduledoc """
Custom expression that means "value must be present (not null)".

Use in filters as `required!(field)` or `ash_required(field)`.
Equivalent to `not is_nil(field)`; compiles to `(expr) IS NOT NULL` in SQL.

Register in your config so Ash knows about it:

config :ash, :custom_expressions, [
AshPostgres.Expressions.Required,
AshPostgres.Expressions.AshRequired
]
"""
use Ash.CustomExpression,
name: :required!,
arguments: [[:any]],
predicate?: true

require Ash.Expr

def expression(AshPostgres.DataLayer, [arg]) do
{:ok, Ash.Expr.expr(not is_nil(^arg))}
end

def expression(_data_layer, _args), do: :unknown
end
56 changes: 56 additions & 0 deletions lib/functions/required_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshPostgres.Functions.RequiredError do
@moduledoc """
Expression that returns the value if present, or an error if nil.
Used for required-attribute validation (Part B): `ash_required!(^value, ^attribute)`.

When the data layer supports `:required_error`, Ash can build
`expr(ash_required!(^value, ^attribute))` instead of the inline if/is_nil/error block.
This module is returned from the data layer's `functions/1` so the expression is available
when using AshPostgres.
"""
use Ash.Query.Function, name: :ash_required!, predicate?: false

@impl true
def args, do: [[:any, :any]]

@impl true
def new([value_expr, attribute]) when is_struct(attribute) or is_map(attribute) do
{:ok, %__MODULE__{arguments: [value_expr, attribute]}}
end

def new(_), do: {:error, "ash_required! expects (value, attribute)"}

@impl true
def evaluate(%{arguments: [value, attribute]}) do
if is_nil(value) do
resource =
Map.get(attribute, :resource) || raise("attribute must have :resource for ash_required!")

field =
Map.get(attribute, :name) || Map.get(attribute, "name") ||
raise("attribute must have :name for ash_required!")

{:error,
Ash.Error.Changes.Required.exception(
field: field,
type: :attribute,
resource: resource
)}
else
{:known, value}
end
end

@impl true
def can_return_nil?(_), do: false

@impl true
def evaluate_nil_inputs?, do: true

@impl true
def returns, do: :unknown
end
18 changes: 17 additions & 1 deletion lib/migration_generator/ash_functions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
IMMUTABLE;
\"\"\")

#{ash_required()}

execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
RETURNS text[] AS $$
Expand Down Expand Up @@ -159,11 +161,13 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
execute("ALTER FUNCTION ash_raise_error(jsonb) STABLE;")
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) STABLE")
#{uuid_generate_v7()}
#{ash_required()}
"""
end

def drop(4) do
"""
execute("DROP FUNCTION IF EXISTS ash_required(ANYCOMPATIBLE)")
execute("ALTER FUNCTION ash_raise_error(jsonb) VOLATILE;")
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) VOLATILE")
"""
Expand All @@ -190,7 +194,19 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
end

def drop(nil) do
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])\")"
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_required(ANYCOMPATIBLE), ash_trim_whitespace(text[])\")"
end

defp ash_required do
"""
execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_required(value ANYCOMPATIBLE)
RETURNS BOOLEAN AS $$ SELECT $1 IS NOT NULL $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
\"\"\")
"""
end

defp ash_raise_error do
Expand Down
3 changes: 2 additions & 1 deletion lib/migration_generator/migration_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,8 @@ defmodule AshPostgres.MigrationGenerator do
if tenant? do
Ecto.Migrator.with_repo(repo, fn repo ->
for prefix <- repo.all_tenants() do
{repo, query, opts} = Ecto.Migration.SchemaMigration.versions(repo, repo.config(), prefix)
{repo, query, opts} =
Ecto.Migration.SchemaMigration.versions(repo, repo.config(), prefix)

repo.transaction(fn ->
versions = repo.all(query, Keyword.put(opts, :timeout, :infinity))
Expand Down
28 changes: 28 additions & 0 deletions test/aggregate_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,34 @@ defmodule AshSql.AggregateTest do
|> Ash.read_one!()
end

test "aggregate with query filter using required!(field) counts only non-nil" do
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "title"})
|> Ash.create!()

Comment
|> Ash.Changeset.for_create(:create, %{title: "match"})
|> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove)
|> Ash.create!()

Comment
|> Ash.Changeset.new()
|> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove)
|> Ash.create!()

assert %{aggregates: %{custom_count_of_comments: 1}} =
Post
|> Ash.Query.filter(id == ^post.id)
|> Ash.Query.aggregate(
:custom_count_of_comments,
:count,
:comments,
query: [filter: expr(required!(title))]
)
|> Ash.read_one!()
end

test "with data for a many_to_many, it returns the count" do
post =
Post
Expand Down
30 changes: 30 additions & 0 deletions test/calculation_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1669,4 +1669,34 @@ defmodule AshPostgres.CalculationTest do
assert Ash.calculate!(post, :past_datetime1?)
assert Ash.calculate!(post, :past_datetime2?)
end

describe "required!/1 in calculations" do
test "calculation using required!(field) returns true when present, false when nil" do
post = Post |> Ash.Changeset.for_create(:create, %{title: "t"}) |> Ash.create!()

comment_with_rating =
Comment
|> Ash.Changeset.for_create(:create, %{title: "c1", rating: %{score: 5}})
|> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove)
|> Ash.create!()

comment_without_rating =
Comment
|> Ash.Changeset.for_create(:create, %{title: "c2"})
|> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove)
|> Ash.create!()

comments =
Comment
|> Ash.Query.filter(id in [^comment_with_rating.id, ^comment_without_rating.id])
|> Ash.Query.load(:has_rating)
|> Ash.read!()

with_rating = Enum.find(comments, &(&1.id == comment_with_rating.id))
without_rating = Enum.find(comments, &(&1.id == comment_without_rating.id))

assert with_rating.has_rating == true
assert without_rating.has_rating == false
end
end
end
2 changes: 1 addition & 1 deletion test/destroy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

defmodule AshPostgres.DestroyTest do
use AshPostgres.RepoCase, async: false
alias AshPostgres.Test.{Post, Permalink}
alias AshPostgres.Test.{Permalink, Post}

test "destroy with restrict on_delete returns would leave records behind error" do
post =
Expand Down
Loading