Skip to content
Open
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ spark_locals_without_parens = [
store_action_name?: 1,
store_resource_identifier?: 1,
table_name: 1,
version_extensions: 1
version_extensions: 1,
version_resource: 1
]

[
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-AshPaperTrail.Resource.md
Comment thread
M-Gonzalo marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ A section for configuring how versioning is derived for the resource.
| [`store_resource_identifier?`](#paper_trail-store_resource_identifier?){: #paper_trail-store_resource_identifier? } | `boolean` | `false` | Whether or not to add the `version_resource_identifier` attribute to the version resource. This is useful for auditing purposes. |
| [`resource_identifier`](#paper_trail-resource_identifier){: #paper_trail-resource_identifier } | `atom` | | A name to use for this resource in the `version_resource_identifier`. Defaults to `Ash.Resource.Info.short_name/1`. |
| [`version_extensions`](#paper_trail-version_extensions){: #paper_trail-version_extensions } | `keyword` | `[]` | Extensions that should be used by the version resource. For example: `extensions: [AshGraphql.Resource], notifier: [Ash.Notifiers.PubSub]` |
| [`version_resource`](#paper_trail-version_resource){: #paper_trail-version_resource } | `atom` | | The explicit module to use for the generated version resource. By default, the version resource is named `X.Version` for a given resource `X`. Use this option when your application already defines `X.Version` and you want the paper trail versions to live in a different module. |
| [`table_name`](#paper_trail-table_name){: #paper_trail-table_name } | `String.t` | | The table to use to store versions if using a SQL-based data layer, derived if not set |
| [`public_timestamps?`](#paper_trail-public_timestamps?){: #paper_trail-public_timestamps? } | `boolean` | `false` | Whether of not to make the version resource's timestamps public |

Expand Down
54 changes: 40 additions & 14 deletions lib/ash_paper_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,49 @@ defmodule AshPaperTrail do
apply(m, f, a) || allow_resource_versions(nil, resource)
end

@regex ~r/\.Version$/
def allow_resource_versions(nil, resource) do
resource_name = to_string(resource)

if String.match?(resource_name, @regex) do
original_resource =
try do
resource_name
|> String.replace(@regex, "")
|> String.to_existing_atom()
rescue
ArgumentError -> false
end
def allow_resource_versions(nil, resource) when not is_atom(resource), do: false

def allow_resource_versions(nil, resource) when is_atom(resource) do
if safe_resource_version?(resource) do
if function_exported?(resource, :version_of, 0) do
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, you don't need it due to safe_version_of

case safe_version_of(resource) do
{:ok, original_resource} ->
AshPaperTrail.Resource in Spark.extensions(original_resource)

original_resource && AshPaperTrail.Resource in Spark.extensions(original_resource)
:error ->
false
end
else
relationship_fallback(resource)
end
else
false
end
end

defp safe_resource_version?(resource) do
resource.resource_version?()
rescue
_ ->
false
end

defp safe_version_of(resource) do
{:ok, resource.version_of()}
rescue
_ ->
:error
end

defp relationship_fallback(resource) do
relationships = Ash.Resource.Info.relationships(resource)

case Enum.find(relationships, &(&1.name == :version_source and &1.type == :belongs_to)) do
nil ->
false

relationship ->
AshPaperTrail.Resource in Spark.extensions(relationship.destination)
end
end
end
14 changes: 9 additions & 5 deletions lib/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ defmodule AshPaperTrail.Resource.Info do
end

@spec version_resource(Spark.Dsl.t() | Ash.Resource.t()) :: Ash.Resource.t()
def version_resource(resource) do
if is_atom(resource) do
def version_resource(resource) when is_atom(resource) do
Spark.Dsl.Extension.get_opt(resource, [:paper_trail], :version_resource, nil) ||
Module.concat([resource, Version])
else
Module.concat([Spark.Dsl.Extension.get_persisted(resource, :module), Version])
end
end

def version_resource(resource) do
module = Spark.Dsl.Extension.get_persisted(resource, :module)

Spark.Dsl.Extension.get_opt(resource, [:paper_trail], :version_resource, nil) ||
Module.concat([module, Version])
end

@spec public_timestamps?(Spark.Dsl.t() | Ash.Resource.t()) :: boolean
Expand Down
6 changes: 6 additions & 0 deletions lib/resource/resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ defmodule AshPaperTrail.Resource do
Extensions that should be used by the version resource. For example: `extensions: [AshGraphql.Resource], notifier: [Ash.Notifiers.PubSub]`
"""
],
version_resource: [
type: :atom,
doc: """
The explicit module to use for the generated version resource. By default, the version resource is named `X.Version` for a given resource `X`. Use this option when your application already defines `X.Version` and you want the paper trail versions to live in a different module.
"""
],
table_name: [
type: :string,
required: false,
Expand Down
2 changes: 2 additions & 0 deletions lib/resource/transformers/create_version_resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ defmodule AshPaperTrail.Resource.Transformers.CreateVersionResource do

def resource_version?, do: true

def version_of, do: unquote(module)

if unquote(multitenant?) do
multitenancy do
strategy(unquote(Ash.Resource.Info.multitenancy_strategy(dsl_state)))
Expand Down
86 changes: 86 additions & 0 deletions test/ash_paper_trail/version_resource_naming_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule AshPaperTrail.VersionResourceNamingTest do
use ExUnit.Case

setup_all do
# Ensure the support resource modules used by these tests are compiled and loaded
# deterministically before any tests run. This avoids intermittent failures where
# compile/load ordering leaves the Source module undefined at assertion time.
for mod <- [
AshPaperTrail.Test.VersionNaming.Source,
AshPaperTrail.Test.VersionNaming.Source.Version,
AshPaperTrail.Test.VersionNaming.SourceVersionResource
] do
case Code.ensure_compiled(mod) do
{:module, _} -> :ok
{:error, reason} -> raise "Failed to ensure compiled #{inspect(mod)}: #{inspect(reason)}"
end
end

:ok
end

alias AshPaperTrail.Test.VersionNaming.Domain
alias AshPaperTrail.Test.VersionNaming.Source
alias AshPaperTrail.Test.VersionNaming.Source.Version, as: SourceVersion
alias AshPaperTrail.Test.VersionNaming.SourceVersionResource
alias AshPaperTrail.Test.VersionOf.ManualVersionWithoutPaperTrail

describe "generated version resource naming" do
test "uses explicit version_resource module and does not conflict with an app-defined Source.Version" do
# Explicit version resource behaves like a version resource
assert SourceVersionResource.resource_version?()

# App-defined Source.Version is a regular resource, not a version resource
refute function_exported?(SourceVersion, :resource_version?, 0)

# Source itself must still be a valid resource
assert function_exported?(Source, :__info__, 1)
end

test "version_resource/1 returns the explicit version_resource module for Source" do
assert AshPaperTrail.Resource.Info.version_resource(Source) == SourceVersionResource
end

test "version_resource/1 falls back to X.Version for version modules themselves" do
assert AshPaperTrail.Resource.Info.version_resource(SourceVersion) ==
Module.concat([SourceVersion, Version])
end

test "writes versions for Source into the explicitly configured version resource module" do
Ash.create!(Source, %{name: "named source"}, domain: Domain)

versions_in_explicit_module =
Ash.read!(SourceVersionResource, domain: Domain)

versions_in_app_defined_version =
Ash.read!(SourceVersion, domain: Domain)

assert length(versions_in_explicit_module) == 1

assert Enum.at(versions_in_explicit_module, 0).changes[:name] == "named source"

assert versions_in_app_defined_version == []
end

test "generated version resource defines version_of/0 returning the original resource" do
# Calling version_of/0 forces the module to be loaded and ensures the function exists
assert SourceVersionResource.version_of() == Source
end

test "allow_resource_versions/2 accepts generated version resources via version_of/0" do
assert AshPaperTrail.allow_resource_versions(nil, SourceVersionResource)
end
end

describe "allow_resource_versions/2 with manual version_of/0" do
test "rejects version modules whose version_of/0 points to a non-paper-trail resource" do
# These calls both verify the functions exist and force module loading
assert ManualVersionWithoutPaperTrail.resource_version?()

assert ManualVersionWithoutPaperTrail.version_of() ==
AshPaperTrail.Test.VersionOf.NonPaperTrailResource

refute AshPaperTrail.allow_resource_versions(nil, ManualVersionWithoutPaperTrail)
end
end
end
12 changes: 12 additions & 0 deletions test/support/version_naming/domain.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule AshPaperTrail.Test.VersionNaming.Domain do
@moduledoc false

use Ash.Domain,
extensions: [AshPaperTrail.Domain],
validate_config_inclusion?: false

resources do
resource AshPaperTrail.Test.VersionNaming.Source
resource AshPaperTrail.Test.VersionNaming.Source.Version
end
end
33 changes: 33 additions & 0 deletions test/support/version_naming/source.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule AshPaperTrail.Test.VersionNaming.Source do
@moduledoc false

use Ash.Resource,
domain: AshPaperTrail.Test.VersionNaming.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshPaperTrail.Resource],
validate_domain_inclusion?: false

ets do
private? true
end

attributes do
uuid_primary_key :id

attribute :name, :string do
allow_nil? false
public? true
end
end

actions do
default_accept :*
defaults [:create, :read, :update, :destroy]
end

paper_trail do
# Explicitly configure a different version resource module to avoid
# colliding with the app-defined Source.Version.
version_resource(AshPaperTrail.Test.VersionNaming.SourceVersionResource)
end
end
27 changes: 27 additions & 0 deletions test/support/version_naming/source_version.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule AshPaperTrail.Test.VersionNaming.Source.Version do
@moduledoc """
Test version resource for version naming tests.
"""
use Ash.Resource,
domain: AshPaperTrail.Test.VersionNaming.Domain,
data_layer: Ash.DataLayer.Ets,
validate_domain_inclusion?: false

ets do
private? true
end

attributes do
uuid_primary_key :id

attribute :label, :string do
allow_nil? false
public? true
end
end

actions do
default_accept :*
defaults [:create, :read, :update, :destroy]
end
end
37 changes: 37 additions & 0 deletions test/support/version_of/manual_version_without_paper_trail.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule AshPaperTrail.Test.VersionOf.NonPaperTrailResource do
@moduledoc false

use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets,
validate_domain_inclusion?: false

ets do
private? true
end

attributes do
uuid_primary_key :id

attribute :name, :string do
allow_nil? false
public? true
end
end

actions do
default_accept :*
defaults [:create, :read, :update, :destroy]
end
end

defmodule AshPaperTrail.Test.VersionOf.ManualVersionWithoutPaperTrail do
@moduledoc false

alias AshPaperTrail.Test.VersionOf.NonPaperTrailResource

# Mimics a version resource (has resource_version?/0 and version_of/0)
# but its underlying resource does NOT use AshPaperTrail.Resource.
def resource_version?, do: true
def version_of, do: NonPaperTrailResource
end