defmodule Ecto.Adapters.Recall.Layout do @moduledoc false # The physical layout of a Mnesia table — its attribute names in storage order, # plus the derived pieces the adapter needs to read/write records (the match-spec # head/body and a name→position map). # # Unlike `Recall.Schema`, which bakes a field's storage position into the # *schema module* at compile time, a `Layout` is resolved from Mnesia's own # catalog at runtime (`:mnesia.table_info(tag, :attributes)`) and cached in # `:persistent_term`. The table is the source of truth for positions, so a # migration that reshapes a table is reflected on the next read without # recompiling, and reordering fields in a schema module can't silently misalign # reads. It also makes secondary indexes physical: `indexed` comes from # Mnesia's catalog, so the planners use an index iff a `create index` migration # actually built one (never created on the fly). # # The stored record is a tagged tuple `{tag, v1, v2, ...}`: element 1 is the # table tag and elements 2.. are the attribute values in `attributes` order. So # an attribute's 0-based slot is its index in `attributes`, and its 1-based # Mnesia tuple position is that index + 2 (one for the leading tag, one for # 1-based tuples). alias __MODULE__ @enforce_keys [:tag, :attributes, :index_of, :field_count, :match_head, :match_body] defstruct [ :tag, :attributes, :index_of, :field_count, :match_head, :match_body, :type, indexed: [] ] @type t :: %Layout{ tag: atom, attributes: [atom], index_of: %{atom => non_neg_integer}, field_count: non_neg_integer, match_head: tuple, match_body: [atom], type: :set | :ordered_set | nil, indexed: [pos_integer] } @doc """ Builds a `Layout` from a table tag and its ordered attribute names. Pure, so it can be exercised without a running Mnesia table and shared by `resolve/1`. `:type` is the Mnesia table type and `:indexed` the list of 1-based attribute positions carrying a secondary index (both as `:mnesia.table_info/2` reports them). """ def from_attributes(tag, attributes, opts \\ []) when is_atom(tag) and is_list(attributes) do count = length(attributes) vars = match_vars(count) %Layout{ tag: tag, attributes: attributes, index_of: attributes |> Enum.with_index() |> Map.new(), field_count: count, match_head: List.to_tuple([tag | vars]), match_body: vars, type: Keyword.get(opts, :type), indexed: Keyword.get(opts, :indexed, []) } end @doc """ The `Layout` for `tag`, resolved from Mnesia's catalog and cached in `:persistent_term`. The table must already exist (callers create it on first touch via `Ecto.Adapters.Recall.Table.ensure/2` before resolving). """ def resolve(tag) when is_atom(tag) do case :persistent_term.get(key(tag), nil) do nil -> tag |> read_from_mnesia() |> cache(tag) %Layout{} = layout -> layout end end @doc """ Drops the cached `Layout` for `tag`. Every schema operation that changes a table's shape (create/alter/drop/index) must call this so the next `resolve/1` re-reads the new layout from Mnesia. """ def invalidate(tag) when is_atom(tag) do :persistent_term.erase(key(tag)) :ok end @doc """ The 0-based slot of an attribute within the record's value elements — its index into a projected row and into `match_body`. """ def index_of(%Layout{index_of: map}, name), do: Map.fetch!(map, name) @doc """ The 1-based Mnesia tuple position of an attribute (the tag is position 1, the first value position 2). """ def position(%Layout{index_of: map}, name), do: Map.fetch!(map, name) + 2 @doc """ Whether `name` carries a secondary index on the table — i.e. its 1-based tuple position is in `indexed` (as `:mnesia.table_info(tag, :index)` reports). Indexes are physical: they exist only because a migration created one. An attribute the table doesn't have answers `false` (never raises). """ def indexed?(%Layout{index_of: map, indexed: indexed}, name) do case Map.fetch(map, name) do {:ok, slot} -> (slot + 2) in indexed :error -> false end end @doc false # The ordered match variables `[:"$1", ..., :"$count"]`. def match_vars(count) when count > 0, do: Enum.map(1..count, &:"$#{&1}") defp read_from_mnesia(tag) do from_attributes(tag, :mnesia.table_info(tag, :attributes), type: :mnesia.table_info(tag, :type), indexed: :mnesia.table_info(tag, :index) ) end defp cache(%Layout{} = layout, tag) do :persistent_term.put(key(tag), layout) layout end defp key(tag), do: {__MODULE__, tag} end