Skip to content

Commit ba1d148

Browse files
authored
improvement: Add required!/1, ash_required/1, and ash_required!/2 (issue #261) (#707)
1 parent f0219bf commit ba1d148

File tree

11 files changed

+291
-12
lines changed

11 files changed

+291
-12
lines changed

config/config.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ if Mix.env() == :test do
3535

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

38-
config :ash, :custom_expressions, [AshPostgres.Expressions.TrigramWordSimilarity]
38+
config :ash, :custom_expressions, [
39+
AshPostgres.Expressions.TrigramWordSimilarity
40+
]
3941

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

documentation/topics/advanced/expressions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,4 @@ For example:
8181
```elixir
8282
Ash.Query.filter(User, trigram_similarity(first_name, "fred") > 0.8)
8383
```
84+

lib/data_layer.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,12 @@ defmodule AshPostgres.DataLayer do
768768
def can?(resource, :expr_error),
769769
do: not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?()
770770

771+
def can?(resource, :required_error) do
772+
not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?() &&
773+
"ash-functions" in AshPostgres.DataLayer.Info.repo(resource, :read).installed_extensions() &&
774+
"ash-functions" in AshPostgres.DataLayer.Info.repo(resource, :mutate).installed_extensions()
775+
end
776+
771777
def can?(resource, {:filter_expr, %Ash.Query.Function.Error{}}) do
772778
not AshPostgres.DataLayer.Info.repo(resource, :mutate).disable_expr_error?() &&
773779
"ash-functions" in AshPostgres.DataLayer.Info.repo(resource, :read).installed_extensions() &&
@@ -888,6 +894,8 @@ defmodule AshPostgres.DataLayer do
888894
AshPostgres.Functions.Binding
889895
]
890896

897+
functions = [Ash.Query.Function.RequiredError | functions]
898+
891899
functions =
892900
if "pg_trgm" in (config[:installed_extensions] || []) do
893901
functions ++

lib/migration_generator/ash_functions.ex

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# SPDX-License-Identifier: MIT
44

55
defmodule AshPostgres.MigrationGenerator.AshFunctions do
6-
@latest_version 5
6+
@latest_version 6
77

88
def latest_version, do: @latest_version
99

@@ -76,6 +76,8 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
7676
7777
#{ash_raise_error()}
7878
79+
#{ash_required()}
80+
7981
#{uuid_generate_v7()}
8082
"""
8183
end
@@ -100,6 +102,8 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
100102
101103
#{ash_raise_error()}
102104
105+
#{ash_required()}
106+
103107
#{uuid_generate_v7()}
104108
105109
execute(\"\"\"
@@ -134,6 +138,8 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
134138
"""
135139
#{ash_raise_error()}
136140
141+
#{ash_required()}
142+
137143
#{uuid_generate_v7()}
138144
"""
139145
end
@@ -142,6 +148,8 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
142148
"""
143149
#{ash_raise_error()}
144150
151+
#{ash_required()}
152+
145153
#{uuid_generate_v7()}
146154
"""
147155
end
@@ -150,6 +158,9 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
150158
"""
151159
execute("ALTER FUNCTION ash_raise_error(jsonb) STABLE;")
152160
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) STABLE")
161+
162+
#{ash_required()}
163+
153164
#{uuid_generate_v7()}
154165
"""
155166
end
@@ -158,39 +169,73 @@ defmodule AshPostgres.MigrationGenerator.AshFunctions do
158169
"""
159170
execute("ALTER FUNCTION ash_raise_error(jsonb) STABLE;")
160171
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) STABLE")
172+
173+
#{ash_required()}
174+
161175
#{uuid_generate_v7()}
162176
"""
163177
end
164178

179+
def install(5) do
180+
"""
181+
#{ash_required()}
182+
"""
183+
end
184+
165185
def drop(4) do
166186
"""
167187
execute("ALTER FUNCTION ash_raise_error(jsonb) VOLATILE;")
168188
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) VOLATILE")
189+
190+
execute("DROP FUNCTION IF EXISTS ash_required(ANYCOMPATIBLE, jsonb)")
169191
"""
170192
end
171193

194+
def drop(5) do
195+
"execute(\"DROP FUNCTION IF EXISTS ash_required(ANYCOMPATIBLE, jsonb)\")"
196+
end
197+
172198
def drop(3) do
173-
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid)\")"
199+
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_required(ANYCOMPATIBLE, jsonb)\")"
174200
end
175201

176202
def drop(2) do
177203
"""
178204
#{ash_raise_error()}
179205
180-
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid)\")"
206+
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_required(ANYCOMPATIBLE, jsonb)\")"
181207
"""
182208
end
183209

184210
def drop(1) do
185-
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE)\")"
211+
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_required(ANYCOMPATIBLE, jsonb)\")"
186212
end
187213

188214
def drop(0) do
189-
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_trim_whitespace(text[])\")"
215+
"execute(\"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_trim_whitespace(text[]), ash_required(ANYCOMPATIBLE, jsonb)\")"
190216
end
191217

192218
def drop(nil) do
193-
"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[])\")"
219+
"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[]), ash_required(ANYCOMPATIBLE, jsonb)\")"
220+
end
221+
222+
defp ash_required do
223+
"""
224+
execute(\"\"\"
225+
CREATE OR REPLACE FUNCTION ash_required(value ANYCOMPATIBLE, payload jsonb)
226+
RETURNS ANYCOMPATIBLE AS $$
227+
BEGIN
228+
IF value IS NULL THEN
229+
RETURN ash_raise_error(payload, value);
230+
END IF;
231+
232+
RETURN value;
233+
END;
234+
$$ LANGUAGE plpgsql
235+
STABLE
236+
SET search_path = '';
237+
\"\"\")
238+
"""
194239
end
195240

196241
defp ash_raise_error do

lib/sql_implementation.ex

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,54 @@ defmodule AshPostgres.SqlImplementation do
229229
end
230230
end
231231

232+
def expr(
233+
query,
234+
%{name: :required!, arguments: [value_expr, attribute]} = required,
235+
bindings,
236+
embedded?,
237+
acc,
238+
type
239+
) do
240+
pred_embedded? = Map.get(required, :embedded?, false)
241+
242+
{value_dyn, acc} =
243+
AshSql.Expr.dynamic_expr(
244+
query,
245+
value_expr,
246+
bindings,
247+
pred_embedded? || embedded?,
248+
type,
249+
acc
250+
)
251+
252+
resource =
253+
Map.get(attribute, :resource) ||
254+
raise("attribute must have :resource for ash_required!")
255+
256+
field =
257+
Map.get(attribute, :name) ||
258+
Map.get(attribute, "name") ||
259+
raise("attribute must have :name for ash_required!")
260+
261+
payload =
262+
%{
263+
exception: inspect(Ash.Error.Changes.Required),
264+
input: %{field: field, type: :attribute, resource: resource}
265+
}
266+
|> Jason.encode!()
267+
268+
{:ok,
269+
Ecto.Query.dynamic(
270+
fragment(
271+
"CASE WHEN ? IS NULL THEN ash_raise_error(?::jsonb, ?) ELSE ? END",
272+
^value_dyn,
273+
^payload,
274+
^value_dyn,
275+
^value_dyn
276+
)
277+
), acc}
278+
end
279+
232280
def expr(
233281
_query,
234282
_expr,

mix.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ defmodule AshPostgres.MixProject do
186186
# Run "mix help deps" to learn about dependencies.
187187
defp deps do
188188
[
189-
{:ash, ash_version("~> 3.19")},
190-
{:spark, ">= 2.3.4"},
189+
{:ash, ash_version("~> 3.21")},
190+
{:spark, "~> 2.3 and >= 2.3.4"},
191191
{:ash_sql, ash_sql_version("~> 0.4 and >= 0.4.3")},
192192
{:igniter, "~> 0.6 and >= 0.6.29", optional: true},
193193
{:ecto_sql, "~> 3.13"},

priv/resource_snapshots/dev_test_repo/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"ash_functions_version": 5,
2+
"ash_functions_version": 6,
33
"installed": [
44
"ash-functions",
55
"uuid-ossp",

priv/resource_snapshots/test_no_sandbox_repo/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"ash_functions_version": 5,
2+
"ash_functions_version": 6,
33
"installed": [
44
"ash-functions",
55
"uuid-ossp",

priv/resource_snapshots/test_repo/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"ash_functions_version": 5,
2+
"ash_functions_version": 6,
33
"installed": [
44
"ash-functions",
55
"uuid-ossp",

test/filter_test.exs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,4 +1243,84 @@ defmodule AshPostgres.FilterTest do
12431243

12441244
assert fetched_org.id == organization.id
12451245
end
1246+
1247+
describe "not is_nil(expr) in filters" do
1248+
test "not is_nil(expr) compiles to IS NOT NULL in SQL (regression)" do
1249+
{query, _vars} =
1250+
Post
1251+
|> Ash.Query.filter(not is_nil(category))
1252+
|> Ash.data_layer_query!()
1253+
|> Map.get(:query)
1254+
|> then(&AshPostgres.TestRepo.to_sql(:all, &1))
1255+
1256+
# SQL may be (expr) IS NOT NULL or NOT ((expr) IS NULL); both are equivalent.
1257+
assert query =~ "IS NOT NULL" or query =~ "is not null" or
1258+
(query =~ "NOT (" and query =~ "IS NULL"),
1259+
"Expected filter(not is_nil(...)) to compile to presence check (IS NOT NULL or NOT (... IS NULL)), got: #{query}"
1260+
end
1261+
1262+
test "not is_nil(expr) filter returns only records where attribute is present (behavioral regression)" do
1263+
Post
1264+
|> Ash.Changeset.for_create(:create, %{title: "with category", category: "tech"})
1265+
|> Ash.create!()
1266+
1267+
Post
1268+
|> Ash.Changeset.for_create(:create, %{title: "no category"})
1269+
|> Ash.create!()
1270+
1271+
assert [%{title: "with category"}] =
1272+
Post
1273+
|> Ash.Query.filter(not is_nil(category))
1274+
|> Ash.read!()
1275+
end
1276+
1277+
test "not is_nil(expr) returns empty list when no records have attribute set (edge case)" do
1278+
Post
1279+
|> Ash.Changeset.for_create(:create, %{title: "a"})
1280+
|> Ash.create!()
1281+
1282+
Post
1283+
|> Ash.Changeset.for_create(:create, %{title: "b"})
1284+
|> Ash.create!()
1285+
1286+
assert [] =
1287+
Post
1288+
|> Ash.Query.filter(not is_nil(category))
1289+
|> Ash.read!()
1290+
end
1291+
1292+
test "not is_nil(expr) includes records where value is 0 or false — required means not null, not truthy (edge case)" do
1293+
post_zero =
1294+
Post
1295+
|> Ash.Changeset.for_create(:create, %{title: "zero score", score: 0})
1296+
|> Ash.create!()
1297+
1298+
Post
1299+
|> Ash.Changeset.for_create(:create, %{title: "nil score"})
1300+
|> Ash.create!()
1301+
1302+
assert [%{id: id}] =
1303+
Post
1304+
|> Ash.Query.filter(title in ["zero score", "nil score"] and not is_nil(score))
1305+
|> Ash.read!()
1306+
1307+
assert id == post_zero.id
1308+
1309+
post_false =
1310+
Post
1311+
|> Ash.Changeset.for_create(:create, %{title: "false public", public: false})
1312+
|> Ash.create!()
1313+
1314+
Post
1315+
|> Ash.Changeset.for_create(:create, %{title: "nil public"})
1316+
|> Ash.create!()
1317+
1318+
assert [%{id: id2}] =
1319+
Post
1320+
|> Ash.Query.filter(title in ["false public", "nil public"] and not is_nil(public))
1321+
|> Ash.read!()
1322+
1323+
assert id2 == post_false.id
1324+
end
1325+
end
12461326
end

0 commit comments

Comments
 (0)