# SPDX-FileCopyrightText: 2020 Zach Daniel # # SPDX-License-Identifier: MIT defmodule AshPostgres.Ltree do @constraints [ escape?: [ type: :boolean, doc: """ Escape the ltree segments to make it possible to include characters that are either `.` (the separation character) or any other unsupported character like `-` (Postgres <= 15). If the option is enabled, any characters besides `[0-9a-zA-Z]` will be replaced with `_[HEX Ascii Code]`. Additionally the type will no longer take strings as user input since it's impossible to decide between `.` being a separator or part of a segment. If the option is disabled, any string will be relayed directly to postgres. If the segments are provided as a list, they can't contain `.` since postgres would split the segment. """ ], min_length: [ type: :non_neg_integer, doc: "A minimum length for the tree segments." ], max_length: [ type: :non_neg_integer, doc: "A maximum length for the tree segments." ] ] @moduledoc """ Ash Type for [postgres `ltree`](https://www.postgresql.org/docs/current/ltree.html), a hierarchical tree-like data type. ## Postgres Extension To be able to use the `ltree` type, you'll have to enable the postgres `ltree` extension first. See `m:AshPostgres.Repo#module-installed-extensions` ## Constraints #{Spark.Options.docs(@constraints)} """ use Ash.Type @type t() :: [segment()] @type segment() :: String.t() @impl Ash.Type def storage_type(_constraints), do: :ltree @impl Ash.Type def constraints, do: @constraints @impl Ash.Type def matches_type?(list, _constraints) when is_list(list), do: true def matches_type?(binary, constraints) when is_binary(binary), do: not Keyword.get(constraints, :escape?, false) def matches_type?(_ltree, _constraints), do: false @impl Ash.Type def generator(constraints) do segment = if constraints[:escape?], do: StreamData.string(:utf8, min_length: 1), else: StreamData.string(:alphanumeric, min_length: 1) StreamData.list_of(segment, Keyword.take(constraints, [:min_length, :max_length])) end @impl Ash.Type def apply_constraints(nil, _constraints), do: {:ok, nil} def apply_constraints(ltree, constraints) do segment_validation = Enum.reduce_while(ltree, :ok, fn segment, :ok -> cond do segment == "" -> {:halt, {:error, message: "Ltree segments can't be empty.", value: segment}} not String.valid?(segment) -> {:halt, {:error, message: "Ltree segments must be valid UTF-8 strings.", value: segment}} String.contains?(segment, ".") and !constraints[:escape?] -> {:halt, {:error, message: ~S|Ltree segments can't contain "." if :escape? is not enabled.|, value: segment}} true -> {:cont, :ok} end end) with :ok <- segment_validation do cond do constraints[:min_length] && length(ltree) < constraints[:min_length] -> {:error, message: "must have %{min} or more items", min: constraints[:min_length]} constraints[:max_length] && length(ltree) > constraints[:max_length] -> {:error, message: "must have %{max} or less items", max: constraints[:max_length]} true -> :ok end end end @impl Ash.Type def cast_input(nil, _constraints), do: {:ok, nil} def cast_input(string, constraints) when is_binary(string) do if constraints[:escape?] do {:error, "String input casting is not supported when the :escape? constraint is enabled"} else string |> String.split(".") |> cast_input(constraints) end end def cast_input(list, _constraints) when is_list(list) do if Enum.all?(list, &is_binary/1) do {:ok, list} else {:error, "Ltree segments must be strings. #{inspect(list)} provided."} end end def cast_input(_ltree, _constraints), do: :error @impl Ash.Type def cast_stored(nil, _constraints), do: {:ok, nil} def cast_stored(ltree, constraints) when is_binary(ltree) do segments = ltree |> String.split(".", trim: true) |> then( if constraints[:escape?] do fn segments -> Enum.map(segments, &unescape_segment/1) end else & &1 end ) {:ok, segments} end def cast_stored(_ltree, _constraints), do: :error @impl Ash.Type def dump_to_native(nil, _constraints), do: {:ok, nil} def dump_to_native(ltree, constraints) when is_list(ltree) do if constraints[:escape?] do {:ok, Enum.map_join(ltree, ".", &escape_segment/1)} else {:ok, Enum.join(ltree, ".")} end end def dump_to_native(_ltree, _constraints), do: :error @doc """ Get shared root of given ltrees. ## Examples iex> Ltree.shared_root(["1", "2"], ["1", "1"]) ["1"] iex> Ltree.shared_root(["1", "2"], ["2", "1"]) [] """ @spec shared_root(ltree1 :: t(), ltree2 :: t()) :: t() def shared_root(ltree1, ltree2) do ltree1 |> List.myers_difference(ltree2) |> case do [{:eq, shared} | _] -> shared _other -> [] end end @spec escape_segment(segment :: String.t()) :: String.t() defp escape_segment(segment) defp escape_segment(<<>>), do: <<>> defp escape_segment(<>) when letter in ?0..?9 when letter in ?a..?z when letter in ?A..?Z, do: <> defp escape_segment(<>) do escape_code = letter |> Integer.to_string(16) |> String.pad_leading(2, "0") <> end @spec unescape_segment(segment :: String.t()) :: String.t() defp unescape_segment(segment) defp unescape_segment(<<>>), do: <<>> defp unescape_segment(<>) when letter in ?0..?9 when letter in ?a..?z when letter in ?A..?Z, do: <> defp unescape_segment(<>) do {letter, ""} = Integer.parse(<>, 16) <> end end