fix: the Open/Resolved and Chains filters apply an exact rule's slash, case and query options (WR-22)

coveredSQL and chainSQL compared the source literally, so /missing/ or
/missing?utm=x stayed open after /missing answered it, and /a -> /b/ was
no chain next to /b. Both now use one SQL form of the runtime's exact
match (prefiltered on the indexed source_key). Pattern and regex rules
remain with the per-row badge, which the list_not_found schema text now
says.
This commit is contained in:
2026-10-27 15:41:18 +01:00
parent ed2d919aee
commit 7677d8d8af
4 changed files with 90 additions and 7 deletions

View File

@@ -41,9 +41,11 @@ const (
// FilterScopes lists the 404 log's filter scopes.
func (NotFound) FilterScopes() []string { return []string{FilterNotFoundStatus} }
// coveredSQL is true for an entry an exact redirect or 410 answers now
// (the list filter; the Resolved badge also counts patterns and regex).
const coveredSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS r WHERE r.match = 'exact' AND (r.source = golem15_seo_notfound.url OR (r.ignore_case AND lower(r.source) = lower(golem15_seo_notfound.url))))`
// coveredSQL is true for an entry an exact redirect or 410 answers now,
// with the rule's case, trailing-slash and query options as the runtime
// matcher applies them (WR-22). Pattern and regex rules are left to the
// Resolved badge, which runs the compiled rule set.
var coveredSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS r WHERE ` + coversSQL("r", "golem15_seo_notfound.url") + `)`
// FilterScope narrows the 404 log to open or resolved entries; an unknown
// value matches nothing.

View File

@@ -224,9 +224,42 @@ func (Redirect) FilterScopes() []string {
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
}
// chainSQL is true for a redirect whose target is another redirect's
// source.
const chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.source = golem15_seo_redirects.target AND next.id <> golem15_seo_redirects.id)`
// coversSQL is an SQL condition, true when the exact redirect aliased
// r matches the address addr (an SQL expression of a path with an optional
// query) the way the runtime matcher does (classes.exactMatches): the
// source's path with its ignore_case and trailing_slash options, and, when
// the source has a query, the address's query exactly (WR-22). The
// source_key test (the generated column of classes.exactKey) is a
// necessary condition that lets Postgres use its index; the rest is exact.
// Pattern and regex rules are not covered: their expressions are RE2 and
// only the compiled RedirectSet evaluates them.
func coversSQL(r, addr string) string {
return strings.NewReplacer("R.", r+".", "ADDR", addr).Replace(exactCoversSQL)
}
// exactCoversSQL is coversSQL with the placeholders R. and ADDR.
const exactCoversSQL = `(R.match = 'exact'
AND R.source_key IN (
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END),
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
|| '?' || (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
AND (strpos(R.source, '?') = 0
OR substr(R.source, strpos(R.source, '?') + 1) = (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
AND (split_part(R.source, '?', 1) = split_part(ADDR, '?', 1)
OR (R.ignore_case AND lower(split_part(R.source, '?', 1)) = lower(split_part(ADDR, '?', 1)))
OR (R.trailing_slash AND (
(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
= (CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
OR (R.ignore_case AND lower(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
= lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END))))))`
// chainSQL is true for a redirect whose target leads into another
// redirect: a path target that another rule's source names literally or
// an exact rule matches with its options (WR-22). Absolute targets on the
// site URL and pattern or regex next hops are only seen by the save-time
// check (the chain notice).
var chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.id <> golem15_seo_redirects.id AND (next.source = golem15_seo_redirects.target OR (golem15_seo_redirects.target LIKE '/%' AND golem15_seo_redirects.target NOT LIKE '//%' AND ` +
coversSQL("next", "golem15_seo_redirects.target") + `)))`
// FilterScope narrows the redirects list; an unknown value matches
// nothing.