Add range matchers for site matrix vector store filtering

Fixes #14359
This commit is contained in:
Bjørn Erik Pedersen
2026-01-22 12:03:02 +01:00
committed by GitHub
parent e569dd59a0
commit 192e3c453e
8 changed files with 350 additions and 25 deletions
+97 -2
View File
@@ -153,6 +153,29 @@ func (p PR[T]) FilterCopy(s []T) []T {
return result
}
const (
rangeOpNone = iota
rangeOpLT
rangeOpLTE
rangeOpGT
rangeOpGTE
)
func cutRangeOp(s string) (op int, rest string) {
switch {
case strings.HasPrefix(s, ">= "):
return rangeOpGTE, s[3:]
case strings.HasPrefix(s, "<= "):
return rangeOpLTE, s[3:]
case strings.HasPrefix(s, "> "):
return rangeOpGT, s[2:]
case strings.HasPrefix(s, "< "):
return rangeOpLT, s[2:]
default:
return rangeOpNone, s
}
}
// NewStringPredicateFromGlobs creates a string predicate from the given glob patterns.
// A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string) (glob.Glob, error)) (P[string], error) {
@@ -187,6 +210,78 @@ func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string)
return p.BoolFunc(), nil
}
type IndexMatcher interface {
IndexMatch(match P[string]) (iter.Seq[int], error)
// NewIndexStringPredicateFromGlobsAndRanges creates an IndexString predicate from the given glob patterns and range patterns.
// A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
// A range pattern is one of "> value", ">= value", "< value" or "<= value".
func NewIndexStringPredicateFromGlobsAndRanges(patterns []string, getIndex func(s string) int, getGlob func(pattern string) (glob.Glob, error)) (P[IndexString], error) {
var p PR[IndexString]
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
negate := strings.HasPrefix(pattern, hglob.NegationPrefix)
if negate {
pattern = pattern[2:]
g, err := getGlob(pattern)
if err != nil {
return nil, err
}
p = p.And(func(s IndexString) Match {
return BoolMatch(!g.Match(s.String))
})
} else {
// This can be either a glob or a value prefixed with one of >, >=, < or <=.
o, v := cutRangeOp(pattern)
if o != rangeOpNone {
i := getIndex(v)
if i == -1 {
// No match possible.
p = p.And(func(s IndexString) Match {
return BoolMatch(false)
})
continue
}
switch o {
// The greater values starts at the top with index 0.
case rangeOpGT:
p = p.And(func(s IndexString) Match {
return BoolMatch(s.Index < i)
})
case rangeOpGTE:
p = p.And(func(s IndexString) Match {
return BoolMatch(s.Index <= i)
})
case rangeOpLT:
p = p.And(func(s IndexString) Match {
return BoolMatch(s.Index > i)
})
case rangeOpLTE:
p = p.And(func(s IndexString) Match {
return BoolMatch(s.Index >= i)
})
}
} else {
g, err := getGlob(pattern)
if err != nil {
return nil, err
}
p = p.Or(func(s IndexString) Match {
return BoolMatch(g.Match(s.String))
})
}
}
}
return p.BoolFunc(), nil
}
type IndexString struct {
Index int
String string
}
type IndexMatcher interface {
IndexMatch(match P[IndexString]) (iter.Seq[int], error)
}
+80
View File
@@ -146,6 +146,86 @@ func TestNewStringPredicateFromGlobs(t *testing.T) {
c.Assert(m("anything"), qt.IsFalse)
}
func TestNewIndexStringPredicateFromGlobsAndRanges(t *testing.T) {
c := qt.New(t)
// Simulate versions: v4.0.0=0, v3.0.0=1, v2.0.0=2, v1.0.0=3
// Lower index = greater value.
versions := []string{"v4.0.0", "v3.0.0", "v2.0.0", "v1.0.0"}
getIndex := func(s string) int {
for i, v := range versions {
if v == s {
return i
}
}
return -1
}
getGlob := func(pattern string) (glob.Glob, error) {
return glob.Compile(pattern)
}
n := func(patterns ...string) predicate.P[predicate.IndexString] {
p, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(patterns, getIndex, getGlob)
c.Assert(err, qt.IsNil)
return p
}
is := func(i int) predicate.IndexString {
return predicate.IndexString{Index: i, String: versions[i]}
}
// Test >= v2.0.0 (index 2): should match indices <= 2
m := n(">= v2.0.0")
c.Assert(m(is(0)), qt.IsTrue) // v4.0.0
c.Assert(m(is(1)), qt.IsTrue) // v3.0.0
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
c.Assert(m(is(3)), qt.IsFalse) // v1.0.0
// Test > v2.0.0 (index 2): should match indices < 2
m = n("> v2.0.0")
c.Assert(m(is(0)), qt.IsTrue) // v4.0.0
c.Assert(m(is(1)), qt.IsTrue) // v3.0.0
c.Assert(m(is(2)), qt.IsFalse) // v2.0.0
c.Assert(m(is(3)), qt.IsFalse) // v1.0.0
// Test < v3.0.0 (index 1): should match indices > 1
m = n("< v3.0.0")
c.Assert(m(is(0)), qt.IsFalse) // v4.0.0
c.Assert(m(is(1)), qt.IsFalse) // v3.0.0
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
c.Assert(m(is(3)), qt.IsTrue) // v1.0.0
// Test range: >= v2.0.0 AND <= v3.0.0
m = n(">= v2.0.0", "<= v3.0.0")
c.Assert(m(is(0)), qt.IsFalse) // v4.0.0 - too high
c.Assert(m(is(1)), qt.IsTrue) // v3.0.0
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
c.Assert(m(is(3)), qt.IsFalse) // v1.0.0 - too low
// Test glob pattern
m = n("v2.*.*")
c.Assert(m(is(0)), qt.IsFalse) // v4.0.0
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
// Test glob with negation
m = n("v*.*.*", "! v3.*.*")
c.Assert(m(is(0)), qt.IsTrue) // v4.0.0
c.Assert(m(is(1)), qt.IsFalse) // v3.0.0 - negated
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
// Test range with negation: >= v2.0.0 but not v3.0.0
m = n(">= v2.0.0", "! v3.0.0")
c.Assert(m(is(0)), qt.IsTrue) // v4.0.0
c.Assert(m(is(1)), qt.IsFalse) // v3.0.0 - negated
c.Assert(m(is(2)), qt.IsTrue) // v2.0.0
c.Assert(m(is(3)), qt.IsFalse) // v1.0.0 - out of range
// Test unknown value in range returns no match
m = n(">= v99.0.0")
c.Assert(m(is(0)), qt.IsFalse)
c.Assert(m(is(3)), qt.IsFalse)
}
func BenchmarkPredicate(b *testing.B) {
b.Run("and or no match", func(b *testing.B) {
var p predicate.PR[int] = intP1
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package predicate
import (
"testing"
qt "github.com/frankban/quicktest"
)
func TestCutRangeOp(t *testing.T) {
c := qt.New(t)
op, rest := cutRangeOp(">= value")
c.Assert(op, qt.Equals, rangeOpGTE)
c.Assert(rest, qt.Equals, "value")
op, rest = cutRangeOp("<= value")
c.Assert(op, qt.Equals, rangeOpLTE)
c.Assert(rest, qt.Equals, "value")
op, rest = cutRangeOp("> value")
c.Assert(op, qt.Equals, rangeOpGT)
c.Assert(rest, qt.Equals, "value")
op, rest = cutRangeOp("< value")
c.Assert(op, qt.Equals, rangeOpLT)
c.Assert(rest, qt.Equals, "value")
op, rest = cutRangeOp("value")
c.Assert(op, qt.Equals, rangeOpNone)
c.Assert(rest, qt.Equals, "value")
}
+3 -3
View File
@@ -102,14 +102,14 @@ func (r RolesInternal) ResolveIndex(name string) int {
return i
}
}
panic(fmt.Sprintf("no role found for name %q", name))
return -1
}
// IndexMatch returns an iterator for the roles that match the filter.
func (r RolesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
func (r RolesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
return func(yield func(i int) bool) {
for i, role := range r.Sorted {
if match(role.Name) {
if match(predicate.IndexString{Index: i, String: role.Name}) {
if !yield(i) {
return
}
@@ -1431,6 +1431,115 @@ All.
b.AssertFileContent("public/guest/en/index.html", "url=https://example.org/guest/\"")
}
func TestSitesMatrixRangeMatchers(t *testing.T) {
t.Parallel()
files := `TestSitesMatrixRangeMatchers
-- hugo.toml --
baseURL = "https://example.org/"
defaultContentVersion = "v4.0.0"
defaultContentVersionInSubDir = true
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
[languages]
[languages.en]
weight = 1
[versions]
[versions."v1.0.0"]
[versions."v2.0.0"]
[versions."v2.1.0"]
[versions."v3.0.0"]
[versions."v4.0.0"]
-- content/_index.md --
---
title: "Home"
sites:
matrix:
versions: ["**"]
---
-- content/p1.md --
---
title: "P1 from v2.0.0"
sites:
matrix:
versions: [">= v2.0.0"]
---
Content for v2.0.0 and later.
-- content/p2.md --
---
title: "P2 between v2 and v3"
sites:
matrix:
versions: [">= v2.0.0", "<= v3.0.0"]
---
Content between v2.0.0 and v3.0.0 (inclusive).
-- content/p3.md --
---
title: "P3 before v3"
sites:
matrix:
versions: ["< v3.0.0"]
---
Content before v3.0.0.
-- content/p4.md --
---
title: "P4 after v2"
sites:
matrix:
versions: ["> v2.0.0"]
---
Content after v2.0.0 (exclusive).
-- content/p5.md --
---
title: "P5 range with negation"
sites:
matrix:
versions: [">= v2.0.0", "! v2.1.0", "<= v4.0.0"]
---
Content from v2.0.0 to v4.0.0 but not v2.1.0.
-- layouts/all.html --
Title: {{ .Title }}|
`
b := hugolib.Test(t, files)
// P1: >= v2.0.0 should be in v2.0.0, v2.1.0, v3.0.0, v4.0.0
b.AssertFileContent("public/v2.0.0/p1/index.html", "Title: P1 from v2.0.0|")
b.AssertFileContent("public/v2.1.0/p1/index.html", "Title: P1 from v2.0.0|")
b.AssertFileContent("public/v3.0.0/p1/index.html", "Title: P1 from v2.0.0|")
b.AssertFileContent("public/v4.0.0/p1/index.html", "Title: P1 from v2.0.0|")
b.AssertFileExists("public/v1.0.0/p1/index.html", false)
// P2: >= v2.0.0 AND <= v3.0.0 should be in v2.0.0, v2.1.0, v3.0.0
b.AssertFileContent("public/v2.0.0/p2/index.html", "Title: P2 between v2 and v3|")
b.AssertFileContent("public/v2.1.0/p2/index.html", "Title: P2 between v2 and v3|")
b.AssertFileContent("public/v3.0.0/p2/index.html", "Title: P2 between v2 and v3|")
b.AssertFileExists("public/v1.0.0/p2/index.html", false)
b.AssertFileExists("public/v4.0.0/p2/index.html", false)
// P3: < v3.0.0 should be in v1.0.0, v2.0.0, v2.1.0
b.AssertFileContent("public/v1.0.0/p3/index.html", "Title: P3 before v3|")
b.AssertFileContent("public/v2.0.0/p3/index.html", "Title: P3 before v3|")
b.AssertFileContent("public/v2.1.0/p3/index.html", "Title: P3 before v3|")
b.AssertFileExists("public/v3.0.0/p3/index.html", false)
b.AssertFileExists("public/v4.0.0/p3/index.html", false)
// P4: > v2.0.0 should be in v2.1.0, v3.0.0, v4.0.0
b.AssertFileContent("public/v2.1.0/p4/index.html", "Title: P4 after v2|")
b.AssertFileContent("public/v3.0.0/p4/index.html", "Title: P4 after v2|")
b.AssertFileContent("public/v4.0.0/p4/index.html", "Title: P4 after v2|")
b.AssertFileExists("public/v1.0.0/p4/index.html", false)
b.AssertFileExists("public/v2.0.0/p4/index.html", false)
// P5: >= v2.0.0 AND ! v2.1.0 AND <= v4.0.0 should be in v2.0.0, v3.0.0, v4.0.0
b.AssertFileContent("public/v2.0.0/p5/index.html", "Title: P5 range with negation|")
b.AssertFileContent("public/v3.0.0/p5/index.html", "Title: P5 range with negation|")
b.AssertFileContent("public/v4.0.0/p5/index.html", "Title: P5 range with negation|")
b.AssertFileExists("public/v1.0.0/p5/index.html", false)
b.AssertFileExists("public/v2.1.0/p5/index.html", false) // Excluded by negation
}
func TestSitesMatrixFileMountOrder(t *testing.T) {
t.Parallel()
+11 -14
View File
@@ -760,22 +760,19 @@ func (b *IntSetsBuilder) WithConfig(cfg IntSetsConfig) *IntSetsBuilder {
return result, nil
}
// Dot separated globs.
filter, err := predicate.NewStringPredicateFromGlobs(values, hglob.GetGlobDot)
filter, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(values, matcher.ResolveIndex, hglob.GetGlobDot)
if err != nil {
return nil, fmt.Errorf("failed to create filter for %s: %w", what, err)
}
for _, pattern := range values {
iter, err := matcher.IndexMatch(filter)
if err != nil {
return nil, fmt.Errorf("failed to match %s %q: %w", what, pattern, err)
}
for i := range iter {
if result == nil {
result = hmaps.NewOrderedIntSet()
}
result.Set(i)
iter, err := matcher.IndexMatch(filter)
if err != nil {
return nil, fmt.Errorf("failed to match %s %q: %w", what, values, err)
}
for i := range iter {
if result == nil {
result = hmaps.NewOrderedIntSet()
}
result.Set(i)
}
return result, nil
@@ -968,10 +965,10 @@ func (m *testDimension) ForEachIndex() iter.Seq[int] {
}
}
func (m *testDimension) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
func (m *testDimension) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
return func(yield func(i int) bool) {
for i, n := range m.names {
if match(n) {
if match(predicate.IndexString{Index: i, String: n}) {
if !yield(i) {
return
}
+3 -3
View File
@@ -104,14 +104,14 @@ func (r VersionsInternal) ResolveIndex(name string) int {
return i
}
}
panic(fmt.Sprintf("no version found for name %q", name))
return -1
}
// IndexMatch returns an iterator for the versions that match the filter.
func (r VersionsInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
func (r VersionsInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
return func(yield func(i int) bool) {
for i, version := range r.Sorted {
if match(version.Name) {
if match(predicate.IndexString{Index: i, String: version.Name}) {
if !yield(i) {
return
}
+3 -3
View File
@@ -89,7 +89,7 @@ func (ls LanguagesInternal) ResolveIndex(name string) int {
return i
}
}
panic(fmt.Sprintf("no language found for name %q", name))
return -1
}
func (ls LanguagesInternal) Len() int {
@@ -97,10 +97,10 @@ func (ls LanguagesInternal) Len() int {
}
// IndexMatch returns an iterator for the roles that match the filter.
func (ls LanguagesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
func (ls LanguagesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
return func(yield func(i int) bool) {
for i, l := range ls.Sorted {
if match(l.Name) {
if match(predicate.IndexString{Index: i, String: l.Name}) {
if !yield(i) {
return
}