Skip to content

Commit ec3273f

Browse files
authored
feat: Implement Ltree Type (#385)
1 parent 8779250 commit ec3273f

File tree

10 files changed

+932
-4
lines changed

10 files changed

+932
-4
lines changed

lib/resource_generator/spec.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ defmodule AshPostgres.ResourceGenerator.Spec do
903903
defp type("tsvector"), do: {:ok, AshPostgres.Tsvector}
904904
defp type("uuid"), do: {:ok, :uuid}
905905
defp type("citext"), do: {:ok, :ci_string}
906+
defp type("ltree"), do: {:ok, AshPostgres.Ltree}
906907
defp type(_), do: :error
907908

908909
defp set_sensitive(attributes) do

lib/types/ltree.ex

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
defmodule AshPostgres.Ltree do
2+
@constraints [
3+
escape?: [
4+
type: :boolean,
5+
doc: """
6+
Escape the ltree segments to make it possible to include characters that
7+
are either `.` (the separation character) or any other unsupported
8+
character like `-` (Postgres <= 15).
9+
10+
If the option is enabled, any characters besides `[0-9a-zA-Z]` will be
11+
replaced with `_[HEX Ascii Code]`.
12+
13+
Additionally the type will no longer take strings as user input since
14+
it's impossible to decide between `.` being a separator or part of a
15+
segment.
16+
17+
If the option is disabled, any string will be relayed directly to
18+
postgres. If the segments are provided as a list, they can't contain `.`
19+
since postgres would split the segment.
20+
"""
21+
],
22+
min_length: [
23+
type: :non_neg_integer,
24+
doc: "A minimum length for the tree segments."
25+
],
26+
max_length: [
27+
type: :non_neg_integer,
28+
doc: "A maximum length for the tree segments."
29+
]
30+
]
31+
32+
@moduledoc """
33+
Ash Type for [postgres `ltree`](https://www.postgresql.org/docs/current/ltree.html),
34+
a hierarchical tree-like data type.
35+
36+
## Postgres Extension
37+
38+
To be able to use the `ltree` type, you'll have to enable the postgres `ltree`
39+
extension first.
40+
41+
See `m:AshPostgres.Repo#module-installed-extensions`
42+
43+
## Constraints
44+
45+
#{Spark.Options.docs(@constraints)}
46+
"""
47+
48+
use Ash.Type
49+
50+
@type t() :: [segment()]
51+
@type segment() :: String.t()
52+
53+
@impl Ash.Type
54+
def storage_type(_constraints), do: :ltree
55+
56+
@impl Ash.Type
57+
def constraints, do: @constraints
58+
59+
@impl Ash.Type
60+
def matches_type?(list, _constraints) when is_list(list), do: true
61+
62+
def matches_type?(binary, constraints) when is_binary(binary),
63+
do: not Keyword.get(constraints, :escape?, false)
64+
65+
def matches_type?(_ltree, _constraints), do: false
66+
67+
@impl Ash.Type
68+
def generator(constraints) do
69+
segment =
70+
if constraints[:escape?],
71+
do: StreamData.string(:utf8, min_length: 1),
72+
else: StreamData.string(:alphanumeric, min_length: 1)
73+
74+
StreamData.list_of(segment, Keyword.take(constraints, [:min_length, :max_length]))
75+
end
76+
77+
@impl Ash.Type
78+
def apply_constraints(nil, _constraints), do: {:ok, nil}
79+
80+
def apply_constraints(ltree, constraints) do
81+
segment_validation =
82+
Enum.reduce_while(ltree, :ok, fn segment, :ok ->
83+
cond do
84+
segment == "" ->
85+
{:halt, {:error, message: "Ltree segments can't be empty.", value: segment}}
86+
87+
not String.valid?(segment) ->
88+
{:halt,
89+
{:error, message: "Ltree segments must be valid UTF-8 strings.", value: segment}}
90+
91+
String.contains?(segment, ".") and !constraints[:escape?] ->
92+
{:halt,
93+
{:error,
94+
message: ~S|Ltree segments can't contain "." if :escape? is not enabled.|,
95+
value: segment}}
96+
97+
true ->
98+
{:cont, :ok}
99+
end
100+
end)
101+
102+
with :ok <- segment_validation do
103+
cond do
104+
constraints[:min_length] && length(ltree) < constraints[:min_length] ->
105+
{:error, message: "must have %{min} or more items", min: constraints[:min_length]}
106+
107+
constraints[:max_length] && length(ltree) > constraints[:max_length] ->
108+
{:error, message: "must have %{max} or less items", max: constraints[:max_length]}
109+
110+
true ->
111+
:ok
112+
end
113+
end
114+
end
115+
116+
@impl Ash.Type
117+
def cast_input(nil, _constraints), do: {:ok, nil}
118+
119+
def cast_input(string, constraints) when is_binary(string) do
120+
if constraints[:escape?] do
121+
{:error, "String input casting is not supported when the :escape? constraint is enabled"}
122+
else
123+
string |> String.split(".") |> cast_input(constraints)
124+
end
125+
end
126+
127+
def cast_input(list, _constraints) when is_list(list) do
128+
if Enum.all?(list, &is_binary/1) do
129+
{:ok, list}
130+
else
131+
{:error, "Ltree segments must be strings. #{inspect(list)} provided."}
132+
end
133+
end
134+
135+
def cast_input(_ltree, _constraints), do: :error
136+
137+
@impl Ash.Type
138+
def cast_stored(nil, _constraints), do: {:ok, nil}
139+
140+
def cast_stored(ltree, constraints) when is_binary(ltree) do
141+
segments =
142+
ltree
143+
|> String.split(".", trim: true)
144+
|> then(
145+
if constraints[:escape?] do
146+
fn segments -> Enum.map(segments, &unescape_segment/1) end
147+
else
148+
& &1
149+
end
150+
)
151+
152+
{:ok, segments}
153+
end
154+
155+
def cast_stored(_ltree, _constraints), do: :error
156+
157+
@impl Ash.Type
158+
def dump_to_native(nil, _constraints), do: {:ok, nil}
159+
160+
def dump_to_native(ltree, constraints) when is_list(ltree) do
161+
if constraints[:escape?] do
162+
{:ok, Enum.map_join(ltree, ".", &escape_segment/1)}
163+
else
164+
{:ok, Enum.join(ltree, ".")}
165+
end
166+
end
167+
168+
def dump_to_native(_ltree, _constraints), do: :error
169+
170+
@doc """
171+
Get shared root of given ltrees.
172+
173+
## Examples
174+
175+
iex> Ltree.shared_root(["1", "2"], ["1", "1"])
176+
["1"]
177+
178+
iex> Ltree.shared_root(["1", "2"], ["2", "1"])
179+
[]
180+
181+
"""
182+
@spec shared_root(ltree1 :: t(), ltree2 :: t()) :: t()
183+
def shared_root(ltree1, ltree2) do
184+
ltree1
185+
|> List.myers_difference(ltree2)
186+
|> case do
187+
[{:eq, shared} | _] -> shared
188+
_other -> []
189+
end
190+
end
191+
192+
@spec escape_segment(segment :: String.t()) :: String.t()
193+
defp escape_segment(segment)
194+
defp escape_segment(<<>>), do: <<>>
195+
196+
defp escape_segment(<<letter, rest::binary>>)
197+
when letter in ?0..?9
198+
when letter in ?a..?z
199+
when letter in ?A..?Z,
200+
do: <<letter, escape_segment(rest)::binary>>
201+
202+
defp escape_segment(<<letter, rest::binary>>) do
203+
escape_code = letter |> Integer.to_string(16) |> String.pad_leading(2, "0")
204+
<<?_, escape_code::binary, escape_segment(rest)::binary>>
205+
end
206+
207+
@spec unescape_segment(segment :: String.t()) :: String.t()
208+
defp unescape_segment(segment)
209+
defp unescape_segment(<<>>), do: <<>>
210+
211+
defp unescape_segment(<<letter, rest::binary>>)
212+
when letter in ?0..?9
213+
when letter in ?a..?z
214+
when letter in ?A..?Z,
215+
do: <<letter, unescape_segment(rest)::binary>>
216+
217+
defp unescape_segment(<<?_, h, l, rest::binary>>) do
218+
{letter, ""} = Integer.parse(<<h, l>>, 16)
219+
<<letter, unescape_segment(rest)::binary>>
220+
end
221+
end

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ defmodule AshPostgres.MixProject do
134134
AshPostgres.Statement
135135
],
136136
Types: [
137+
AshPostgres.Ltree,
137138
AshPostgres.Type,
138139
AshPostgres.Tsquery,
139140
AshPostgres.Tsvector,
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
{
2+
"ash_functions_version": 4,
23
"installed": [
34
"ash-functions",
45
"uuid-ossp",
56
"pg_trgm",
67
"citext",
7-
"demo-functions_v1"
8-
],
9-
"ash_functions_version": 4
8+
"demo-functions_v1",
9+
"ltree"
10+
]
1011
}

0 commit comments

Comments
 (0)