Skip to content

Commit 5d8cbca

Browse files
committed
GH-1039 - Revamp JavaPackage's sub-package traversal to retain empty intermediate packages.
1 parent 18f10b0 commit 5d8cbca

File tree

7 files changed

+189
-59
lines changed

7 files changed

+189
-59
lines changed

spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public class JavaPackage implements DescribedIterable<JavaClass>, Comparable<Jav
6060
private final PackageName name;
6161
private final Classes classes, packageClasses;
6262
private final Supplier<Set<JavaPackage>> directSubPackages;
63+
private final Supplier<JavaPackages> subPackages;
6364

6465
/**
6566
* Creates a new {@link JavaPackage} for the given {@link Classes}, name and whether to include all sub-packages.
@@ -76,13 +77,13 @@ private JavaPackage(Classes classes, PackageName name, boolean includeSubPackage
7677
this.packageClasses = classes
7778
.that(resideInAPackage(name.asFilter(includeSubPackages)));
7879
this.name = name;
79-
this.directSubPackages = SingletonSupplier.of(() -> packageClasses.stream() //
80-
.map(it -> it.getPackageName()) //
81-
.filter(Predicate.not(name::hasName)) //
82-
.map(it -> extractDirectSubPackage(it)) //
83-
.distinct() //
84-
.map(it -> of(classes, it)) //
85-
.collect(Collectors.toSet()));
80+
81+
this.directSubPackages = () -> detectSubPackages()
82+
.filter(this::isDirectParentOf)
83+
.collect(Collectors.toUnmodifiableSet());
84+
85+
this.subPackages = SingletonSupplier.of(() -> detectSubPackages()
86+
.collect(collectingAndThen(toUnmodifiableList(), JavaPackages::new)));
8687
}
8788

8889
/**
@@ -93,7 +94,7 @@ private JavaPackage(Classes classes, PackageName name, boolean includeSubPackage
9394
* @return
9495
*/
9596
public static JavaPackage of(Classes classes, String name) {
96-
return new JavaPackage(classes, new PackageName(name), true);
97+
return new JavaPackage(classes, PackageName.of(name), true);
9798
}
9899

99100
/**
@@ -351,13 +352,7 @@ Classes getClasses(Iterable<JavaPackage> exclusions) {
351352
* @since 1.3
352353
*/
353354
JavaPackages getSubPackages() {
354-
355-
return packageClasses.stream() //
356-
.map(JavaClass::getPackageName)
357-
.filter(Predicate.not(name::hasName))
358-
.distinct()
359-
.map(it -> new JavaPackage(classes, new PackageName(it), true))
360-
.collect(collectingAndThen(toUnmodifiableList(), JavaPackages::new));
355+
return subPackages.get();
361356
}
362357

363358
/**
@@ -408,6 +403,21 @@ public <A extends Annotation> Optional<A> findAnnotation(Class<A> annotationType
408403
});
409404
}
410405

406+
/**
407+
* Returns whether the current {@link JavaPackage} is the direct parent of the given one.
408+
*
409+
* @param reference must not be {@literal null}.
410+
* @since 1.4
411+
*/
412+
boolean isDirectParentOf(JavaPackage reference) {
413+
414+
Assert.notNull(reference, "Reference JavaPackage must not be null!");
415+
416+
var name = reference.getPackageName();
417+
418+
return name.hasParent() && this.getPackageName().equals(name.getParent());
419+
}
420+
411421
/*
412422
* (non-Javadoc)
413423
* @see com.tngtech.archunit.base.HasDescription#getDescription()
@@ -417,6 +427,17 @@ public String getDescription() {
417427
return classes.getDescription();
418428
}
419429

430+
private Stream<JavaPackage> detectSubPackages() {
431+
432+
return packageClasses.stream() //
433+
.map(JavaClass::getPackageName)
434+
.filter(Predicate.not(name::hasName))
435+
.map(PackageName::of)
436+
.flatMap(name::expandUntil)
437+
.distinct()
438+
.map(it -> new JavaPackage(classes, it, true));
439+
}
440+
420441
/*
421442
* (non-Javadoc)
422443
* @see java.lang.Iterable#iterator()
@@ -479,24 +500,6 @@ public int hashCode() {
479500
return Objects.hash(classes, directSubPackages.get(), name, packageClasses);
480501
}
481502

482-
/**
483-
* Extract the direct sub-package name of the given candidate.
484-
*
485-
* @param candidate
486-
* @return will never be {@literal null}.
487-
*/
488-
private String extractDirectSubPackage(String candidate) {
489-
490-
if (candidate.length() <= name.length()) {
491-
return candidate;
492-
}
493-
494-
int subSubPackageIndex = candidate.indexOf('.', name.length() + 1);
495-
int endIndex = subSubPackageIndex == -1 ? candidate.length() : subSubPackageIndex;
496-
497-
return candidate.substring(0, endIndex);
498-
}
499-
500503
static Comparator<JavaPackage> reverse() {
501504
return (left, right) -> -left.compareTo(right);
502505
}

spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ private Predicate<JavaPackage> matchesTrailingName(Collection<String> names) {
456456

457457
return it -> {
458458

459-
var trailingName = new PackageName(basePackage.getTrailingName(it));
459+
var trailingName = PackageName.of(basePackage.getTrailingName(it));
460460

461461
return names.stream().anyMatch(trailingName::nameContainsOrMatches);
462462
};

spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@
1515
*/
1616
package org.springframework.modulith.core;
1717

18+
import java.util.Arrays;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.stream.Collectors;
1822
import java.util.stream.Stream;
1923

24+
import org.springframework.lang.Nullable;
2025
import org.springframework.util.Assert;
2126
import org.springframework.util.ClassUtils;
2227

@@ -29,6 +34,8 @@
2934
*/
3035
class PackageName implements Comparable<PackageName> {
3136

37+
private static final Map<String, PackageName> PACKAGE_NAMES = new HashMap<>();
38+
3239
private final String name;
3340
private final String[] segments;
3441

@@ -37,12 +44,23 @@ class PackageName implements Comparable<PackageName> {
3744
*
3845
* @param name must not be {@literal null}.
3946
*/
40-
public PackageName(String name) {
47+
private PackageName(String name) {
48+
this(name, name.split("\\."));
49+
}
50+
51+
/**
52+
* Creates a new {@link PackageName} with the given name and segments.
53+
*
54+
* @param name must not be {@literal null}.
55+
* @param segments must not be {@literal null}.
56+
*/
57+
private PackageName(String name, String[] segments) {
4158

4259
Assert.notNull(name, "Name must not be null!");
60+
Assert.notNull(segments, "Segments must not be null!");
4361

4462
this.name = name;
45-
this.segments = name.split("\\.");
63+
this.segments = segments;
4664
}
4765

4866
/**
@@ -51,11 +69,41 @@ public PackageName(String name) {
5169
* @param fullyQualifiedName must not be {@literal null} or empty.
5270
* @return will never be {@literal null}.
5371
*/
54-
public static PackageName ofType(String fullyQualifiedName) {
72+
static PackageName ofType(String fullyQualifiedName) {
5573

5674
Assert.notNull(fullyQualifiedName, "Type name must not be null!");
5775

58-
return new PackageName(ClassUtils.getPackageName(fullyQualifiedName));
76+
return PackageName.of(ClassUtils.getPackageName(fullyQualifiedName));
77+
}
78+
79+
/**
80+
* Returns the {@link PackageName} with the given name.
81+
*
82+
* @param name must not be {@literal null}.
83+
* @return will never be {@literal null}.
84+
* @since 1.4
85+
*/
86+
static PackageName of(String name) {
87+
88+
Assert.notNull(name, "Name must not be null!");
89+
90+
return PACKAGE_NAMES.computeIfAbsent(name, PackageName::new);
91+
}
92+
93+
/**
94+
* Returns the {@link PackageName} for the given segments.
95+
*
96+
* @param segments must not be {@literal null}.
97+
* @return will never be {@literal null}.
98+
* @since 1.4
99+
*/
100+
static PackageName of(String[] segments) {
101+
102+
Assert.notNull(segments, "Segments must not be null!");
103+
104+
var name = Stream.of(segments).collect(Collectors.joining("."));
105+
106+
return PACKAGE_NAMES.computeIfAbsent(name, it -> new PackageName(name, segments));
59107
}
60108

61109
/**
@@ -134,6 +182,10 @@ boolean isParentPackageOf(PackageName reference) {
134182
return reference.name.startsWith(name + ".");
135183
}
136184

185+
boolean isDirectParentOf(PackageName reference) {
186+
return this.equals(getParent());
187+
}
188+
137189
/**
138190
* Returns whether the package name contains the given one, i.e. if the given one either is the current one or a
139191
* sub-package of it.
@@ -208,6 +260,48 @@ public int compareTo(PackageName o) {
208260
return segments.length - o.segments.length;
209261
}
210262

263+
/**
264+
* Returns the names of sub-packages of the current one until the given reference {@link PackageName}.
265+
*
266+
* @param reference must not be {@literal null}.
267+
* @return will never be {@literal null}.
268+
* @since 1.4
269+
*/
270+
Stream<PackageName> expandUntil(PackageName reference) {
271+
272+
Assert.notNull(reference, "Reference must not be null!");
273+
274+
if (!reference.isSubPackageOf(this) || !reference.hasParent()) {
275+
return Stream.empty();
276+
}
277+
278+
if (isDirectParentOf(reference)) {
279+
return Stream.of(reference);
280+
}
281+
282+
return Stream.concat(expandUntil(reference.getParent()), Stream.of(reference));
283+
}
284+
285+
/**
286+
* Returns whether the current {@link PackageName} has a parent.
287+
*
288+
* @since 1.4
289+
*/
290+
boolean hasParent() {
291+
return segments.length > 1;
292+
}
293+
294+
/**
295+
* Returns the parent {@link PackageName}.
296+
*
297+
* @return can be {@literal null}.
298+
* @since 1.4
299+
*/
300+
@Nullable
301+
PackageName getParent() {
302+
return PackageName.of(Arrays.copyOf(segments, segments.length - 1));
303+
}
304+
211305
/*
212306
* (non-Javadoc)
213307
* @see java.lang.Object#toString()

spring-modulith-core/src/test/java/org/springframework/modulith/core/JavaPackageUnitTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,16 @@ void samePackagesConsideredEqual() {
116116
assertThat(first.equals(second)).isTrue();
117117
assertThat(second.equals(first)).isTrue();
118118
}
119+
120+
@Test // GH-1039
121+
void detectsIntermediateSubPackages() {
122+
123+
var packages = TestUtils.getPackage("with").getSubPackages();
124+
125+
assertThat(packages)
126+
.extracting(JavaPackage::getName)
127+
.containsExactly("with.many",
128+
"with.many.intermediate",
129+
"with.many.intermediate.packages");
130+
}
119131
}

spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ class PackageNameUnitTests {
3131
@Test // GH-578
3232
void sortsPackagesByNameAndDepth() {
3333

34-
var comAcme = new PackageName("com.acme");
35-
var comAcmeA = new PackageName("com.acme.a");
36-
var comAcmeAFirst = new PackageName("com.acme.a.first");
37-
var comAcmeAFirstOne = new PackageName("com.acme.a.first.one");
38-
var comAcmeASecond = new PackageName("com.acme.a.second");
39-
var comAcmeB = new PackageName("com.acme.b");
34+
var comAcme = PackageName.of("com.acme");
35+
var comAcmeA = PackageName.of("com.acme.a");
36+
var comAcmeAFirst = PackageName.of("com.acme.a.first");
37+
var comAcmeAFirstOne = PackageName.of("com.acme.a.first.one");
38+
var comAcmeASecond = PackageName.of("com.acme.a.second");
39+
var comAcmeB = PackageName.of("com.acme.b");
4040

4141
assertThat(List.of(comAcmeAFirstOne, comAcmeB, comAcmeASecond, comAcmeAFirst, comAcme, comAcmeA)
4242
.stream()
@@ -48,8 +48,8 @@ void sortsPackagesByNameAndDepth() {
4848
@Test // GH-802
4949
void caculatesNestingCorrectly() {
5050

51-
var comAcme = new PackageName("com.acme");
52-
var comAcmeA = new PackageName("com.acme.a");
51+
var comAcme = PackageName.of("com.acme");
52+
var comAcmeA = PackageName.of("com.acme.a");
5353

5454
assertThat(comAcme.contains(comAcme)).isTrue();
5555
assertThat(comAcme.contains(comAcmeA)).isTrue();
@@ -59,7 +59,7 @@ void caculatesNestingCorrectly() {
5959
@Test
6060
void findsMatchingSegments() {
6161

62-
var source = new PackageName("com.acme.foo");
62+
var source = PackageName.of("com.acme.foo");
6363

6464
assertThat(source.nameContainsOrMatches("acme")).isTrue();
6565
assertThat(source.nameContainsOrMatches("*me")).isTrue();

spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,28 +85,28 @@ public static Classes getClasses(Class<?> packageType) {
8585
.that(resideInAPackage(packageType.getPackage().getName() + "..")));
8686
}
8787

88-
public static JavaPackage getPackage(Class<?> packageType) {
89-
return JavaPackage.of(TestUtils.getClasses(packageType), packageType.getPackageName());
90-
}
88+
public static Classes getClasses(String packageName) {
9189

92-
public static ApplicationModule getApplicationModule(String packageName) {
90+
Assert.hasText(packageName, "Package name must not be null or empty!");
9391

94-
var pkg = getPackage(packageName);
95-
var source = ApplicationModuleSource.from(pkg, pkg.getLocalName());
92+
return Classes.of(new ClassFileImporter()
93+
.importPackages(packageName));
94+
}
9695

97-
return new ApplicationModule(source);
96+
public static JavaPackage getPackage(Class<?> packageType) {
97+
return JavaPackage.of(TestUtils.getClasses(packageType), packageType.getPackageName());
9898
}
9999

100-
private static JavaPackage getPackage(String name) {
100+
public static JavaPackage getPackage(String name) {
101101
return JavaPackage.of(getClasses(name), name);
102102
}
103103

104-
private static Classes getClasses(String packageName) {
104+
public static ApplicationModule getApplicationModule(String packageName) {
105105

106-
Assert.hasText(packageName, "Package name must not be null or empty!");
106+
var pkg = getPackage(packageName);
107+
var source = ApplicationModuleSource.from(pkg, pkg.getLocalName());
107108

108-
return Classes.of(new ClassFileImporter()
109-
.importPackages(packageName));
109+
return new ApplicationModule(source);
110110
}
111111

112112
private static ApplicationModules of(ModulithMetadata metadata, DescribedPredicate<JavaClass> ignores) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 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+
package with.many.intermediate.packages;
17+
18+
/**
19+
* @author Oliver Drotbohm
20+
*/
21+
class Marker {}

0 commit comments

Comments
 (0)