Skip to content

Commit e4221b9

Browse files
improvement: register Ash timestamps with Ecto's autogenerate system (ash-project#2622)
Ash resources generate Ecto schemas automatically, but create_timestamp/update_timestamp attributes were not registered with Ecto's autogenerate system. This meant __schema__(:autogenerate_fields) returned [] and Repo.insert/1 left timestamp fields as nil. This wires Ash's timestamp attributes into Ecto's :ecto_autogenerate and :ecto_autoupdate module attributes at compile time, so that Repo.insert/1 and Repo.update/1 auto-populate them — matching what Ecto's native timestamps() macro does. Timestamps sharing the same storage type are grouped into a single registration entry so they receive the same generated value, preserving Ash's match_other_defaults? semantics. Closes ash-project#769
1 parent 0e99d88 commit e4221b9

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

lib/ash/resource/schema.ex

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ defmodule Ash.Schema do
6161
)
6262
end
6363

64+
Ash.Schema.register_ecto_autogenerate(__MODULE__)
65+
6466
field(:aggregates, :map, virtual: true, default: %{})
6567
field(:calculations, :map, virtual: true, default: %{})
6668
field(:__metadata__, :map, virtual: true, default: %{}, redact: true)
@@ -198,6 +200,8 @@ defmodule Ash.Schema do
198200
)
199201
end
200202

203+
Ash.Schema.register_ecto_autogenerate(__MODULE__)
204+
201205
field(:aggregates, :map, virtual: true, default: %{})
202206
field(:calculations, :map, virtual: true, default: %{})
203207
field(:__metadata__, :map, virtual: true, default: %{}, redact: true)
@@ -383,4 +387,92 @@ defmodule Ash.Schema do
383387
def attribute_default(fun) when is_function(fun), do: nil
384388
def attribute_default({_mod, _func, _args}), do: nil
385389
def attribute_default(value), do: value
390+
391+
@doc false
392+
# Called at compile time from within the `schema do ... end` block to wire
393+
# Ash's create_timestamp/update_timestamp attributes into Ecto's autogenerate
394+
# system. This enables Repo.insert/1 and Repo.update/1 to auto-populate
395+
# timestamp fields without going through Ash's changeset pipeline.
396+
def register_ecto_autogenerate(module) do
397+
autogen_timestamps =
398+
Ash.Resource.Info.attributes(module)
399+
|> Enum.filter(&timestamp_attribute?/1)
400+
401+
# Partition by match_other_defaults?: timestamps that share defaults are
402+
# grouped into a single {[field1, field2], mfa} tuple so Ecto calls the MFA
403+
# once and assigns the same value to all fields. Timestamps with
404+
# match_other_defaults? == false each get their own entry.
405+
{shared, individual} =
406+
Enum.split_with(autogen_timestamps, & &1.match_other_defaults?)
407+
408+
shared
409+
|> Enum.group_by(&Ash.Type.storage_type(&1.type, &1.constraints))
410+
|> Enum.each(fn {storage_type, attrs} ->
411+
field_names = Enum.map(attrs, & &1.name)
412+
413+
Module.put_attribute(
414+
module,
415+
:ecto_autogenerate,
416+
{field_names, {Ecto.Schema, :__timestamps__, [storage_type]}}
417+
)
418+
end)
419+
420+
Enum.each(individual, fn attr ->
421+
storage_type = Ash.Type.storage_type(attr.type, attr.constraints)
422+
423+
Module.put_attribute(
424+
module,
425+
:ecto_autogenerate,
426+
{[attr.name], {Ecto.Schema, :__timestamps__, [storage_type]}}
427+
)
428+
end)
429+
430+
# Register update_timestamp fields in :ecto_autoupdate so Repo.update/1
431+
# refreshes them automatically.
432+
update_timestamps =
433+
Enum.filter(autogen_timestamps, fn attr -> not is_nil(attr.update_default) end)
434+
435+
{shared_update, individual_update} =
436+
Enum.split_with(update_timestamps, & &1.match_other_defaults?)
437+
438+
shared_update
439+
|> Enum.group_by(&Ash.Type.storage_type(&1.type, &1.constraints))
440+
|> Enum.each(fn {storage_type, attrs} ->
441+
field_names = Enum.map(attrs, & &1.name)
442+
443+
Module.put_attribute(
444+
module,
445+
:ecto_autoupdate,
446+
{field_names, {Ecto.Schema, :__timestamps__, [storage_type]}}
447+
)
448+
end)
449+
450+
Enum.each(individual_update, fn attr ->
451+
storage_type = Ash.Type.storage_type(attr.type, attr.constraints)
452+
453+
Module.put_attribute(
454+
module,
455+
:ecto_autoupdate,
456+
{[attr.name], {Ecto.Schema, :__timestamps__, [storage_type]}}
457+
)
458+
end)
459+
end
460+
461+
@doc false
462+
def timestamp_attribute?(attr) do
463+
ecto_datetime_type?(Ash.Type.storage_type(attr.type, attr.constraints)) &&
464+
attr.writable? == false &&
465+
datetime_default?(attr.default) &&
466+
attr.allow_nil? == false
467+
end
468+
469+
defp datetime_default?(fun) when is_function(fun, 0) do
470+
fun == (&DateTime.utc_now/0)
471+
end
472+
473+
defp datetime_default?(_), do: false
474+
475+
defp ecto_datetime_type?(type) do
476+
type in [:utc_datetime, :utc_datetime_usec, :naive_datetime, :naive_datetime_usec]
477+
end
386478
end

test/resource/ecto_compat_test.exs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Ash.Test.Resource.EctoCompatTest do
6+
@moduledoc false
7+
use ExUnit.Case, async: true
8+
9+
# Verifies that Ash-generated Ecto schemas properly register timestamp
10+
# attributes with Ecto's autogenerate system, so that Repo.insert/1 and
11+
# Repo.update/1 can auto-populate them without going through Ash's
12+
# changeset pipeline.
13+
#
14+
# See: https://github.com/ash-project/ash/issues/769
15+
16+
describe "timestamp autogenerate registration" do
17+
test "create_timestamp fields appear in __schema__(:autogenerate_fields)" do
18+
defmodule CreateTimestampResource do
19+
@moduledoc false
20+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
21+
22+
attributes do
23+
uuid_primary_key :id
24+
create_timestamp :inserted_at
25+
end
26+
end
27+
28+
autogen_fields = CreateTimestampResource.__schema__(:autogenerate_fields)
29+
assert :inserted_at in autogen_fields
30+
end
31+
32+
test "update_timestamp fields appear in __schema__(:autogenerate_fields)" do
33+
defmodule UpdateTimestampResource do
34+
@moduledoc false
35+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
36+
37+
attributes do
38+
uuid_primary_key :id
39+
update_timestamp :updated_at
40+
end
41+
end
42+
43+
autogen_fields = UpdateTimestampResource.__schema__(:autogenerate_fields)
44+
assert :updated_at in autogen_fields
45+
end
46+
47+
test "both timestamps are registered together for autogenerate on insert" do
48+
defmodule BothTimestampsResource do
49+
@moduledoc false
50+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
51+
52+
attributes do
53+
uuid_primary_key :id
54+
create_timestamp :inserted_at
55+
update_timestamp :updated_at
56+
end
57+
end
58+
59+
autogen_fields = BothTimestampsResource.__schema__(:autogenerate_fields)
60+
assert :inserted_at in autogen_fields
61+
assert :updated_at in autogen_fields
62+
end
63+
64+
test "update_timestamp fields are registered in autoupdate" do
65+
defmodule AutoupdateResource do
66+
@moduledoc false
67+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
68+
69+
attributes do
70+
uuid_primary_key :id
71+
create_timestamp :inserted_at
72+
update_timestamp :updated_at
73+
end
74+
end
75+
76+
# :autoupdate returns a list of {[fields], {mod, fun, args}} tuples.
77+
# update_timestamp fields should be registered here.
78+
autoupdate = AutoupdateResource.__schema__(:autoupdate)
79+
autoupdate_fields = Enum.flat_map(autoupdate, &elem(&1, 0))
80+
81+
assert :updated_at in autoupdate_fields
82+
refute :inserted_at in autoupdate_fields
83+
end
84+
85+
test "timestamps of the same type are grouped (match_other_defaults)" do
86+
defmodule GroupedTimestampsResource do
87+
@moduledoc false
88+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
89+
90+
attributes do
91+
uuid_primary_key :id
92+
create_timestamp :inserted_at
93+
update_timestamp :updated_at
94+
end
95+
end
96+
97+
# Both timestamps default to Ash.Type.UtcDatetimeUsec, so they should
98+
# be grouped into a single {[fields], mfa} tuple for autogenerate.
99+
# This ensures Ecto calls the MFA once and assigns the same value to
100+
# both fields — matching Ash's match_other_defaults? behavior.
101+
autogenerate = GroupedTimestampsResource.__schema__(:autogenerate)
102+
103+
grouped_entry =
104+
Enum.find(autogenerate, fn {fields, _mfa} ->
105+
:inserted_at in fields and :updated_at in fields
106+
end)
107+
108+
assert grouped_entry != nil,
109+
"Expected inserted_at and updated_at to be grouped in a single autogenerate entry, " <>
110+
"got: #{inspect(autogenerate)}"
111+
end
112+
113+
test "resource with no timestamps has empty autogenerate_fields" do
114+
defmodule NoTimestampResource do
115+
@moduledoc false
116+
use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets
117+
118+
attributes do
119+
uuid_primary_key :id
120+
attribute :name, :string
121+
end
122+
end
123+
124+
autogen_fields = NoTimestampResource.__schema__(:autogenerate_fields)
125+
assert autogen_fields == []
126+
end
127+
end
128+
end

0 commit comments

Comments
 (0)