@@ -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
143438end
0 commit comments