Packages

phoenix_kit

1.7.78
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules shop slug_resolver.ex
Raw

lib/modules/shop/slug_resolver.ex

defmodule PhoenixKit.Modules.Shop.SlugResolver do
@moduledoc """
Resolves URL slugs to Products and Categories with language awareness.
This module provides language-aware slug resolution for the Shop module,
supporting per-language URL slugs for SEO optimization.
## Features
- Per-language SEO-friendly URL slugs
- Fallback to canonical slug when translation not found
- Base code matching (e.g., "en" matches "en-US")
- Efficient queries using JSONB operators
## URL Architecture
```
/shop/products/geometric-planter # Default language
/es/shop/products/maceta-geometrica # Spanish (SEO slug)
/ru/shop/products/geometricheskoe-kashpo # Russian (SEO slug)
```
## Usage Examples
# Find product by slug in specific language
SlugResolver.find_product_by_slug("maceta-geometrica", "es-ES")
# => {:ok, %Product{}}
# Find product with base code (resolves to full dialect)
SlugResolver.find_product_by_slug("geometric-planter", "en")
# => {:ok, %Product{}} (matches en-US via base code)
# Find category by slug
SlugResolver.find_category_by_slug("jarrones-macetas", "es-ES")
# => {:ok, %Category{}}
## Query Behavior
The resolver checks both translated slugs and canonical slugs:
1. First tries `translations->'language'->>'slug' = ?`
2. Falls back to canonical `slug = ?`
This ensures URLs work even for products without translations.
"""
import Ecto.Query
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Shop.Category
alias PhoenixKit.Modules.Shop.Product
alias PhoenixKit.Modules.Shop.Translations
# ============================================================================
# Product Slug Resolution
# ============================================================================
@doc """
Finds a product by URL slug for a specific language.
## Parameters
- `url_slug` - The URL slug to search for
- `language` - Language code (supports both "es-ES" and base codes like "en")
- `opts` - Optional keyword list:
- `:preload` - Associations to preload (default: [])
- `:status` - Filter by status (e.g., "active")
## Examples
iex> SlugResolver.find_product_by_slug("maceta-geometrica", "es-ES")
{:ok, %Product{title: "Maceta Geométrica", ...}}
iex> SlugResolver.find_product_by_slug("geometric-planter", "en")
{:ok, %Product{}} # Matches en-US via base code resolution
iex> SlugResolver.find_product_by_slug("nonexistent", "en-US")
{:error, :not_found}
## Query Details
The query checks both:
1. Translated slug: `translations->'lang'->>'slug'`
2. Canonical slug: `slug` column
This ensures backward compatibility with products that have no translations.
"""
@spec find_product_by_slug(String.t(), String.t(), keyword()) ::
{:ok, Product.t()} | {:error, :not_found}
def find_product_by_slug(url_slug, language, opts \\ []) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
# Localized fields: slug is a JSONB map like %{"en" => "planter", "ru" => "kashpo"}
# Search for exact language match or fallback to default language
query =
from(p in Product,
where:
fragment(
"slug->>? = ?",
^lang,
^url_slug
),
limit: 1
)
query =
if status do
from(p in query, where: p.status == ^status)
else
query
end
query =
if preload != [] do
from(p in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil -> {:error, :not_found}
product -> {:ok, product}
end
end
@doc """
Finds a product by slug, requiring exact language match.
Unlike `find_product_by_slug/3`, this does not fall back to canonical slug.
Useful when you need to ensure the translation exists.
## Examples
iex> SlugResolver.find_product_by_translated_slug("maceta-geometrica", "es-ES")
{:ok, %Product{}}
iex> SlugResolver.find_product_by_translated_slug("maceta-geometrica", "en-US")
{:error, :not_found} # No fallback to canonical
"""
@spec find_product_by_translated_slug(String.t(), String.t(), keyword()) ::
{:ok, Product.t()} | {:error, :not_found}
def find_product_by_translated_slug(url_slug, language, opts \\ []) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
# Localized fields: slug is a JSONB map
query =
from(p in Product,
where:
fragment(
"slug->>? = ?",
^lang,
^url_slug
),
limit: 1
)
query =
if preload != [] do
from(p in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil -> {:error, :not_found}
product -> {:ok, product}
end
end
# ============================================================================
# Category Slug Resolution
# ============================================================================
@doc """
Finds a category by URL slug for a specific language.
## Parameters
- `url_slug` - The URL slug to search for
- `language` - Language code (supports both full and base codes)
- `opts` - Optional keyword list:
- `:preload` - Associations to preload (default: [])
- `:status` - Filter by status (e.g., "active")
## Examples
iex> SlugResolver.find_category_by_slug("jarrones-macetas", "es-ES")
{:ok, %Category{name: "Jarrones y Macetas", ...}}
iex> SlugResolver.find_category_by_slug("vases-planters", "en")
{:ok, %Category{}}
iex> SlugResolver.find_category_by_slug("nonexistent", "en-US")
{:error, :not_found}
"""
@spec find_category_by_slug(String.t(), String.t(), keyword()) ::
{:ok, Category.t()} | {:error, :not_found}
def find_category_by_slug(url_slug, language, opts \\ []) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
# Localized fields: slug is a JSONB map
query =
from(c in Category,
where:
fragment(
"slug->>? = ?",
^lang,
^url_slug
),
limit: 1
)
query =
if status do
from(c in query, where: c.status == ^status)
else
query
end
query =
if preload != [] do
from(c in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil -> {:error, :not_found}
category -> {:ok, category}
end
end
@doc """
Finds a category by slug, requiring exact language match.
Does not fall back to canonical slug.
## Examples
iex> SlugResolver.find_category_by_translated_slug("jarrones-macetas", "es-ES")
{:ok, %Category{}}
"""
@spec find_category_by_translated_slug(String.t(), String.t(), keyword()) ::
{:ok, Category.t()} | {:error, :not_found}
def find_category_by_translated_slug(url_slug, language, opts \\ []) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
# Localized fields: slug is a JSONB map
query =
from(c in Category,
where:
fragment(
"slug->>? = ?",
^lang,
^url_slug
),
limit: 1
)
query =
if preload != [] do
from(c in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil -> {:error, :not_found}
category -> {:ok, category}
end
end
# ============================================================================
# Batch Resolution
# ============================================================================
@doc """
Finds multiple products by their slugs for a specific language.
Useful for preloading products in listing pages.
## Examples
iex> SlugResolver.find_products_by_slugs(["planter-1", "planter-2"], "en-US")
[%Product{}, %Product{}]
"""
@spec find_products_by_slugs([String.t()], String.t(), keyword()) :: [Product.t()]
def find_products_by_slugs(url_slugs, language, opts \\ []) when is_list(url_slugs) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
# Localized fields: slug is a JSONB map
query =
from(p in Product,
where:
fragment(
"slug->>? = ANY(?)",
^lang,
^url_slugs
)
)
query =
if status do
from(p in query, where: p.status == ^status)
else
query
end
query =
if preload != [] do
from(p in query, preload: ^preload)
else
query
end
repo().all(query)
end
@doc """
Finds multiple categories by their slugs for a specific language.
## Examples
iex> SlugResolver.find_categories_by_slugs(["cat-1", "cat-2"], "en-US")
[%Category{}, %Category{}]
"""
@spec find_categories_by_slugs([String.t()], String.t(), keyword()) :: [Category.t()]
def find_categories_by_slugs(url_slugs, language, opts \\ []) when is_list(url_slugs) do
lang = normalize_language(language)
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
# Localized fields: slug is a JSONB map
query =
from(c in Category,
where:
fragment(
"slug->>? = ANY(?)",
^lang,
^url_slugs
)
)
query =
if status do
from(c in query, where: c.status == ^status)
else
query
end
query =
if preload != [] do
from(c in query, preload: ^preload)
else
query
end
repo().all(query)
end
# ============================================================================
# Slug Existence Checks
# ============================================================================
@doc """
Checks if a product slug exists for a specific language.
Useful for slug validation during product creation/editing.
## Parameters
- `slug` - The slug to check
- `language` - Language code
- `exclude_uuid` - Product UUID to exclude from check (for edits)
## Examples
iex> SlugResolver.product_slug_exists?("geometric-planter", "en-US")
true
iex> SlugResolver.product_slug_exists?("geometric-planter", "en-US", exclude_uuid: "some-uuid")
false # Excludes product with given UUID from check
"""
@spec product_slug_exists?(String.t(), String.t(), keyword()) :: boolean()
def product_slug_exists?(slug, language, opts \\ []) do
lang = normalize_language(language)
exclude_uuid = Keyword.get(opts, :exclude_uuid)
# Localized fields: slug is a JSONB map
query =
from(p in Product,
where:
fragment(
"slug->>? = ?",
^lang,
^slug
),
select: count(p.uuid)
)
query =
if is_binary(exclude_uuid) && match?({:ok, _}, Ecto.UUID.cast(exclude_uuid)) do
from(p in query, where: p.uuid != ^exclude_uuid)
else
query
end
repo().one(query) > 0
end
@doc """
Checks if a category slug exists for a specific language.
## Examples
iex> SlugResolver.category_slug_exists?("vases-planters", "en-US")
true
"""
@spec category_slug_exists?(String.t(), String.t(), keyword()) :: boolean()
def category_slug_exists?(slug, language, opts \\ []) do
lang = normalize_language(language)
exclude_uuid = Keyword.get(opts, :exclude_uuid)
# Localized fields: slug is a JSONB map
query =
from(c in Category,
where:
fragment(
"slug->>? = ?",
^lang,
^slug
),
select: count(c.uuid)
)
query =
if is_binary(exclude_uuid) && match?({:ok, _}, Ecto.UUID.cast(exclude_uuid)) do
from(c in query, where: c.uuid != ^exclude_uuid)
else
query
end
repo().one(query) > 0
end
# ============================================================================
# URL Generation
# ============================================================================
@doc """
Gets the best slug for a product in a specific language.
Returns translated slug if available, otherwise canonical slug.
## Examples
iex> SlugResolver.product_slug(product, "es-ES")
"maceta-geometrica"
iex> SlugResolver.product_slug(product, "fr-FR")
"geometric-planter" # Falls back to canonical
"""
@spec product_slug(Product.t(), String.t()) :: String.t() | nil
def product_slug(%Product{} = product, language) do
lang = normalize_language(language)
slug_map = product.slug || %{}
# Localized fields approach: slug is directly a map
slug_map[lang] || slug_map[default_language()] || first_slug(slug_map)
end
@doc """
Gets the best slug for a category in a specific language.
Returns translated slug if available, otherwise canonical slug.
## Examples
iex> SlugResolver.category_slug(category, "es-ES")
"jarrones-macetas"
"""
@spec category_slug(Category.t(), String.t()) :: String.t() | nil
def category_slug(%Category{} = category, language) do
lang = normalize_language(language)
slug_map = category.slug || %{}
# Localized fields approach: slug is directly a map
slug_map[lang] || slug_map[default_language()] || first_slug(slug_map)
end
# ============================================================================
# Cross-Language Slug Resolution
# ============================================================================
@doc """
Finds a product by slug in any language.
Searches across all translated slugs to find the product.
Useful for cross-language redirect when user visits with a slug
from a different language.
## Parameters
- `url_slug` - The URL slug to search for
- `opts` - Optional keyword list:
- `:preload` - Associations to preload (default: [])
- `:status` - Filter by status (e.g., "active")
## Examples
iex> SlugResolver.find_product_by_any_slug("maceta-geometrica")
{:ok, %Product{}, "es"} # Returns product with language that matched
iex> SlugResolver.find_product_by_any_slug("geometric-planter")
{:ok, %Product{}, "en"}
iex> SlugResolver.find_product_by_any_slug("nonexistent")
{:error, :not_found}
"""
@spec find_product_by_any_slug(String.t(), keyword()) ::
{:ok, Product.t(), String.t()} | {:error, :not_found}
def find_product_by_any_slug(url_slug, opts \\ []) do
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
# Search across all language slugs using JSONB query
# slug is a JSONB map like %{"en" => "planter", "ru" => "kashpo", "es" => "maceta"}
query =
from(p in Product,
where:
fragment(
"EXISTS (SELECT 1 FROM jsonb_each_text(slug) WHERE value = ?)",
^url_slug
),
limit: 1
)
query =
if status do
from(p in query, where: p.status == ^status)
else
query
end
query =
if preload != [] do
from(p in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil ->
{:error, :not_found}
product ->
# Find which language matched
matched_lang = find_matching_language(product.slug || %{}, url_slug)
{:ok, product, matched_lang}
end
end
@doc """
Finds a category by slug in any language.
## Examples
iex> SlugResolver.find_category_by_any_slug("jarrones-macetas")
{:ok, %Category{}, "es"}
"""
@spec find_category_by_any_slug(String.t(), keyword()) ::
{:ok, Category.t(), String.t()} | {:error, :not_found}
def find_category_by_any_slug(url_slug, opts \\ []) do
preload = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
query =
from(c in Category,
where:
fragment(
"EXISTS (SELECT 1 FROM jsonb_each_text(slug) WHERE value = ?)",
^url_slug
),
limit: 1
)
query =
if status do
from(c in query, where: c.status == ^status)
else
query
end
query =
if preload != [] do
from(c in query, preload: ^preload)
else
query
end
case repo().one(query) do
nil ->
{:error, :not_found}
category ->
matched_lang = find_matching_language(category.slug || %{}, url_slug)
{:ok, category, matched_lang}
end
end
# Find which language key contains the matching slug
defp find_matching_language(slug_map, slug) do
Enum.find_value(slug_map, default_language(), fn {lang, lang_slug} ->
if lang_slug == slug, do: lang, else: nil
end)
end
# ============================================================================
# Private Helpers
# ============================================================================
@doc """
Normalizes a language code to dialect format.
Converts base codes to full dialect (e.g., "en" -> "en-US").
Used by import system to ensure consistent language keys in JSONB fields.
"""
def normalize_language_public(lang) when is_binary(lang), do: normalize_language(lang)
# Normalize language code (convert base code to full dialect)
defp normalize_language(lang) when is_binary(lang) do
cond do
# Already a full dialect code (contains hyphen)
String.contains?(lang, "-") ->
lang
# Base code only - convert to dialect
String.length(lang) == 2 ->
DialectMapper.base_to_dialect(lang)
# Unknown format - use as-is
true ->
lang
end
end
defp default_language do
Translations.default_language()
end
defp first_slug(map) when map == %{}, do: nil
defp first_slug(map) do
map |> Map.values() |> List.first()
end
defp repo, do: PhoenixKit.RepoHelper.repo()
end