From 577b7f4548ff18bbf191ca2cd91c852d18713006 Mon Sep 17 00:00:00 2001 From: dev-guy Date: Mon, 14 Jul 2025 10:39:44 -0500 Subject: [PATCH] Unique slugs. Revert standard attribute definition to macro --- lib/geo/geography/country.ex | 6 +-- lib/geo/resources/attributes/id.ex | 30 ++--------- lib/geo/resources/changes/slugify_name.ex | 61 ++++++++++++++++++++++- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/lib/geo/geography/country.ex b/lib/geo/geography/country.ex index bb8f75b..2f31258 100644 --- a/lib/geo/geography/country.ex +++ b/lib/geo/geography/country.ex @@ -2,13 +2,13 @@ defmodule Geo.Geography.Country do use Ash.Resource, otp_app: :geo, domain: Geo.Geography, - data_layer: AshPostgres.DataLayer, - extensions: [Geo.Resources.Attributes.Id] + data_layer: AshPostgres.DataLayer # === Attributes === + use Geo.Resources.Attributes.Id + use Geo.Resources.Attributes.Timestamps use Geo.Resources.Attributes.Name, allow_nil?: false, unique?: true use Geo.Resources.Attributes.Slug, allow_nil?: false, unique?: true - use Geo.Resources.Attributes.Timestamps attributes do attribute :iso_code, :ci_string do diff --git a/lib/geo/resources/attributes/id.ex b/lib/geo/resources/attributes/id.ex index 1a169a0..32f595b 100644 --- a/lib/geo/resources/attributes/id.ex +++ b/lib/geo/resources/attributes/id.ex @@ -1,32 +1,12 @@ defmodule Geo.Resources.Attributes.Id do @moduledoc """ - An Ash extension that adds a UUID v7 primary key attribute to a resource. """ - use Spark.Dsl.Extension, - transformers: [ - __MODULE__.Transformer - ] - - defmodule Transformer do - @moduledoc false - use Spark.Dsl.Transformer - - def before?(Ash.Resource.Transformers.BelongsToAttribute), do: true - def before?(_), do: false - - def transform(dsl_state) do - attribute = %Ash.Resource.Attribute{ - name: :id, - type: :uuid_v7, - allow_nil?: false, - writable?: false, - public?: true, - primary_key?: true, - default: &Ash.UUIDv7.generate/0 - } - - {:ok, Spark.Dsl.Transformer.add_entity(dsl_state, [:attributes], attribute)} + defmacro __using__(_opts) do + quote do + attributes do + uuid_v7_primary_key :id + end end end end diff --git a/lib/geo/resources/changes/slugify_name.ex b/lib/geo/resources/changes/slugify_name.ex index be0ad5d..1777e2a 100644 --- a/lib/geo/resources/changes/slugify_name.ex +++ b/lib/geo/resources/changes/slugify_name.ex @@ -28,8 +28,9 @@ defmodule Geo.Resources.Changes.SlugifyName do # If we have a name but no slug, generate one not is_nil(name) -> - generated_slug = slugify(name) - Ash.Changeset.force_change_attribute(changeset, :slug, generated_slug) + base_slug = slugify(name) + unique_slug = ensure_unique_slug(changeset, base_slug) + Ash.Changeset.force_change_attribute(changeset, :slug, unique_slug) # Otherwise, leave as is true -> @@ -37,6 +38,62 @@ defmodule Geo.Resources.Changes.SlugifyName do end end + defp ensure_unique_slug(changeset, base_slug) do + # Try base slug first + if !slug_exists?(changeset, base_slug) do + base_slug + else + find_unique_slug_with_number(changeset, base_slug) + end + end + + defp find_unique_slug_with_number(changeset, base_slug) do + # Range 1: 1-9 + case find_in_range(changeset, base_slug, 1..9) do + {:found, number} -> "#{base_slug}-#{number}" + :not_found -> + # Range 2: 1-99 + case find_in_range(changeset, base_slug, 1..99) do + {:found, number} -> "#{base_slug}-#{number}" + :not_found -> + # Range 3: 1-9999 + case find_in_range(changeset, base_slug, 1..9999) do + {:found, number} -> "#{base_slug}-#{number}" + :not_found -> raise "Could not find unique slug after trying up to 9999" + end + end + end + end + + defp find_in_range(changeset, base_slug, range) do + Enum.find_value(range, :not_found, fn number -> + slug_candidate = "#{base_slug}-#{number}" + if !slug_exists?(changeset, slug_candidate) do + {:found, number} + end + end) + end + + defp slug_exists?(changeset, slug) do + resource = changeset.resource + query = Ash.Query.filter(resource, slug: slug) + + # Exclude the current record if it's an update + query = + case changeset.data do + %{id: id} when not is_nil(id) -> + Ash.Query.filter(query, id != ^id) + _ -> + query + end + + case Ash.read(query) do + {:ok, []} -> false + {:ok, _} -> true + {:error, _} -> false + end + end + defp slugify(text) when is_binary(text) do text |> String.downcase()