Skip to content

Commit 673ee5e

Browse files
committed
Introduce selector API
Add a new API that allows the selection of elements based on a name and labels. Partially inspired by the Kubernetes "labels" concept. The core concepts are: - `Selectable` : Something that can be selected (i.e. has a name and can have labels). - `SelectableSet` : A set of selectable elements. - `Selector` a was to select selectable elements from a selectable set. This interface is designed to be bolted-on to existing customizer interfaces.
1 parent ade5b17 commit 673ee5e

28 files changed

+3123
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.selector;
18+
19+
import java.util.Collections;
20+
import java.util.Iterator;
21+
import java.util.LinkedHashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.function.BiFunction;
25+
import java.util.function.BiPredicate;
26+
import java.util.function.Function;
27+
import java.util.function.Predicate;
28+
import java.util.stream.Collectors;
29+
import java.util.stream.Stream;
30+
31+
import org.springframework.util.Assert;
32+
import org.springframework.util.StringUtils;
33+
34+
/**
35+
* Skeletal implementation of {@link SelectableSet} designed for subclassing.
36+
*
37+
* @param <S> a self reference for fluent methods
38+
* @param <E> the type of elements maintained
39+
* @author Phillip Webb
40+
* @since 4.0.0
41+
*/
42+
public abstract class AbstractSelectableSet<S extends SelectableSet<S, E>, E> implements SelectableSet<S, E> {
43+
44+
private final Map<String, Entry<E>> entries;
45+
46+
private final Predicate<Entry<E>> predicate;
47+
48+
private volatile Boolean empty;
49+
50+
private volatile String toString;
51+
52+
/**
53+
* Package-private constructor used to create {@link SelectableSet#empty()}.
54+
*/
55+
AbstractSelectableSet() {
56+
this(Collections.emptyMap());
57+
}
58+
59+
/**
60+
* Create a new {@link AbstractSelectableSet} instance with the given entries and
61+
* predicate.
62+
* @param entries the set entries
63+
* @param predicate an updated predicate
64+
* @see #withPredicate(Map, Predicate)
65+
*/
66+
protected AbstractSelectableSet(Map<String, Entry<E>> entries, Predicate<Entry<E>> predicate) {
67+
this.entries = entries;
68+
this.predicate = predicate;
69+
}
70+
71+
/**
72+
* Create a new {@link AbstractSelectableSet} instance from the given {@link Stream}.
73+
* @param <T> the type managed by the stream
74+
* @param stream the source stream
75+
* @param entryProvider a function to to create an {@link SelectableSet.Entry} from a
76+
* stream element
77+
* @throws DuplicateSelectableNameException if duplicate selectable names are used
78+
*/
79+
protected <T> AbstractSelectableSet(Stream<T> stream, Function<? super T, Entry<E>> entryProvider)
80+
throws DuplicateSelectableNameException {
81+
this(buildEntries(stream, entryProvider));
82+
}
83+
84+
/**
85+
* Create a new {@link AbstractSelectableSet} instance from the given {@link Map}.
86+
* @param <K> the map key type
87+
* @param <V> the map value type
88+
* @param map the source mao
89+
* @param entryProvider a bi-function to to create an {@link SelectableSet.Entry} from
90+
* a map entry
91+
* @throws DuplicateSelectableNameException if duplicate selectable names are used
92+
*/
93+
protected <K, V> AbstractSelectableSet(Map<K, V> map, BiFunction<? super K, ? super V, Entry<E>> entryProvider)
94+
throws DuplicateSelectableNameException {
95+
this(buildEntries(map, entryProvider));
96+
}
97+
98+
/**
99+
* Create a new {@link AbstractSelectableSet} instance from the given
100+
* {@link Iterable}.
101+
* @param <T> the type managed by the iterable
102+
* @param iterable the source iterable
103+
* @param entryProvider a function to to create an {@link SelectableSet.Entry} from a
104+
* stream element
105+
* @throws DuplicateSelectableNameException if duplicate selectable names are used
106+
*/
107+
protected <T> AbstractSelectableSet(Iterable<T> iterable, Function<? super T, Entry<E>> entryProvider)
108+
throws DuplicateSelectableNameException {
109+
this(buildEntries(iterable, entryProvider));
110+
}
111+
112+
private AbstractSelectableSet(Map<String, Entry<E>> entries) {
113+
this(entries, (entry) -> true);
114+
}
115+
116+
/**
117+
* Factory method that must be implemented by subclasses to create a copy of this set
118+
* with an updated predicate.
119+
* @param entries the set entries
120+
* @param predicate an updated predicate
121+
* @return a new {@link SelectableSet} instance
122+
* @see #AbstractSelectableSet(Map, Predicate)
123+
*/
124+
protected abstract S withPredicate(Map<String, Entry<E>> entries, Predicate<Entry<E>> predicate);
125+
126+
private static <K, V, E> Map<String, Entry<E>> buildEntries(Map<K, V> map,
127+
BiFunction<? super K, ? super V, Entry<E>> entryProvider) throws DuplicateSelectableNameException {
128+
Assert.notNull(map, "'map' must not be null");
129+
return buildEntries(map.entrySet(), (mapEntry) -> entryProvider.apply(mapEntry.getKey(), mapEntry.getValue()));
130+
}
131+
132+
private static <T, E> Map<String, Entry<E>> buildEntries(Stream<T> stream,
133+
Function<? super T, Entry<E>> entryProvider) throws DuplicateSelectableNameException {
134+
Assert.notNull(stream, "'stream' must not be null");
135+
return buildEntries(stream::iterator, entryProvider);
136+
}
137+
138+
private static <T, E> Map<String, Entry<E>> buildEntries(Iterable<T> iterable,
139+
Function<? super T, Entry<E>> entryProvider) throws DuplicateSelectableNameException {
140+
Assert.notNull(iterable, "'iterable' must not be null");
141+
Assert.notNull(entryProvider, "'entryProvider' must not be null");
142+
Map<String, Entry<E>> entries = new LinkedHashMap<>();
143+
for (T source : iterable) {
144+
Entry<E> entry = entryProvider.apply(source);
145+
String name = entry.selectable().name();
146+
Assert.state(StringUtils.hasText(name), "Selectable instances must have a name");
147+
Entry<E> duplicate = entries.put(name, entry);
148+
if (duplicate != null) {
149+
List<Selectable> duplicates = List.of(entry.selectable(), entry.selectable());
150+
throw new DuplicateSelectableNameException(null, duplicates, null);
151+
}
152+
}
153+
return Collections.unmodifiableMap(entries);
154+
}
155+
156+
@Override
157+
public Entry<E> getEntry(String name) {
158+
Assert.notNull(name, "'name' must not be null");
159+
Entry<E> entry = this.entries.get(name);
160+
if (entry == null || !this.predicate.test(entry)) {
161+
throw new NoSuchSelectableNameException(null, name, null);
162+
}
163+
return entry;
164+
}
165+
166+
@Override
167+
public Iterator<E> iterator() {
168+
return stream().iterator();
169+
}
170+
171+
@Override
172+
public Stream<Entry<E>> streamEntries() {
173+
return (!isKnownEmpty()) ? this.entries.values().stream().filter(this::filter) : Stream.empty();
174+
}
175+
176+
private boolean filter(Entry<E> entry) {
177+
return this.predicate.test(entry);
178+
}
179+
180+
@Override
181+
public boolean isEmpty() {
182+
Boolean empty = this.empty;
183+
if (empty == null) {
184+
empty = !iterator().hasNext();
185+
this.empty = empty;
186+
}
187+
return empty;
188+
}
189+
190+
@Override
191+
@SuppressWarnings("unchecked")
192+
public S having(BiPredicate<Selectable, E> predicate) {
193+
if (isKnownEmpty()) {
194+
return (S) this;
195+
}
196+
return withPredicate(this.entries, this.predicate.and(entryPredicate(predicate)));
197+
}
198+
199+
private Predicate<Entry<E>> entryPredicate(BiPredicate<? super Selectable, ? super E> predicate) {
200+
return (entry) -> predicate.test(entry.selectable(), entry.element());
201+
}
202+
203+
private boolean isKnownEmpty() {
204+
return Boolean.TRUE.equals(this.empty);
205+
}
206+
207+
@Override
208+
public String toString() {
209+
String toString = this.toString;
210+
if (toString == null) {
211+
toString = streamEntries().map(SimpleSelectableSetEntry::toString)
212+
.collect(Collectors.joining(", ", "[", "]"));
213+
this.toString = toString;
214+
}
215+
return toString;
216+
}
217+
218+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.selector;
18+
19+
import java.util.Collection;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.stream.Collectors;
23+
24+
import org.springframework.util.CollectionUtils;
25+
import org.springframework.util.StringUtils;
26+
27+
/**
28+
* {@link SelectorsException} thrown to prevent duplicate {@link Label#key() label keys}
29+
* from being added.
30+
*
31+
* @author Phillip Webb
32+
* @since 4.0.0
33+
*/
34+
public class DuplicateLabelKeyException extends SelectorsException {
35+
36+
private final Collection<Label> duplicates;
37+
38+
/**
39+
* Create a new {@link DuplicateSelectableNameException}.
40+
* @param message the exception message or {@code null} to use the default message
41+
*/
42+
public DuplicateLabelKeyException(String message) {
43+
this(message, null);
44+
}
45+
46+
/**
47+
* Create a new {@link DuplicateLabelKeyException}.
48+
* @param message the exception message or {@code null} to use the default message
49+
* @param cause the cause of the exception or {@code null}
50+
*/
51+
public DuplicateLabelKeyException(String message, Throwable cause) {
52+
this(message, null, cause);
53+
}
54+
55+
/**
56+
* Create a new {@link DuplicateLabelKeyException}.
57+
* @param message the exception message or {@code null} to use the default message
58+
* @param duplicates the duplicates (if known)
59+
* @param cause the cause of the exception or {@code null}
60+
*/
61+
public DuplicateLabelKeyException(String message, Collection<? extends Label> duplicates, Throwable cause) {
62+
super((!StringUtils.hasText(message)) ? defaultMessage(duplicates) : message, cause);
63+
this.duplicates = (duplicates != null) ? List.copyOf(duplicates) : Collections.emptyList();
64+
}
65+
66+
private static String defaultMessage(Collection<? extends Label> duplicates) {
67+
String message = "Duplicate labels detected";
68+
if (!CollectionUtils.isEmpty(duplicates)) {
69+
message += duplicates.stream().map(SimpleLabel::toString).collect(Collectors.joining("', '", ": '", "'"));
70+
}
71+
return message;
72+
}
73+
74+
/**
75+
* Return the duplicates that caused the exception or an empty collection if the
76+
* duplicates are not known.
77+
* @return a collection of the duplicates
78+
*/
79+
public Collection<Label> getDuplicates() {
80+
return this.duplicates;
81+
}
82+
83+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.selector;
18+
19+
import java.util.Collection;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.stream.Collectors;
23+
24+
import org.springframework.util.CollectionUtils;
25+
import org.springframework.util.StringUtils;
26+
27+
/**
28+
* {@link SelectorsException} thrown to prevent duplicate {@link Selectable#name()
29+
* selectable names} from being added.
30+
*
31+
* @author Phillip Webb
32+
* @since 4.0.0
33+
*/
34+
public class DuplicateSelectableNameException extends SelectorsException {
35+
36+
private final Collection<Selectable> duplicates;
37+
38+
/**
39+
* Create a new {@link DuplicateSelectableNameException}.
40+
* @param message the exception message or {@code null} to use the default message
41+
*/
42+
public DuplicateSelectableNameException(String message) {
43+
this(message, null);
44+
}
45+
46+
/**
47+
* Create a new {@link DuplicateSelectableNameException}.
48+
* @param message the exception message or {@code null} to use the default message
49+
* @param cause the cause of the exception or {@code null}
50+
*/
51+
public DuplicateSelectableNameException(String message, Throwable cause) {
52+
this(message, null, cause);
53+
}
54+
55+
/**
56+
* Create a new {@link DuplicateSelectableNameException}.
57+
* @param message the exception message or {@code null} to use the default message
58+
* @param duplicates the duplicates that caused the exception (if known)
59+
* @param cause the cause of the exception or {@code null}
60+
*/
61+
public DuplicateSelectableNameException(String message, Collection<? extends Selectable> duplicates,
62+
Throwable cause) {
63+
super((!StringUtils.hasText(message)) ? message : defaultMessage(duplicates), cause);
64+
this.duplicates = (!CollectionUtils.isEmpty(duplicates)) ? List.copyOf(duplicates) : Collections.emptyList();
65+
}
66+
67+
/**
68+
* Return the duplicates that caused the exception or an empty collection if the
69+
* duplicates are not known.
70+
* @return a collection of the duplicates
71+
*/
72+
public Collection<Selectable> getDuplicates() {
73+
return this.duplicates;
74+
}
75+
76+
private static String defaultMessage(Collection<? extends Selectable> duplicates) {
77+
String message = "Duplicate labels detected";
78+
if (!CollectionUtils.isEmpty(duplicates)) {
79+
message += duplicates.stream()
80+
.map(SimpleSelectable::toString)
81+
.collect(Collectors.joining("', '", ": '", "'"));
82+
}
83+
return message;
84+
}
85+
86+
}

0 commit comments

Comments
 (0)