Packages
ash_meilisearch
0.1.0
An Ash extension that brings full-text search to your resources via Meilisearch.
Current section
Files
Jump to
Current section
Files
lib/ash_meilisearch/index_manager.ex
defmodule AshMeilisearch.IndexManager do
@moduledoc """
Manages Meilisearch index lifecycle including creation, validation, and updates.
Handles environment-specific behavior:
- prod: Strict validation, errors if settings don't match
- dev/test: Auto-create and auto-update index settings
"""
require Logger
alias AshMeilisearch.{Config, Client}
@doc """
Ensure an index exists and has the correct settings for a resource.
This is called during resource validation to set up indexes.
"""
def ensure_index(resource) do
index_name = get_index_name(resource)
settings = build_index_settings(resource)
case get_or_create_index(index_name, settings) do
{:ok, :created} ->
Logger.info("AshMeilisearch: Created index '#{index_name}'")
:ok
{:ok, :exists} ->
validate_index_settings!(resource, index_name, settings)
{:error, reason} ->
raise "AshMeilisearch: Failed to ensure index '#{index_name}': #{inspect(reason)}"
end
end
@doc """
Get the full index name for a resource including environment suffix.
"""
def get_index_name(resource) do
AshMeilisearch.index_name(resource)
end
@doc """
Build Meilisearch index settings from resource configuration.
Returns a map with searchableAttributes, filterableAttributes, sortableAttributes, etc.
"""
def build_index_settings(resource) do
%{
searchableAttributes: AshMeilisearch.Info.searchable_attributes(resource),
filterableAttributes: AshMeilisearch.Info.filterable_attributes(resource),
sortableAttributes: AshMeilisearch.Info.sortable_attributes(resource),
primaryKey: AshMeilisearch.primary_key(resource)
}
|> Enum.reject(fn {_k, v} -> is_nil(v) || v == [] end)
|> Map.new()
end
@doc """
Delete a test index for cleanup.
Only works in test environment for indexes with test suffixes.
"""
def delete_test_index!(resource) do
case Config.environment() do
:test ->
index_name = get_index_name(resource)
if String.contains?(index_name, Config.test_suffix() || "") do
case Client.delete_index(index_name) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.warning(
"AshMeilisearch: Failed to delete test index '#{index_name}': #{inspect(reason)}"
)
# Don't fail cleanup
:ok
end
else
Logger.warning(
"AshMeilisearch: Attempted to delete non-test index '#{index_name}' in test environment"
)
:ok
end
_ ->
Logger.warning("AshMeilisearch: delete_test_index!/1 called outside test environment")
:ok
end
end
# Private functions
defp get_or_create_index(index_name, settings) do
case Client.get_index(index_name) do
{:ok, _index} ->
{:ok, :exists}
{:error, %{"code" => "index_not_found"}} ->
create_index(index_name, settings)
{:error, reason} ->
{:error, reason}
end
end
defp create_index(index_name, settings) do
# Create index with primary key
primary_key = settings[:primaryKey] || "id"
case Client.create_index(index_name, %{primaryKey: primary_key}) do
{:ok, _task} ->
# Update settings after creation
update_index_settings(index_name, Map.delete(settings, :primaryKey))
{:ok, :created}
{:error, reason} ->
{:error, reason}
end
end
defp validate_index_settings!(resource, index_name, expected_settings) do
case Client.get_index_settings(index_name) do
{:ok, current_settings} ->
case Config.environment() do
:prod ->
validate_settings_strict!(resource, index_name, current_settings, expected_settings)
_ ->
validate_settings_lenient!(resource, index_name, current_settings, expected_settings)
end
{:error, reason} ->
raise "AshMeilisearch: Failed to get settings for index '#{index_name}': #{inspect(reason)}"
end
end
defp validate_settings_strict!(resource, index_name, current, expected) do
mismatches = find_setting_mismatches(current, expected)
unless Enum.empty?(mismatches) do
error_msg = """
AshMeilisearch: Index '#{index_name}' for #{inspect(resource)} has incorrect settings.
Expected: #{inspect(expected)}
Current: #{inspect(current)}
Mismatches: #{inspect(mismatches)}
In production, index settings must match the resource configuration exactly.
Please update the index settings in Meilisearch or adjust the resource configuration.
"""
raise error_msg
end
:ok
end
defp validate_settings_lenient!(resource, index_name, current, expected) do
mismatches = find_setting_mismatches(current, expected)
if Enum.empty?(mismatches) do
:ok
else
# Only log if this looks like a meaningful change (not just initial setup)
is_initial_setup = is_initial_index_setup?(current)
if is_initial_setup do
Logger.debug(
"AshMeilisearch: Configuring new index '#{index_name}' for #{inspect(resource)}"
)
else
Logger.info(
"AshMeilisearch: Updating index '#{index_name}' settings to match #{inspect(resource)} configuration"
)
end
update_index_settings(index_name, expected)
end
end
# Check if this appears to be initial setup (index has default/empty settings)
defp is_initial_index_setup?(current_settings) do
searchable =
Map.get(current_settings, "searchableAttributes") ||
Map.get(current_settings, :searchableAttributes)
filterable =
Map.get(current_settings, "filterableAttributes") ||
Map.get(current_settings, :filterableAttributes)
sortable =
Map.get(current_settings, "sortableAttributes") ||
Map.get(current_settings, :sortableAttributes)
# Consider it initial setup if we have default searchable ["*"] and empty filterable/sortable
searchable == ["*"] && filterable == [] && sortable == []
end
defp find_setting_mismatches(current, expected) do
expected
# Skip primaryKey - it's set at creation, not via settings
|> Enum.reject(fn {key, _value} -> key == :primaryKey end)
|> Enum.reduce([], fn {key, expected_value}, mismatches ->
current_value = Map.get(current, Atom.to_string(key)) || Map.get(current, key)
# Normalize for comparison (convert strings to atoms in lists, handle ordering)
normalized_current = normalize_setting_value(current_value)
normalized_expected = normalize_setting_value(expected_value)
# Handle special cases where defaults should be considered "unset"
equivalent = settings_equivalent?(key, normalized_current, normalized_expected)
if equivalent do
mismatches
else
[{key, expected_value, current_value} | mismatches]
end
end)
end
# Check if two setting values should be considered equivalent
defp settings_equivalent?(_key, current, expected) when current == expected, do: true
# Handle primaryKey: nil current vs expected value means it needs to be set
defp settings_equivalent?(:primaryKey, nil, _expected), do: false
# Handle empty arrays vs configured arrays - these are real differences
defp settings_equivalent?(_key, [], expected) when is_list(expected), do: false
# Handle ["*"] searchableAttributes default vs configured attributes
defp settings_equivalent?(:searchableAttributes, ["*"], expected) when is_list(expected),
do: false
defp settings_equivalent?(_key, _current, _expected), do: false
defp normalize_setting_value(value) when is_list(value) do
value
|> Enum.map(&to_string/1)
|> Enum.sort()
end
defp normalize_setting_value(value) when is_binary(value), do: value
defp normalize_setting_value(value) when is_atom(value), do: Atom.to_string(value)
defp normalize_setting_value(nil), do: nil
defp normalize_setting_value(value), do: value
defp update_index_settings(index_name, settings) do
# Remove primaryKey from settings update (it's set during index creation)
settings_to_update = Map.delete(settings, :primaryKey)
case Client.update_index_settings(index_name, settings_to_update) do
{:ok, _task} ->
:ok
{:error, reason} ->
Logger.error(
"AshMeilisearch: Failed to update settings for #{index_name}: #{inspect(reason)}"
)
raise "AshMeilisearch: Failed to update settings for index '#{index_name}': #{inspect(reason)}"
end
end
end