Skip to content

Commit 8d3b0c1

Browse files
committed
Add range matchers for site matrix vector store filtering
Fixes #14359
1 parent 1878471 commit 8d3b0c1

7 files changed

Lines changed: 226 additions & 25 deletions

File tree

common/predicate/predicate.go

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,32 @@ func (p PR[T]) FilterCopy(s []T) []T {
153153
return result
154154
}
155155

156+
const (
157+
rangeOpNone = iota
158+
rangeOpLT
159+
rangeOpLTE
160+
rangeOpGT
161+
rangeOpGTE
162+
)
163+
164+
func cutRangeOp(s string) (op int, rest string) {
165+
switch {
166+
case strings.HasPrefix(s, ">= "):
167+
return rangeOpGTE, s[3:]
168+
case strings.HasPrefix(s, "<= "):
169+
return rangeOpLTE, s[3:]
170+
case strings.HasPrefix(s, "> "):
171+
return rangeOpGT, s[2:]
172+
case strings.HasPrefix(s, "< "):
173+
return rangeOpLT, s[2:]
174+
default:
175+
return rangeOpNone, s
176+
}
177+
}
178+
156179
// NewStringPredicateFromGlobs creates a string predicate from the given glob patterns.
157180
// A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
181+
// TODO1 consolidate with the one below.
158182
func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string) (glob.Glob, error)) (P[string], error) {
159183
var p PR[string]
160184
for _, pattern := range patterns {
@@ -187,6 +211,77 @@ func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string)
187211
return p.BoolFunc(), nil
188212
}
189213

214+
// NewIndexStringPredicateFromGlobsAndRanges creates a string predicate from the given glob patterns.
215+
// A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
216+
func NewIndexStringPredicateFromGlobsAndRanges(patterns []string, getIndex func(s string) int, getGlob func(pattern string) (glob.Glob, error)) (P[IndexString], error) {
217+
var p PR[IndexString]
218+
for _, pattern := range patterns {
219+
pattern = strings.TrimSpace(pattern)
220+
if pattern == "" {
221+
continue
222+
}
223+
negate := strings.HasPrefix(pattern, hglob.NegationPrefix)
224+
if negate {
225+
pattern = pattern[2:]
226+
g, err := getGlob(pattern)
227+
if err != nil {
228+
return nil, err
229+
}
230+
p = p.And(func(s IndexString) Match {
231+
return BoolMatch(!g.Match(s.String))
232+
})
233+
} else {
234+
// This can be eiter a glob or a value prefixed with one of >, >=, < or <=.
235+
o, v := cutRangeOp(pattern)
236+
if o != rangeOpNone {
237+
i := getIndex(v)
238+
if i == -1 {
239+
// No match possible.
240+
p = p.And(func(s IndexString) Match {
241+
return BoolMatch(false)
242+
})
243+
continue
244+
}
245+
switch o {
246+
// The greater values starts at the top with index 0.
247+
case rangeOpGT:
248+
p = p.And(func(s IndexString) Match {
249+
return BoolMatch(s.Index < i)
250+
})
251+
case rangeOpGTE:
252+
p = p.And(func(s IndexString) Match {
253+
return BoolMatch(s.Index <= i)
254+
})
255+
case rangeOpLT:
256+
p = p.And(func(s IndexString) Match {
257+
return BoolMatch(s.Index > i)
258+
})
259+
case rangeOpLTE:
260+
p = p.And(func(s IndexString) Match {
261+
return BoolMatch(s.Index >= i)
262+
})
263+
}
264+
} else {
265+
g, err := getGlob(pattern)
266+
if err != nil {
267+
return nil, err
268+
}
269+
p = p.Or(func(s IndexString) Match {
270+
return BoolMatch(g.Match(s.String))
271+
})
272+
}
273+
274+
}
275+
}
276+
277+
return p.BoolFunc(), nil
278+
}
279+
280+
type IndexString struct {
281+
Index int
282+
String string
283+
}
284+
190285
type IndexMatcher interface {
191-
IndexMatch(match P[string]) (iter.Seq[int], error)
286+
IndexMatch(match P[IndexString]) (iter.Seq[int], error)
192287
}

common/predicate/predicate_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func TestNewStringPredicateFromGlobs(t *testing.T) {
132132
}
133133

134134
n := func(patterns ...string) predicate.P[string] {
135-
p, err := predicate.NewStringPredicateFromGlobs(patterns, getGlob)
135+
p, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(patterns, getGlob)
136136
c.Assert(err, qt.IsNil)
137137
return p
138138
}

hugolib/roles/roles.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ func (r RolesInternal) ResolveIndex(name string) int {
102102
return i
103103
}
104104
}
105-
panic(fmt.Sprintf("no role found for name %q", name))
105+
return -1
106106
}
107107

108108
// IndexMatch returns an iterator for the roles that match the filter.
109-
func (r RolesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
109+
func (r RolesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
110110
return func(yield func(i int) bool) {
111111
for i, role := range r.Sorted {
112-
if match(role.Name) {
112+
if match(predicate.IndexString{Index: i, String: role.Name}) {
113113
if !yield(i) {
114114
return
115115
}

hugolib/sitesmatrix/sitematrix_integration_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,3 +1430,112 @@ All.
14301430
b = hugolib.Test(t, files)
14311431
b.AssertFileContent("public/guest/en/index.html", "url=https://example.org/guest/\"")
14321432
}
1433+
1434+
func TestSitesMatrixRangeMatchers(t *testing.T) {
1435+
t.Parallel()
1436+
files := `TestSitesMatrixRangeMatchers
1437+
-- hugo.toml --
1438+
baseURL = "https://example.org/"
1439+
defaultContentVersion = "v4.0.0"
1440+
defaultContentVersionInSubDir = true
1441+
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
1442+
1443+
[languages]
1444+
[languages.en]
1445+
weight = 1
1446+
1447+
[versions]
1448+
[versions."v1.0.0"]
1449+
[versions."v2.0.0"]
1450+
[versions."v2.1.0"]
1451+
[versions."v3.0.0"]
1452+
[versions."v4.0.0"]
1453+
1454+
-- content/_index.md --
1455+
---
1456+
title: "Home"
1457+
sites:
1458+
matrix:
1459+
versions: ["**"]
1460+
---
1461+
-- content/p1.md --
1462+
---
1463+
title: "P1 from v2.0.0"
1464+
sites:
1465+
matrix:
1466+
versions: [">= v2.0.0"]
1467+
---
1468+
Content for v2.0.0 and later.
1469+
-- content/p2.md --
1470+
---
1471+
title: "P2 between v2 and v3"
1472+
sites:
1473+
matrix:
1474+
versions: [">= v2.0.0", "<= v3.0.0"]
1475+
---
1476+
Content between v2.0.0 and v3.0.0 (inclusive).
1477+
-- content/p3.md --
1478+
---
1479+
title: "P3 before v3"
1480+
sites:
1481+
matrix:
1482+
versions: ["< v3.0.0"]
1483+
---
1484+
Content before v3.0.0.
1485+
-- content/p4.md --
1486+
---
1487+
title: "P4 after v2"
1488+
sites:
1489+
matrix:
1490+
versions: ["> v2.0.0"]
1491+
---
1492+
Content after v2.0.0 (exclusive).
1493+
-- content/p5.md --
1494+
---
1495+
title: "P5 range with negation"
1496+
sites:
1497+
matrix:
1498+
versions: [">= v2.0.0", "! v2.1.0", "<= v4.0.0"]
1499+
---
1500+
Content from v2.0.0 to v4.0.0 but not v2.1.0.
1501+
-- layouts/all.html --
1502+
Title: {{ .Title }}|
1503+
`
1504+
1505+
b := hugolib.Test(t, files)
1506+
1507+
// P1: >= v2.0.0 should be in v2.0.0, v2.1.0, v3.0.0, v4.0.0
1508+
b.AssertFileContent("public/v2.0.0/p1/index.html", "Title: P1 from v2.0.0|")
1509+
b.AssertFileContent("public/v2.1.0/p1/index.html", "Title: P1 from v2.0.0|")
1510+
b.AssertFileContent("public/v3.0.0/p1/index.html", "Title: P1 from v2.0.0|")
1511+
b.AssertFileContent("public/v4.0.0/p1/index.html", "Title: P1 from v2.0.0|")
1512+
b.AssertFileExists("public/v1.0.0/p1/index.html", false)
1513+
1514+
// P2: >= v2.0.0 AND <= v3.0.0 should be in v2.0.0, v2.1.0, v3.0.0
1515+
b.AssertFileContent("public/v2.0.0/p2/index.html", "Title: P2 between v2 and v3|")
1516+
b.AssertFileContent("public/v2.1.0/p2/index.html", "Title: P2 between v2 and v3|")
1517+
b.AssertFileContent("public/v3.0.0/p2/index.html", "Title: P2 between v2 and v3|")
1518+
b.AssertFileExists("public/v1.0.0/p2/index.html", false)
1519+
b.AssertFileExists("public/v4.0.0/p2/index.html", false)
1520+
1521+
// P3: < v3.0.0 should be in v1.0.0, v2.0.0, v2.1.0
1522+
b.AssertFileContent("public/v1.0.0/p3/index.html", "Title: P3 before v3|")
1523+
b.AssertFileContent("public/v2.0.0/p3/index.html", "Title: P3 before v3|")
1524+
b.AssertFileContent("public/v2.1.0/p3/index.html", "Title: P3 before v3|")
1525+
b.AssertFileExists("public/v3.0.0/p3/index.html", false)
1526+
b.AssertFileExists("public/v4.0.0/p3/index.html", false)
1527+
1528+
// P4: > v2.0.0 should be in v2.1.0, v3.0.0, v4.0.0
1529+
b.AssertFileContent("public/v2.1.0/p4/index.html", "Title: P4 after v2|")
1530+
b.AssertFileContent("public/v3.0.0/p4/index.html", "Title: P4 after v2|")
1531+
b.AssertFileContent("public/v4.0.0/p4/index.html", "Title: P4 after v2|")
1532+
b.AssertFileExists("public/v1.0.0/p4/index.html", false)
1533+
b.AssertFileExists("public/v2.0.0/p4/index.html", false)
1534+
1535+
// P5: >= v2.0.0 AND ! v2.1.0 AND <= v4.0.0 should be in v2.0.0, v3.0.0, v4.0.0
1536+
b.AssertFileContent("public/v2.0.0/p5/index.html", "Title: P5 range with negation|")
1537+
b.AssertFileContent("public/v3.0.0/p5/index.html", "Title: P5 range with negation|")
1538+
b.AssertFileContent("public/v4.0.0/p5/index.html", "Title: P5 range with negation|")
1539+
b.AssertFileExists("public/v1.0.0/p5/index.html", false)
1540+
b.AssertFileExists("public/v2.1.0/p5/index.html", false) // Excluded by negation
1541+
}

hugolib/sitesmatrix/vectorstores.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -760,22 +760,19 @@ func (b *IntSetsBuilder) WithConfig(cfg IntSetsConfig) *IntSetsBuilder {
760760
return result, nil
761761
}
762762

763-
// Dot separated globs.
764-
filter, err := predicate.NewStringPredicateFromGlobs(values, hglob.GetGlobDot)
763+
filter, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(values, matcher.ResolveIndex, hglob.GetGlobDot)
765764
if err != nil {
766765
return nil, fmt.Errorf("failed to create filter for %s: %w", what, err)
767766
}
768-
for _, pattern := range values {
769-
iter, err := matcher.IndexMatch(filter)
770-
if err != nil {
771-
return nil, fmt.Errorf("failed to match %s %q: %w", what, pattern, err)
772-
}
773-
for i := range iter {
774-
if result == nil {
775-
result = hmaps.NewOrderedIntSet()
776-
}
777-
result.Set(i)
767+
iter, err := matcher.IndexMatch(filter)
768+
if err != nil {
769+
return nil, fmt.Errorf("failed to match %s %q: %w", what, values, err)
770+
}
771+
for i := range iter {
772+
if result == nil {
773+
result = hmaps.NewOrderedIntSet()
778774
}
775+
result.Set(i)
779776
}
780777

781778
return result, nil
@@ -968,10 +965,10 @@ func (m *testDimension) ForEachIndex() iter.Seq[int] {
968965
}
969966
}
970967

971-
func (m *testDimension) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
968+
func (m *testDimension) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
972969
return func(yield func(i int) bool) {
973970
for i, n := range m.names {
974-
if match(n) {
971+
if match(predicate.IndexString{Index: i, String: n}) {
975972
if !yield(i) {
976973
return
977974
}

hugolib/versions/versions.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,14 @@ func (r VersionsInternal) ResolveIndex(name string) int {
104104
return i
105105
}
106106
}
107-
panic(fmt.Sprintf("no version found for name %q", name))
107+
return -1
108108
}
109109

110110
// IndexMatch returns an iterator for the versions that match the filter.
111-
func (r VersionsInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
111+
func (r VersionsInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
112112
return func(yield func(i int) bool) {
113113
for i, version := range r.Sorted {
114-
if match(version.Name) {
114+
if match(predicate.IndexString{Index: i, String: version.Name}) {
115115
if !yield(i) {
116116
return
117117
}

langs/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,18 @@ func (ls LanguagesInternal) ResolveIndex(name string) int {
8989
return i
9090
}
9191
}
92-
panic(fmt.Sprintf("no language found for name %q", name))
92+
return -1
9393
}
9494

9595
func (ls LanguagesInternal) Len() int {
9696
return len(ls.Sorted)
9797
}
9898

9999
// IndexMatch returns an iterator for the roles that match the filter.
100-
func (ls LanguagesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
100+
func (ls LanguagesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
101101
return func(yield func(i int) bool) {
102102
for i, l := range ls.Sorted {
103-
if match(l.Name) {
103+
if match(predicate.IndexString{Index: i, String: l.Name}) {
104104
if !yield(i) {
105105
return
106106
}

0 commit comments

Comments
 (0)