Skip to content

Commit b4a02ec

Browse files
committed
Improve robustness for model validation
1 parent 1340bde commit b4a02ec

4 files changed

Lines changed: 146 additions & 35 deletions

File tree

src/main/java/life/qbic/compass/SignPostingProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ private static List<WebLink> applyModelValidation(List<WebLink> webLinks,
242242
var result = modelValidator.validate(webLinks);
243243
var sanitizedLinks = new ArrayList<WebLink>();
244244
for (int index = 0; index < webLinks.size(); index++) {
245-
if (!result.blockingLinkByIndex()[index]) {
245+
if (!result.blockingIndices().get(index)) {
246246
sanitizedLinks.add(webLinks.get(index));
247247
}
248248
}

src/main/java/life/qbic/compass/spi/WebLinkModelValidator.java

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package life.qbic.compass.spi;
22

3-
import java.util.Arrays;
3+
import java.util.BitSet;
44
import java.util.List;
55
import java.util.Objects;
66
import life.qbic.linksmith.model.WebLink;
@@ -10,7 +10,7 @@
1010
* Contract for validating {@link WebLink} objects at the <em>model level</em>.
1111
*
1212
* <p><strong>Audience:</strong> Maintainers of the Compass library.
13-
* This interface is internal to the validation layer and is not intended as a public extension
13+
* This interface is part of the internal validation layer and is not intended as a public extension
1414
* point for end users.</p>
1515
*
1616
* <h2>Purpose</h2>
@@ -36,8 +36,8 @@
3636
* </p>
3737
* <ul>
3838
* <li>Parsers produce {@link WebLink} instances (possibly permissive).</li>
39-
* <li>{@code WebLinkModelValidator}s ensure the model obeys core Web Linking rules
40-
* (e.g. RFC&nbsp;8288 invariants).</li>
39+
* <li>{@code WebLinkModelValidator}s ensure the model obeys core Web Linking
40+
* invariants (e.g. RFC&nbsp;8288 constraints).</li>
4141
* <li>Signposting validators operate on a trusted model to apply FAIR-specific semantics.</li>
4242
* </ul>
4343
*
@@ -84,45 +84,58 @@ public interface WebLinkModelValidator {
8484
*
8585
* <p>
8686
* Implementations must inspect each element independently and accumulate all detected issues into
87-
* the returned {@link IssueReport}. Validation must not stop after the first failure.
87+
* the returned {@link ModelValidationResult}. Validation must not stop after the first failure.
8888
* </p>
8989
*
9090
* @param webLinks the list of {@link WebLink} instances to validate (must not be {@code null})
91-
* @return an {@link ModelValidationResult} containing all detected issues and the indices of
92-
* weblinks with recorded ERROR
91+
* @return a {@link ModelValidationResult} containing all detected issues and information about
92+
* which input elements are considered blocking
9393
* @throws NullPointerException if {@code webLinks} is {@code null}
9494
*/
9595
ModelValidationResult validate(List<WebLink> webLinks);
9696

97-
98-
record ModelValidationResult(IssueReport issueReport, boolean[] blockingLinkByIndex) {
97+
/**
98+
* Result object returned by {@link WebLinkModelValidator} implementations.
99+
*
100+
* <p>
101+
* The result consists of:
102+
* </p>
103+
* <ul>
104+
* <li>an {@link IssueReport} containing all detected validation issues, and</li>
105+
* <li>a {@link BitSet} indicating which input indices correspond to
106+
* <em>blocking</em> model violations.</li>
107+
* </ul>
108+
*
109+
* <p>
110+
* A blocking index represents a {@link WebLink} that must not be used for
111+
* downstream semantic processing (e.g. Signposting validation).
112+
* </p>
113+
*
114+
* <p>
115+
* This record is <strong>immutable</strong>:
116+
* </p>
117+
* <ul>
118+
* <li>The contained {@link IssueReport} is defensively copied.</li>
119+
* <li>The {@link BitSet} is defensively copied using {@link BitSet#clone()}.</li>
120+
* </ul>
121+
*
122+
* <p>
123+
* Modifying the original {@link IssueReport} or {@link BitSet} passed to the
124+
* constructor has no effect on the created result instance.
125+
* </p>
126+
*
127+
* @param issueReport all detected validation issues
128+
* @param blockingIndices bit set marking indices of blocking WebLinks
129+
* @since 1.0.0
130+
*/
131+
record ModelValidationResult(IssueReport issueReport, BitSet blockingIndices) {
99132

100133
public ModelValidationResult {
101-
issueReport = new IssueReport(List.copyOf(issueReport.issues()));
102-
blockingLinkByIndex = Arrays.copyOf(blockingLinkByIndex, blockingLinkByIndex.length);
103-
}
134+
Objects.requireNonNull(issueReport, "issueReport");
135+
Objects.requireNonNull(blockingIndices, "blockingIndices");
104136

105-
@Override
106-
public boolean equals(Object o) {
107-
if (o == null || getClass() != o.getClass()) {
108-
return false;
109-
}
110-
ModelValidationResult that = (ModelValidationResult) o;
111-
return Objects.equals(issueReport, that.issueReport) && Objects.deepEquals(
112-
blockingLinkByIndex, that.blockingLinkByIndex);
113-
}
114-
115-
@Override
116-
public int hashCode() {
117-
return Objects.hash(issueReport, Arrays.hashCode(blockingLinkByIndex));
118-
}
119-
120-
@Override
121-
public String toString() {
122-
return "ModelValidationResult{" +
123-
"issueReport=" + issueReport +
124-
", blockingLinkByIndex=" + Arrays.toString(blockingLinkByIndex) +
125-
'}';
137+
issueReport = new IssueReport(List.copyOf(issueReport.issues()));
138+
blockingIndices = (BitSet) blockingIndices.clone();
126139
}
127140
}
128141
}

src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.net.URI;
44
import java.util.ArrayList;
5+
import java.util.BitSet;
56
import java.util.List;
67
import java.util.Objects;
78
import java.util.regex.Pattern;
@@ -147,7 +148,17 @@ public ModelValidationResult validate(List<WebLink> webLinks) {
147148
}
148149
validate(currentLink, issueSink);
149150
}
150-
return new ModelValidationResult(new IssueReport(issueSink.issues), issueSink.blockingLinkIndices);
151+
return new ModelValidationResult(new IssueReport(issueSink.issues), toBitSet(issueSink.blockingLinkIndices));
152+
}
153+
154+
private static BitSet toBitSet(boolean[] flags) {
155+
BitSet bitset = new BitSet(flags.length);
156+
for (int index = 0; index < flags.length; index++) {
157+
if (flags[index]) {
158+
bitset.set(index);
159+
}
160+
}
161+
return bitset;
151162
}
152163

153164
/**
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package life.qbic.compass.spi
2+
3+
import life.qbic.linksmith.spi.WebLinkValidator
4+
import spock.lang.Specification
5+
6+
class WebLinkModelValidatorSpec extends Specification {
7+
def "constructor performs defensive copy of IssueReport issues list"() {
8+
given:
9+
def originalIssues = new ArrayList<WebLinkValidator.Issue>()
10+
originalIssues.add(WebLinkValidator.Issue.warning("w1"))
11+
def originalReport = new WebLinkValidator.IssueReport(originalIssues)
12+
13+
and:
14+
def flags = new BitSet()
15+
flags.set(1)
16+
17+
when:
18+
def result = new WebLinkModelValidator.ModelValidationResult(originalReport, flags)
19+
20+
and: "mutate original list after construction"
21+
originalIssues.add(WebLinkValidator.Issue.error("e1"))
22+
23+
then: "result's report is unaffected"
24+
result.issueReport().issues()*.message() == ["w1"]
25+
}
26+
27+
def "constructor performs defensive copy of BitSet"() {
28+
given:
29+
def report = new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("w1")])
30+
31+
and:
32+
def original = new BitSet()
33+
original.set(0)
34+
original.set(3)
35+
36+
when:
37+
def result = new WebLinkModelValidator.ModelValidationResult(report, original)
38+
39+
and: "mutate original BitSet after construction"
40+
original.clear(0)
41+
original.set(5)
42+
43+
then: "result's BitSet is unaffected (still has original bits)"
44+
result.blockingIndices().get(0)
45+
result.blockingIndices().get(3)
46+
!result.blockingIndices().get(5)
47+
}
48+
49+
def "equals and hashCode consider IssueReport and BitSet content"() {
50+
given:
51+
def report1 = new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("w1"), WebLinkValidator.Issue.error("e1")])
52+
def report2 = new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("w1"), WebLinkValidator.Issue.error("e1")])
53+
54+
and:
55+
def b1 = new BitSet()
56+
b1.set(2); b1.set(7)
57+
58+
def b2 = new BitSet()
59+
b2.set(2); b2.set(7)
60+
61+
when:
62+
def r1 = new WebLinkModelValidator.ModelValidationResult(report1, b1)
63+
def r2 = new WebLinkModelValidator.ModelValidationResult(report2, b2)
64+
65+
then:
66+
r1 == r2
67+
r1.hashCode() == r2.hashCode()
68+
}
69+
70+
def "constructor throws NullPointerException for null fields"() {
71+
given:
72+
def report = new WebLinkValidator.IssueReport([])
73+
def bits = new BitSet()
74+
75+
when:
76+
new WebLinkModelValidator.ModelValidationResult(null, bits)
77+
78+
then:
79+
thrown(NullPointerException)
80+
81+
when:
82+
new WebLinkModelValidator.ModelValidationResult(report, null)
83+
84+
then:
85+
thrown(NullPointerException)
86+
}
87+
}

0 commit comments

Comments
 (0)