From 7874074f3de3d4fccc99193c7be636b3217a08b6 Mon Sep 17 00:00:00 2001 From: Henry Zhan Date: Thu, 5 Mar 2026 15:39:54 -0600 Subject: [PATCH] feat: add --use_fragments option to resource generator Add a new `--use_fragments` (`-f`) flag to `mix ash_postgres.gen.resources` that generates attributes and relationships in a separate fragment file. This allows the fragment to be regenerated without affecting user customizations in the main resource file. When enabled: - Creates fragment at `{Resource}.Model` (e.g., `MyApp.Accounts.User.Model`) - Fragment contains attributes, relationships, and identities - Main resource includes `fragments: [FragmentModule]` option - If resource already exists, only regenerates the fragment --- lib/mix/tasks/ash_postgres.gen.resources.ex | 10 +- lib/resource_generator/resource_generator.ex | 147 +++++++++++- test/resource_generator_test.exs | 239 +++++++++++++++++++ 3 files changed, 392 insertions(+), 4 deletions(-) diff --git a/lib/mix/tasks/ash_postgres.gen.resources.ex b/lib/mix/tasks/ash_postgres.gen.resources.ex index 51e2c5a3..7e7f2e8e 100644 --- a/lib/mix/tasks/ash_postgres.gen.resources.ex +++ b/lib/mix/tasks/ash_postgres.gen.resources.ex @@ -34,6 +34,7 @@ if Code.ensure_loaded?(Igniter) do - `public` - Mark all attributes and relationships as `public? true`. Defaults to `true`. - `no-migrations` - Do not generate snapshots & migrations for the resources. Defaults to `false`. - `skip-unknown` - Skip any attributes with types that we don't have a corresponding Elixir type for, and relationships that we can't assume the name of. + - `use-fragments`, `f` - Generate attributes and relationships in a separate fragment file. This allows the fragment to be regenerated without affecting user customizations in the main resource file. Defaults to `false`. ## Tables @@ -61,7 +62,8 @@ if Code.ensure_loaded?(Igniter) do skip_unknown: :boolean, migrations: :boolean, snapshots_only: :boolean, - domain: :keep + domain: :keep, + use_fragments: :boolean ], aliases: [ t: :tables, @@ -69,12 +71,14 @@ if Code.ensure_loaded?(Igniter) do r: :repo, e: :extend, d: :domain, - s: :skip_tables + s: :skip_tables, + f: :use_fragments ], defaults: [ default_actions: true, migrations: true, - public: true + public: true, + use_fragments: false ] } end diff --git a/lib/resource_generator/resource_generator.ex b/lib/resource_generator/resource_generator.ex index 93576414..8150da45 100644 --- a/lib/resource_generator/resource_generator.ex +++ b/lib/resource_generator/resource_generator.ex @@ -76,7 +76,11 @@ if Code.ensure_loaded?(Igniter) do |> Spec.add_relationships(resources, opts) Enum.reduce(specs, igniter, fn table_spec, igniter -> - table_to_resource(igniter, table_spec, domain, opts) + if opts[:use_fragments] do + table_to_resource_with_fragment(igniter, table_spec, domain, opts) + else + table_to_resource(igniter, table_spec, domain, opts) + end end) end @@ -149,6 +153,147 @@ if Code.ensure_loaded?(Igniter) do end) end + defp table_to_resource_with_fragment( + igniter, + %AshPostgres.ResourceGenerator.Spec{} = table_spec, + domain, + opts + ) do + fragment_module = Module.concat(table_spec.resource, Model) + + fragment_content = + """ + use Spark.Dsl.Fragment, + of: Ash.Resource + + #{attributes_block(table_spec, opts)} + #{identities_block(table_spec)} + #{relationships_block(table_spec, opts)} + """ + + resource_path = Igniter.Project.Module.proper_location(igniter, table_spec.resource) + + resource_exists? = + Rewrite.has_source?(igniter.rewrite, resource_path) || + Igniter.exists?(igniter, resource_path) + + if resource_exists? do + # Only create/update the fragment file + Igniter.Project.Module.create_module(igniter, fragment_module, fragment_content) + else + # Create both resource and fragment + no_migrate_flag = + if opts[:no_migrations] do + "migrate? false" + end + + resource_content = + """ + use Ash.Resource, + domain: #{inspect(domain)}, + data_layer: AshPostgres.DataLayer, + fragments: [#{inspect(fragment_module)}] + + #{default_actions(opts)} + + postgres do + table #{inspect(table_spec.table_name)} + repo #{inspect(table_spec.repo)} + #{schema_option(table_spec)} + #{no_migrate_flag} + #{references(table_spec, opts[:no_migrations])} + #{custom_indexes(table_spec, opts[:no_migrations])} + #{check_constraints(table_spec, opts[:no_migrations])} + #{skip_unique_indexes(table_spec)} + #{identity_index_names(table_spec)} + end + """ + + igniter + |> Igniter.Project.Module.create_module(fragment_module, fragment_content) + |> Igniter.Project.Module.create_module(table_spec.resource, resource_content) + |> Ash.Domain.Igniter.add_resource_reference(domain, table_spec.resource) + |> then(fn igniter -> + if opts[:extend] && opts[:extend] != [] do + Igniter.compose_task(igniter, "ash.patch.extend", [ + table_spec.resource | opts[:extend] || [] + ]) + else + igniter + end + end) + end + end + + defp attributes_block(table_spec, opts) do + """ + attributes do + #{attributes(table_spec, opts)} + end + """ + end + + defp identities_block(%{indexes: indexes}) do + indexes + |> Enum.filter(fn %{unique?: unique?, columns: columns} -> + unique? && Enum.all?(columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) + end) + |> Enum.map(fn index -> + name = index.identity_name + + fields = "[" <> Enum.map_join(index.columns, ", ", &":#{&1}") <> "]" + + case identity_options(index) do + "" -> + "identity :#{name}, #{fields}" + + options -> + """ + identity :#{name}, #{fields} do + #{options} + end + """ + end + end) + |> case do + [] -> + "" + + identities -> + """ + identities do + #{Enum.join(identities, "\n")} + end + """ + end + end + + defp relationships_block(%{relationships: []}, _opts), do: "" + + defp relationships_block(%{relationships: relationships} = spec, opts) do + relationships + |> Enum.map_join("\n", fn relationship -> + case relationship_options(spec, relationship, opts) do + "" -> + "#{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)}" + + options -> + """ + #{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)} do + #{options} + end + """ + end + end) + |> then(fn rels -> + """ + relationships do + #{rels} + end + """ + end) + end + defp schema_option(%{schema: schema}) when schema != "public" do "schema #{inspect(schema)}" end diff --git a/test/resource_generator_test.exs b/test/resource_generator_test.exs index 0aa92e1d..c722e1fb 100644 --- a/test/resource_generator_test.exs +++ b/test/resource_generator_test.exs @@ -196,4 +196,243 @@ defmodule AshPostgres.ResourceGeenratorTests do end """) end + + describe "--use_fragments option" do + @describetag :use_fragments + + test "generates resource and fragment files when resource does not exist" do + test_project() + |> Igniter.compose_task("ash_postgres.gen.resources", [ + "MyApp.Accounts", + "--tables", + "example_table", + "--yes", + "--repo", + "AshPostgres.TestRepo", + "--use-fragments" + ]) + |> assert_creates("lib/my_app/accounts/example_table.ex", """ + defmodule MyApp.Accounts.ExampleTable do + use Ash.Resource, + domain: MyApp.Accounts, + data_layer: AshPostgres.DataLayer, + fragments: [MyApp.Accounts.ExampleTable.Model] + + actions do + defaults([:read, :destroy, create: :*, update: :*]) + end + + postgres do + table("example_table") + repo(AshPostgres.TestRepo) + end + end + """) + |> assert_creates("lib/my_app/accounts/example_table/model.ex", """ + defmodule MyApp.Accounts.ExampleTable.Model do + use Spark.Dsl.Fragment, + of: Ash.Resource + + attributes do + uuid_primary_key :id do + public?(true) + end + + attribute :name, :string do + public?(true) + end + + attribute :age, :integer do + public?(true) + end + + attribute :email, :string do + sensitive?(true) + public?(true) + end + end + end + """) + end + + @tag :use_fragments + test "generates fragment with relationships" do + AshPostgres.TestRepo.query!("CREATE SCHEMA IF NOT EXISTS fragtest") + + AshPostgres.TestRepo.query!("DROP TABLE IF EXISTS fragtest.orders CASCADE") + AshPostgres.TestRepo.query!("DROP TABLE IF EXISTS fragtest.customers CASCADE") + + AshPostgres.TestRepo.query!(""" + CREATE TABLE fragtest.customers ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + name VARCHAR(255) NOT NULL + ) + """) + + AshPostgres.TestRepo.query!(""" + CREATE TABLE fragtest.orders ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + customer_id UUID REFERENCES fragtest.customers(id), + total INTEGER + ) + """) + + test_project() + |> Igniter.compose_task("ash_postgres.gen.resources", [ + "MyApp.Sales", + "--tables", + "fragtest.customers,fragtest.orders", + "--yes", + "--repo", + "AshPostgres.TestRepo", + "--use-fragments" + ]) + |> assert_creates("lib/my_app/sales/customer.ex", """ + defmodule MyApp.Sales.Customer do + use Ash.Resource, + domain: MyApp.Sales, + data_layer: AshPostgres.DataLayer, + fragments: [MyApp.Sales.Customer.Model] + + actions do + defaults([:read, :destroy, create: :*, update: :*]) + end + + postgres do + table("customers") + repo(AshPostgres.TestRepo) + schema("fragtest") + end + end + """) + |> assert_creates("lib/my_app/sales/customer/model.ex", """ + defmodule MyApp.Sales.Customer.Model do + use Spark.Dsl.Fragment, + of: Ash.Resource + + attributes do + uuid_primary_key :id do + public?(true) + end + + uuid_primary_key :id do + public?(true) + end + + attribute :name, :string do + public?(true) + end + + attribute :name, :string do + allow_nil?(false) + public?(true) + end + end + + relationships do + has_many :orders, MyApp.Sales.Order do + public?(true) + end + end + end + """) + |> assert_creates("lib/my_app/sales/order/model.ex", """ + defmodule MyApp.Sales.Order.Model do + use Spark.Dsl.Fragment, + of: Ash.Resource + + attributes do + uuid_primary_key :id do + public?(true) + end + + uuid_primary_key :id do + public?(true) + end + + attribute :total, :integer do + public?(true) + end + end + + relationships do + belongs_to :customer, MyApp.Sales.Customer do + allow_nil?(false) + public?(true) + end + end + end + """) + end + + @tag :use_fragments + test "only regenerates fragment when resource already exists" do + # Create a pre-existing resource file with user customization + existing_resource = """ + defmodule MyApp.Accounts.ExampleTable do + use Ash.Resource, + domain: MyApp.Accounts, + data_layer: AshPostgres.DataLayer, + fragments: [MyApp.Accounts.ExampleTable.Model] + + # User customization that should be preserved + actions do + defaults([:read, :destroy, create: :*, update: :*]) + + create :custom_create do + accept [:name] + end + end + + postgres do + table("example_table") + repo(AshPostgres.TestRepo) + end + end + """ + + test_project( + files: %{ + "lib/my_app/accounts/example_table.ex" => existing_resource + } + ) + |> Igniter.compose_task("ash_postgres.gen.resources", [ + "MyApp.Accounts", + "--tables", + "example_table", + "--yes", + "--repo", + "AshPostgres.TestRepo", + "--use-fragments" + ]) + # Resource should NOT be modified (it already exists) + |> assert_unchanged("lib/my_app/accounts/example_table.ex") + # Fragment should still be created + |> assert_creates("lib/my_app/accounts/example_table/model.ex", """ + defmodule MyApp.Accounts.ExampleTable.Model do + use Spark.Dsl.Fragment, + of: Ash.Resource + + attributes do + uuid_primary_key :id do + public?(true) + end + + attribute :name, :string do + public?(true) + end + + attribute :age, :integer do + public?(true) + end + + attribute :email, :string do + sensitive?(true) + public?(true) + end + end + end + """) + end + end end