Skip to content

Commit 1601011

Browse files
committed
Add page finder service
Add a new service which find the page by urlname or should try to find the correct wildcard url for a given parent_page. It will traverse the page tree and will try to match all page_layouts with a wildcard_url. The first match wins and will be returned. The page and params are stored in the service itself and can be received later in the controller.
1 parent ec7ae54 commit 1601011

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
class PageFinder
5+
attr_reader :urlname
6+
7+
Result = Data.define(:page, :extracted_params)
8+
9+
def initialize(urlname)
10+
@urlname = urlname
11+
end
12+
13+
# @return [PageFinder::Result, nil]
14+
def call
15+
return if urlname.blank?
16+
17+
find_by_urlname || find_by_wildcard_url
18+
end
19+
20+
private
21+
22+
# Finds a page by exact urlname match within the current language.
23+
def find_by_urlname
24+
page = Current.language.pages.contentpages.find_by(urlname: urlname)
25+
Result.new(page: page, extracted_params: permitted_params({})) if page
26+
end
27+
28+
# Finds a page whose urlname pattern matches the given URL.
29+
# Uses a single SQL query to load all candidate pages with wildcard patterns
30+
# in their urlname, then matches and validates constraints in Ruby.
31+
def find_by_wildcard_url
32+
return if wildcard_definitions.empty?
33+
34+
wildcard_pages = Current.language.pages.contentpages
35+
.where("urlname LIKE ?", "%:%")
36+
.to_a
37+
38+
url_depth = urlname.count("/")
39+
40+
matches = wildcard_pages.filter_map do |wildcard_page|
41+
next if wildcard_page.urlname.count("/") != url_depth
42+
43+
matched_params = match_url_pattern(wildcard_page)
44+
[wildcard_page, matched_params] if matched_params
45+
end
46+
47+
return if matches.empty?
48+
49+
# take the first page that matched the url
50+
page, params = matches.min_by { |page, _| [page.depth, page.lft] }
51+
Result.new(page: page, extracted_params: permitted_params(params))
52+
end
53+
54+
# Matches the urlname against a page's wildcard pattern.
55+
# Builds a regex from the pattern with constraints baked into capture groups,
56+
# then extracts and validates params in one step.
57+
#
58+
# @param wildcard_page [Alchemy::Page] a page with wildcard segments in its urlname
59+
# @return [Hash<Symbol, String>, nil] matched params or nil
60+
def match_url_pattern(wildcard_page)
61+
regex_parts = wildcard_page.urlname.split("/").map do |segment|
62+
segment_to_pattern(segment)
63+
end
64+
65+
return if regex_parts.include?(nil)
66+
67+
match = Regexp.new("\\A#{regex_parts.join("/")}\\z").match(urlname)
68+
return unless match
69+
70+
match.named_captures.transform_keys(&:to_sym)
71+
end
72+
73+
# Converts a single URL segment into a regex pattern string.
74+
# Static segments are escaped, dynamic segments (e.g. ":id") are resolved
75+
# to a capture group with the constraint from the matching wildcard definition.
76+
#
77+
# @param segment [String] a single URL segment, e.g. "products" or ":id"
78+
# @return [String, nil] regex pattern string or nil if no matching definition found
79+
def segment_to_pattern(segment)
80+
unless segment.start_with?(":")
81+
return Regexp.escape(segment)
82+
end
83+
84+
key = segment[1..] # remove leading colon
85+
wildcard_url = wildcard_urls_by_param_key[key.to_sym]
86+
wildcard_url ? "(?<#{key}>#{wildcard_url.constraint_pattern(key)})" : nil
87+
end
88+
89+
# Cache the wildcard_url definition and store it in a hash
90+
#
91+
# @return [Hash<Symbol, WildcardUrlType::Value>] param key to wildcard_url lookup
92+
def wildcard_urls_by_param_key
93+
@_wildcard_urls_by_param_key ||= wildcard_definitions.each_with_object({}) do |definition, hash|
94+
definition.wildcard_url.param_keys.each do |key|
95+
hash[key] = definition.wildcard_url
96+
end
97+
end
98+
end
99+
100+
# @param hash [Hash] raw extracted params
101+
# @return [ActionController::Parameters] permitted params
102+
def permitted_params(hash)
103+
ActionController::Parameters.new(hash).permit(*hash.keys)
104+
end
105+
106+
# Returns all page definitions that have a wildcard_url defined.
107+
#
108+
# @return [Array<PageDefinition>]
109+
def wildcard_definitions
110+
@_wildcard_definitions ||= PageDefinition.all.select { |d| d.wildcard_url&.present? }
111+
end
112+
end
113+
end
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
module Alchemy
6+
RSpec.describe PageFinder do
7+
let(:language) { create(:alchemy_language) }
8+
let!(:language_root) do
9+
create(:alchemy_page, :language_root, language: language)
10+
end
11+
12+
before do
13+
PageDefinition.reset!
14+
Current.language = language
15+
end
16+
17+
def create_page(name:, layout: "standard", parent: language_root)
18+
create(
19+
:alchemy_page, :public,
20+
name: name,
21+
page_layout: layout,
22+
parent: parent,
23+
language: language
24+
)
25+
end
26+
27+
# Page tree used by most tests:
28+
#
29+
# Products (standard) -> /products
30+
# +-- Product Details (product_detail) -> /products/:id (integer constraint)
31+
# | +-- Comments (standard) -> /products/:id/comments
32+
# +-- Product By Slug (product_by_slug) -> /products/:product_slug (no constraint)
33+
#
34+
# Blog (standard) -> /blog
35+
# +-- Blog Post (blog_post) -> /blog/:year/:slug (integer + string)
36+
#
37+
# Users (standard) -> /users
38+
# +-- User Profile (user_profile) -> /users/:uuid/profile (uuid constraint)
39+
40+
let!(:products_page) { create_page(name: "Products") }
41+
let!(:product_detail_page) { create_page(name: "Product Details", layout: "product_detail", parent: products_page) }
42+
let!(:comments_page) { create_page(name: "Comments", parent: product_detail_page) }
43+
let!(:product_by_slug_page) { create_page(name: "Product By Slug", layout: "product_by_slug", parent: products_page) }
44+
45+
let!(:blog_page) { create_page(name: "Blog") }
46+
let!(:blog_post_page) { create_page(name: "Blog Post", layout: "blog_post", parent: blog_page) }
47+
48+
let!(:users_page) { create_page(name: "Users") }
49+
let!(:user_profile_page) { create_page(name: "User Profile", layout: "user_profile", parent: users_page) }
50+
51+
it "finds a wildcard page and extracts params" do
52+
result = described_class.new("products/123").call
53+
expect(result.page).to eq(product_detail_page)
54+
expect(result.extracted_params[:id]).to eq("123")
55+
end
56+
57+
it "finds a page by exact urlname" do
58+
result = described_class.new("products").call
59+
expect(result.page).to eq(products_page)
60+
end
61+
62+
it "finds a nested page by its full urlname" do
63+
result = described_class.new(comments_page.urlname).call
64+
expect(result.page).to eq(comments_page)
65+
end
66+
67+
it "returns nil for a blank path" do
68+
expect(described_class.new("").call).to be_nil
69+
end
70+
71+
it "returns nil for a path with no matching prefix" do
72+
expect(described_class.new("other/123").call).to be_nil
73+
end
74+
75+
context "single-segment pattern with integer constraint" do
76+
it "matches /products/123 and prefers the constrained sibling" do
77+
result = described_class.new("products/123").call
78+
expect(result.page).to eq(product_detail_page)
79+
expect(result.extracted_params[:id]).to eq("123")
80+
expect(result.extracted_params).not_to have_key(:slug)
81+
end
82+
83+
it "rejects non-integer and falls through to unconstrained sibling" do
84+
result = described_class.new("products/some-slug").call
85+
expect(result.page).to eq(product_by_slug_page)
86+
expect(result.extracted_params[:product_slug]).to eq("some-slug")
87+
expect(result.extracted_params).not_to have_key(:id)
88+
end
89+
end
90+
91+
context "multi-segment pattern" do
92+
it "matches /blog/2024/my-post and extracts both params" do
93+
result = described_class.new("blog/2024/my-post").call
94+
expect(result.page).to eq(blog_post_page)
95+
expect(result.extracted_params[:year]).to eq("2024")
96+
expect(result.extracted_params[:slug]).to eq("my-post")
97+
end
98+
99+
it "does not match with wrong segment count" do
100+
expect(described_class.new("blog/2024").call).to be_nil
101+
end
102+
end
103+
104+
context "pattern with static segments" do
105+
let(:uuid) { "550e8400-e29b-41d4-a716-446655440000" }
106+
107+
it "matches /users/:uuid/profile" do
108+
result = described_class.new("users/#{uuid}/profile").call
109+
expect(result.page).to eq(user_profile_page)
110+
expect(result.extracted_params[:uuid]).to eq(uuid)
111+
end
112+
113+
it "does not match without the trailing static segment" do
114+
expect(described_class.new("users/#{uuid}").call).to be_nil
115+
end
116+
end
117+
118+
context "hierarchical patterns (grandchild under pattern page)" do
119+
it "matches /products/42/comments through the pattern parent" do
120+
result = described_class.new("products/42/comments").call
121+
expect(result.page).to eq(comments_page)
122+
expect(result.extracted_params[:id]).to eq("42")
123+
end
124+
end
125+
126+
context "nested wildcard patterns" do
127+
let!(:category_page) { create_page(name: "Category") }
128+
let!(:category_by_slug) { create_page(name: "Category By Slug", layout: "product_by_slug", parent: category_page) }
129+
let!(:category_detail) { create_page(name: "Category Detail", layout: "product_detail", parent: category_by_slug) }
130+
131+
it "matches /category/electronics/42 and extracts both product_slug and id" do
132+
result = described_class.new("category/electronics/42").call
133+
expect(result.page).to eq(category_detail)
134+
expect(result.extracted_params[:product_slug]).to eq("electronics")
135+
expect(result.extracted_params[:id]).to eq("42")
136+
end
137+
end
138+
139+
context "with competing sibling patterns of different segment counts" do
140+
let!(:shared_page) { create_page(name: "Shared") }
141+
let!(:multi_segment_page) { create_page(name: "Multi Segment", layout: "blog_post", parent: shared_page) }
142+
let!(:single_segment_page) { create_page(name: "Single Segment", layout: "product_by_slug", parent: shared_page) }
143+
let!(:child_of_single) { create_page(name: "My Post", parent: single_segment_page) }
144+
145+
it "matches the first pattern sibling even when the other could also match via its child" do
146+
result = described_class.new("shared/2024/my-post").call
147+
expect(result.page).to eq(multi_segment_page)
148+
expect(result.extracted_params[:year]).to eq("2024")
149+
expect(result.extracted_params[:slug]).to eq("my-post")
150+
end
151+
end
152+
153+
context "with a static wildcard_url pattern (no dynamic segments)" do
154+
let!(:static_parent) { create_page(name: "Static Parent") }
155+
let!(:static_page) { create_page(name: "Static Page", layout: "static_wildcard", parent: static_parent) }
156+
157+
it "matches /static-parent/foo/bar" do
158+
result = described_class.new("static-parent/foo/bar").call
159+
expect(result.page).to eq(static_page)
160+
expect(result.extracted_params.keys).to be_empty
161+
end
162+
end
163+
164+
context "with a regex constraint" do
165+
let!(:warehouse_page) { create_page(name: "Warehouse") }
166+
let!(:sku_page) { create_page(name: "SKU Lookup", layout: "product_by_sku", parent: warehouse_page) }
167+
168+
it "matches when the value satisfies the regex" do
169+
result = described_class.new("warehouse/AB-1234").call
170+
expect(result.page).to eq(sku_page)
171+
expect(result.extracted_params[:sku]).to eq("AB-1234")
172+
end
173+
174+
it "does not match when the value violates the regex" do
175+
expect(described_class.new("warehouse/invalid").call).to be_nil
176+
end
177+
end
178+
end
179+
180+
RSpec.describe PageFinder, "when no content pages exist" do
181+
let(:language) { create(:alchemy_language) }
182+
let!(:language_root) do
183+
create(:alchemy_page, :language_root, language: language)
184+
end
185+
186+
before do
187+
PageDefinition.reset!
188+
Current.language = language
189+
end
190+
191+
it "returns nil" do
192+
expect(described_class.new("anything").call).to be_nil
193+
end
194+
end
195+
end

0 commit comments

Comments
 (0)