Skip to content

Commit fcab18e

Browse files
committed
Address PR #1749 review — router prefix resolution and edge-case tests
- B2: add Router.resolve_prefix/1 for correct get_map/3 adapter selection - B2: raise ArgumentError at config time for subtree-conflicting patterns - M10: cover tie-break, zero-config, malformed entries, deeper exact wins
1 parent a539f54 commit fcab18e

3 files changed

Lines changed: 501 additions & 13 deletions

File tree

lib/backpex/preferences.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ defmodule Backpex.Preferences do
107107
@spec get_map(Context.t() | map(), String.t(), keyword()) :: map()
108108
def get_map(ctx_or_session, prefix, opts \\ []) do
109109
ctx = resolve_identity(Context.coerce(ctx_or_session))
110-
{module, adapter_opts} = Router.resolve(prefix)
110+
{module, adapter_opts} = Router.resolve_prefix(prefix)
111111

112112
case module.get_map(ctx, prefix, merge_opts(adapter_opts, opts)) do
113113
{:ok, map} when is_map(map) -> map

lib/backpex/preferences/router.ex

Lines changed: 307 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ defmodule Backpex.Preferences.Router do
3131
When no `:adapters` config is set, the router falls back to a single
3232
`{:default, Backpex.Preferences.Adapters.Session, []}` route so the zero-
3333
config behavior matches the legacy single-adapter implementation.
34+
35+
## Prefix vs. key resolution
36+
37+
Point lookups use `resolve/1,2` — they treat the argument as a complete key
38+
and pick the most specific matching pattern.
39+
40+
Subtree reads (`Backpex.Preferences.get_map/3`) use `resolve_prefix/1,2` —
41+
they treat the argument as a namespace and pick the adapter that owns the
42+
entire subtree. See `resolve_prefix/2` for the matching rules.
3443
"""
3544

3645
alias Backpex.Preferences.Key
@@ -75,7 +84,16 @@ defmodule Backpex.Preferences.Router do
7584
@spec resolve(String.t()) :: {module(), keyword()}
7685
@spec resolve(String.t(), [route()]) :: {module(), keyword()}
7786
def resolve(key, routes \\ routes()) when is_binary(key) do
78-
case best_match(key, normalize(routes)) do
87+
normalized = normalize(routes)
88+
89+
if normalized == [] do
90+
raise ArgumentError,
91+
"no Backpex.Preferences adapters configured; " <>
92+
"set :adapters under config :backpex, Backpex.Preferences, or omit the config " <>
93+
"to use the default Session adapter"
94+
end
95+
96+
case best_match(key, normalized) do
7997
nil ->
8098
raise ArgumentError,
8199
"no Backpex.Preferences adapter matches key #{inspect(key)}; " <>
@@ -86,27 +104,299 @@ defmodule Backpex.Preferences.Router do
86104
end
87105
end
88106

107+
@doc """
108+
Returns the `{module, opts}` that owns the subtree rooted at `prefix`.
109+
110+
Unlike `resolve/1`, which treats its argument as a complete key, this
111+
function treats `prefix` as a **namespace** and asks "which adapter is
112+
responsible for keys under this prefix?"
113+
114+
A route pattern `P` matches prefix `Q` when:
115+
116+
- `P` is the exact string `Q` — e.g., route `"global.theme"` matches prefix
117+
`"global.theme"`; or
118+
- `P` is a wildcard `"X.*"` whose prefix segments are `Q`'s segments (the
119+
route is rooted exactly at `Q`); or
120+
- `P` is a wildcard `"X.*"` whose prefix segments are an ancestor of `Q`
121+
(the route covers a superset of `Q`'s subtree); or
122+
- `P` is a wildcard `"X.*"` whose prefix segments are a descendant of `Q`
123+
(the route lives strictly inside `Q`'s subtree); or
124+
- `P` is `:default` (catch-all).
125+
126+
Among matching routes the most-specific one wins — the route whose prefix
127+
equals `Q` beats an ancestor-rooted wildcard, which in turn beats a
128+
descendant-rooted wildcard at greater depth, which beats `:default`.
129+
130+
Returns `{module, opts}` or raises `ArgumentError` when nothing matches.
131+
132+
## Examples
133+
134+
iex> routes = [
135+
...> {"resource.*", Backpex.Preferences.Adapters.Session, []},
136+
...> {:default, Backpex.Preferences.Adapters.Session, []}
137+
...> ]
138+
iex> Backpex.Preferences.Router.resolve_prefix("resource", routes)
139+
{Backpex.Preferences.Adapters.Session, []}
140+
"""
141+
@spec resolve_prefix(String.t()) :: {module(), keyword()}
142+
@spec resolve_prefix(String.t(), [route()]) :: {module(), keyword()}
143+
def resolve_prefix(prefix, routes \\ routes()) when is_binary(prefix) do
144+
normalized = normalize(routes)
145+
146+
if normalized == [] do
147+
raise ArgumentError,
148+
"no Backpex.Preferences adapters configured; " <>
149+
"set :adapters under config :backpex, Backpex.Preferences, or omit the config " <>
150+
"to use the default Session adapter"
151+
end
152+
153+
case best_prefix_match(prefix, normalized) do
154+
nil ->
155+
raise ArgumentError,
156+
"no Backpex.Preferences adapter matches prefix #{inspect(prefix)}; " <>
157+
"configure a :default route under config :backpex, Backpex.Preferences, adapters: [...]"
158+
159+
{_pattern, module, opts} ->
160+
{module, opts}
161+
end
162+
end
163+
89164
@doc false
90165
@spec default_routes() :: [route()]
91166
def default_routes do
92167
[{:default, Backpex.Preferences.Adapters.Session, []}]
93168
end
94169

95-
defp normalize(routes) do
96-
Enum.map(routes, fn
97-
{pattern, module} when is_atom(module) -> {pattern, module, []}
98-
{pattern, module, opts} when is_atom(module) and is_list(opts) -> {pattern, module, opts}
99-
end)
170+
@doc """
171+
Normalizes a raw route list, canonicalizing two-tuple entries to three-tuple
172+
form and validating shape.
173+
174+
Raises `ArgumentError` with a descriptive message for malformed entries and
175+
for configurations where two wildcard routes with different adapters sit in
176+
an ancestor/descendant relationship that cannot be unambiguously resolved
177+
(see module docs).
178+
"""
179+
@spec normalize([term()]) :: [route()]
180+
def normalize(routes) when is_list(routes) do
181+
normalized = Enum.map(routes, &validate_route/1)
182+
:ok = validate_no_conflicts!(normalized)
183+
normalized
184+
end
185+
186+
defp validate_route({pattern, module}) when is_atom(module) do
187+
validate_pattern!(pattern)
188+
validate_module!(module, {pattern, module})
189+
{pattern, module, []}
190+
end
191+
192+
defp validate_route({pattern, module, opts}) when is_atom(module) and is_list(opts) do
193+
validate_pattern!(pattern)
194+
validate_module!(module, {pattern, module, opts})
195+
{pattern, module, opts}
100196
end
101197

198+
defp validate_route(other) do
199+
raise ArgumentError,
200+
"invalid Backpex.Preferences route entry: " <>
201+
inspect(other) <>
202+
". Expected {pattern, adapter_module} or {pattern, adapter_module, opts}, " <>
203+
"where pattern is :default or a string and adapter_module is a module."
204+
end
205+
206+
# Distinguishes a module alias (e.g. `MyApp.Foo` → `:"Elixir.MyApp.Foo"`)
207+
# from a plain atom (e.g. `:not_a_module`) so misconfigured routes fail at
208+
# config time with a clear message instead of crashing downstream.
209+
defp validate_module!(module, entry) when is_atom(module) do
210+
cond do
211+
is_nil(module) ->
212+
raise ArgumentError,
213+
"expected adapter module for route #{inspect(entry)}, got: nil"
214+
215+
module_alias?(module) ->
216+
:ok
217+
218+
true ->
219+
raise ArgumentError,
220+
"expected adapter module for route #{inspect(entry)}, got: " <> inspect(module)
221+
end
222+
end
223+
224+
defp module_alias?(atom) when is_atom(atom) do
225+
case Atom.to_string(atom) do
226+
"Elixir." <> _rest -> true
227+
_other -> false
228+
end
229+
end
230+
231+
defp validate_pattern!(:default), do: :ok
232+
233+
defp validate_pattern!(pattern) when is_binary(pattern) do
234+
if pattern == "" do
235+
raise ArgumentError, "Backpex.Preferences route pattern must not be an empty string"
236+
end
237+
238+
:ok
239+
end
240+
241+
defp validate_pattern!(other) do
242+
raise ArgumentError,
243+
"invalid Backpex.Preferences route pattern: " <>
244+
inspect(other) <>
245+
". Expected a string like \"resource.*\" or the atom :default."
246+
end
247+
248+
# Raises when two wildcard routes with different adapters have prefix
249+
# portions in an ancestor/descendant relationship in a way that makes a
250+
# later-listed route broader than an earlier-listed one — a pattern that
251+
# almost always indicates a misauthored config (the broader route swallows
252+
# the narrower one for any prefix lookup at the ancestor level).
253+
defp validate_no_conflicts!(routes) do
254+
wildcards = extract_wildcards(routes)
255+
256+
for a <- wildcards, b <- wildcards, conflicting_pair?(a, b) do
257+
raise_conflict!(a, b)
258+
end
259+
260+
:ok
261+
end
262+
263+
defp extract_wildcards(routes) do
264+
routes
265+
|> Enum.with_index()
266+
|> Enum.flat_map(&to_wildcard_entry/1)
267+
end
268+
269+
defp to_wildcard_entry({{pattern, module, _opts}, index}) when is_binary(pattern) do
270+
case wildcard_prefix_segments(pattern) do
271+
nil -> []
272+
segs -> [{segs, pattern, module, index}]
273+
end
274+
end
275+
276+
defp to_wildcard_entry(_other), do: []
277+
278+
# A is earlier and narrower/equal; B is later and strictly broader; different
279+
# adapters.
280+
defp conflicting_pair?({segs_a, _pa, mod_a, idx_a}, {segs_b, _pb, mod_b, idx_b}) do
281+
idx_b > idx_a and mod_a != mod_b and proper_prefix?(segs_b, segs_a)
282+
end
283+
284+
defp raise_conflict!({_segs_a, pattern_a, module_a, _idx_a}, {_segs_b, pattern_b, module_b, _idx_b}) do
285+
raise ArgumentError,
286+
"conflicting Backpex.Preferences routes: #{inspect(pattern_b)} " <>
287+
"(adapter #{inspect(module_b)}) covers the subtree already owned by " <>
288+
"#{inspect(pattern_a)} (adapter #{inspect(module_a)}); reorder so the " <>
289+
"broader pattern comes first, or point both routes at the same adapter."
290+
end
291+
292+
# Returns the prefix segments of a wildcard pattern ("X.Y.*" → ["X", "Y"]),
293+
# or nil when the pattern is not a wildcard (no trailing "*").
294+
defp wildcard_prefix_segments(pattern) when is_binary(pattern) do
295+
segments = String.split(pattern, ".")
296+
297+
case List.last(segments) do
298+
"*" when length(segments) > 1 -> Enum.drop(segments, -1)
299+
_other -> nil
300+
end
301+
end
302+
303+
# True when `a` is a proper prefix of `b` (strictly shorter, matching at
304+
# every position of `a`). Equal lists are not a proper prefix.
305+
defp proper_prefix?(a, b) when length(a) < length(b) do
306+
Enum.take(b, length(a)) == a
307+
end
308+
309+
defp proper_prefix?(_a, _b), do: false
310+
102311
defp best_match(key, routes) do
103312
routes
104313
|> Enum.filter(&matches?(&1, key))
105314
|> Enum.max_by(&specificity/1, fn -> nil end)
106315
end
107316

108317
defp matches?({:default, _module, _opts}, _key), do: true
109-
defp matches?({pattern, _module, _opts}, key) when is_binary(pattern), do: Key.match?(pattern, key)
318+
319+
defp matches?({pattern, _module, _opts}, key) when is_binary(pattern) do
320+
case wildcard_prefix_segments(pattern) do
321+
nil ->
322+
# Exact pattern or legacy single-segment-wildcard; delegate to Key.match?
323+
# so colon-form keys ("resource:MyApp.MyLive:columns") still split
324+
# correctly against single-level wildcards like "resource.*".
325+
Key.match?(pattern, key)
326+
327+
wildcard_segs ->
328+
# Multi-segment wildcard ("resource.foo.*"): match when the key's
329+
# leading segments equal the wildcard's prefix segments.
330+
key_segs = Key.parse(key)
331+
length(key_segs) >= length(wildcard_segs) and Enum.take(key_segs, length(wildcard_segs)) == wildcard_segs
332+
end
333+
end
334+
335+
defp best_prefix_match(prefix, routes) do
336+
prefix_segments = String.split(prefix, ".")
337+
338+
routes
339+
|> Enum.filter(&prefix_matches?(&1, prefix, prefix_segments))
340+
|> Enum.max_by(&prefix_specificity(&1, prefix_segments), fn -> nil end)
341+
end
342+
343+
defp prefix_matches?({:default, _module, _opts}, _prefix, _prefix_segments), do: true
344+
345+
defp prefix_matches?({pattern, _module, _opts}, prefix, prefix_segments) when is_binary(pattern) do
346+
case wildcard_prefix_segments(pattern) do
347+
nil ->
348+
# Exact pattern: matches only when identical to the query prefix.
349+
pattern == prefix
350+
351+
wildcard_segs ->
352+
# Wildcard P matches prefix Q when P's prefix segments and Q are on
353+
# the same lineage in the tree: equal, or one an ancestor of the
354+
# other.
355+
lineage?(wildcard_segs, prefix_segments)
356+
end
357+
end
358+
359+
defp lineage?(a, b) do
360+
a == b or proper_prefix?(a, b) or proper_prefix?(b, a)
361+
end
362+
363+
# Specificity for a prefix match against query segments `Q`. Tuple is
364+
# designed so `Enum.max_by/2` picks the most specific match. Tiers (higher
365+
# beats lower):
366+
#
367+
# 4 — exact pattern equal to Q
368+
# 3 — wildcard whose prefix equals Q (route is rooted at Q)
369+
# 2 — wildcard whose prefix is an ancestor of Q (route covers a superset)
370+
# 1 — wildcard whose prefix is a descendant of Q (route is inside Q)
371+
# 0 — :default catch-all
372+
#
373+
# Within tier 2 (ancestor), deeper (longer) prefix wins — it's closer to Q.
374+
# Within tier 1 (descendant), shallower (shorter) prefix wins — it's closer
375+
# to Q from above, so we negate the length to invert the sort.
376+
defp prefix_specificity({:default, _module, _opts}, _query_segs), do: {0, 0}
377+
378+
defp prefix_specificity({pattern, _module, _opts}, query_segs) when is_binary(pattern) do
379+
case wildcard_prefix_segments(pattern) do
380+
nil ->
381+
{4, length(query_segs)}
382+
383+
wildcard_segs ->
384+
cond do
385+
wildcard_segs == query_segs ->
386+
{3, length(wildcard_segs)}
387+
388+
proper_prefix?(wildcard_segs, query_segs) ->
389+
{2, length(wildcard_segs)}
390+
391+
proper_prefix?(query_segs, wildcard_segs) ->
392+
{1, -length(wildcard_segs)}
393+
394+
true ->
395+
# Shouldn't happen — filtered out upstream.
396+
{-1, 0}
397+
end
398+
end
399+
end
110400

111401
@doc """
112402
Returns a tuple representing the specificity of a route pattern.
@@ -133,11 +423,16 @@ defmodule Backpex.Preferences.Router do
133423

134424
def specificity({pattern, _module, _opts}) when is_binary(pattern) do
135425
segments = String.split(pattern, ".")
136-
length = length(segments)
137-
exact? = List.last(segments) != "*"
426+
wildcard? = List.last(segments) == "*"
427+
428+
# Measure "effective depth" — exact patterns use their own segment count,
429+
# wildcards count only the leading prefix segments (dropping the trailing
430+
# "*"). This way an exact "global.theme" at depth 2 beats the wildcard
431+
# "global.*" whose prefix is at depth 1.
432+
depth = if wildcard?, do: length(segments) - 1, else: length(segments)
138433

139-
# Sort order: named patterns beat :default, longer patterns beat shorter,
140-
# exact matches beat wildcards at the same depth.
141-
{1, length, if(exact?, do: 1, else: 0)}
434+
# Sort order: named patterns beat :default, longer effective depth beats
435+
# shorter, exact matches beat wildcards at the same effective depth.
436+
{1, depth, if(wildcard?, do: 0, else: 1)}
142437
end
143438
end

0 commit comments

Comments
 (0)