1 /*
2 * SPDX-FileCopyrightText: none
3 * SPDX-License-Identifier: CC0-1.0
4 */
5
6 package dev.metaschema.core.model.constraint;
7
8 import java.util.List;
9 import java.util.Objects;
10 import java.util.regex.Pattern;
11 import java.util.stream.Collectors;
12
13 import dev.metaschema.core.datatype.IDataTypeAdapter;
14 import dev.metaschema.core.metapath.DynamicContext;
15 import dev.metaschema.core.metapath.format.IPathFormatter;
16 import dev.metaschema.core.metapath.item.ISequence;
17 import dev.metaschema.core.metapath.item.node.INodeItem;
18 import dev.metaschema.core.util.CustomCollectors;
19 import dev.metaschema.core.util.ObjectUtils;
20 import edu.umd.cs.findbugs.annotations.NonNull;
21
22 /**
23 * Provides messaging for constraint violations.
24 */
25 public abstract class AbstractConstraintValidationHandler implements IConstraintValidationHandler {
26 @NonNull
27 private IPathFormatter pathFormatter = IPathFormatter.METAPATH_PATH_FORMATER;
28
29 /**
30 * Get the formatter used to generate content paths for validation issue
31 * locations.
32 *
33 * @return the formatter
34 */
35 @NonNull
36 public IPathFormatter getPathFormatter() {
37 return pathFormatter;
38 }
39
40 /**
41 * Set the path formatter to use when generating contextual paths in validation
42 * messages.
43 *
44 * @param formatter
45 * the path formatter to use
46 */
47 public void setPathFormatter(@NonNull IPathFormatter formatter) {
48 this.pathFormatter = Objects.requireNonNull(formatter, "pathFormatter");
49 }
50
51 /**
52 * Get the path of the provided item using the configured path formatter.
53 *
54 * @param item
55 * the node item to generate the path for
56 * @return the path
57 * @see #getPathFormatter()
58 */
59 protected String toPath(@NonNull INodeItem item) {
60 return item.toPath(getPathFormatter());
61 }
62
63 /**
64 * Construct a new violation message for the provided {@code constraint} applied
65 * to the {@code node}.
66 *
67 * @param constraint
68 * the constraint the requested message pertains to
69 * @param target
70 * the item the constraint targeted
71 * @param testedItems
72 * the items tested by the constraint
73 * @param dynamicContext
74 * the Metapath dynamic execution context to use for Metapath
75 * evaluation
76 * @return the new message
77 * @throws ConstraintValidationException
78 * if the custom message contains a Metapath expression that is
79 * invalid or if the expression failed to evaluate
80 */
81 @NonNull
82 protected String newCardinalityMinimumViolationMessage(
83 @NonNull ICardinalityConstraint constraint,
84 @NonNull INodeItem target,
85 @NonNull ISequence<? extends INodeItem> testedItems,
86 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
87 return constraint.getMessage() == null
88 ? ObjectUtils.notNull(String.format(
89 "The cardinality '%d' is below the required minimum '%d' for items matching '%s'.",
90 testedItems.size(),
91 constraint.getMinOccurs(),
92 constraint.getTarget().getPath()))
93 : constraint.generateMessage(target, dynamicContext);
94 }
95
96 /**
97 * Construct a new violation message for the provided {@code constraint} applied
98 * to the {@code node}.
99 *
100 * @param constraint
101 * the constraint the requested message pertains to
102 * @param target
103 * the item the constraint targeted
104 * @param testedItems
105 * the items tested by the constraint
106 * @param dynamicContext
107 * the Metapath dynamic execution context to use for Metapath
108 * evaluation
109 * @return the new message
110 * @throws ConstraintValidationException
111 * if the custom message contains a Metapath expression that is
112 * invalid or if the expression failed to evaluate
113 */
114 @NonNull
115 protected String newCardinalityMaximumViolationMessage(
116 @NonNull ICardinalityConstraint constraint,
117 @NonNull INodeItem target,
118 @NonNull ISequence<? extends INodeItem> testedItems,
119 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
120 return constraint.getMessage() == null
121 ? ObjectUtils.notNull(String.format(
122 "The cardinality '%d' is greater than the required maximum '%d' at: %s.",
123 testedItems.size(),
124 constraint.getMinOccurs(),
125 testedItems.safeStream()
126 .map(item -> new StringBuilder(12)
127 .append('\'')
128 .append(toPath(ObjectUtils.notNull(item)))
129 .append('\'')
130 .toString())
131 .collect(CustomCollectors.joiningWithOxfordComma("and"))))
132 : constraint.generateMessage(target, dynamicContext);
133 }
134
135 /**
136 * Construct a new violation message for the provided {@code constraint} applied
137 * to the {@code node}.
138 *
139 * @param constraint
140 * the constraint the requested message pertains to
141 * @param node
142 * the item the constraint targeted
143 * @param oldItem
144 * the original item matching the constraint
145 * @param target
146 * the new item matching the constraint
147 * @param dynamicContext
148 * the Metapath dynamic execution context to use for Metapath
149 * evaluation
150 * @return the new message
151 * @throws ConstraintValidationException
152 * if the custom message contains a Metapath expression that is
153 * invalid or if the expression failed to evaluate
154 */
155 @NonNull
156 protected String newIndexDuplicateKeyViolationMessage(
157 @NonNull IIndexConstraint constraint,
158 @NonNull INodeItem node,
159 @NonNull INodeItem oldItem,
160 @NonNull INodeItem target,
161 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
162 // TODO: render the key paths
163 return constraint.getMessage() == null
164 ? ObjectUtils.notNull(String.format("Index '%s' has duplicate key for items at paths '%s' and '%s'",
165 constraint.getName(),
166 toPath(oldItem),
167 toPath(target)))
168 : constraint.generateMessage(target, dynamicContext);
169 }
170
171 /**
172 * Construct a new violation message for the provided {@code constraint} applied
173 * to the {@code node}.
174 *
175 * @param constraint
176 * the constraint the requested message pertains to
177 * @param node
178 * the item the constraint targeted
179 * @param oldItem
180 * the original item matching the constraint
181 * @param target
182 * the new item matching the constraint
183 * @param dynamicContext
184 * the Metapath dynamic execution context to use for Metapath
185 * evaluation
186 * @return the new message
187 * @throws ConstraintValidationException
188 * if the custom message contains a Metapath expression that is
189 * invalid or if the expression failed to evaluate
190 */
191 @NonNull
192 protected String newUniqueKeyViolationMessage(
193 @NonNull IUniqueConstraint constraint,
194 @NonNull INodeItem node,
195 @NonNull INodeItem oldItem,
196 @NonNull INodeItem target,
197 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
198 return constraint.getMessage() == null
199 ? ObjectUtils.notNull(String.format("Unique constraint violation at paths '%s' and '%s'",
200 toPath(oldItem),
201 toPath(target)))
202 : constraint.generateMessage(target, dynamicContext);
203 }
204
205 /**
206 * Construct a new violation message for the provided {@code constraint} applied
207 * to the {@code node}.
208 *
209 * @param constraint
210 * the constraint the requested message pertains to
211 * @param node
212 * the item the constraint targeted
213 * @param target
214 * the target matching the constraint
215 * @param value
216 * the target's value
217 * @param pattern
218 * the expected pattern
219 * @param dynamicContext
220 * the Metapath dynamic execution context to use for Metapath
221 * evaluation
222 * @return the new message
223 * @throws ConstraintValidationException
224 * if the custom message contains a Metapath expression that is
225 * invalid or if the expression failed to evaluate
226 */
227 @NonNull
228 protected String newMatchPatternViolationMessage(
229 @NonNull IMatchesConstraint constraint,
230 @NonNull INodeItem node,
231 @NonNull INodeItem target,
232 @NonNull String value,
233 @NonNull Pattern pattern,
234 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
235 return constraint.getMessage() == null
236 ? ObjectUtils.notNull(String.format("Value '%s' did not match the pattern '%s' at path '%s'",
237 value,
238 pattern.pattern(),
239 toPath(target)))
240 : constraint.generateMessage(target, dynamicContext);
241 }
242
243 /**
244 * Construct a new violation message for the provided {@code constraint} applied
245 * to the {@code node}.
246 *
247 * @param constraint
248 * the constraint the requested message pertains to
249 * @param node
250 * the item the constraint targeted
251 * @param target
252 * the target matching the constraint
253 * @param value
254 * the target's value
255 * @param adapter
256 * the expected data type adapter
257 * @param dynamicContext
258 * the Metapath dynamic execution context to use for Metapath
259 * evaluation
260 * @return the new message
261 * @throws ConstraintValidationException
262 * if the custom message contains a Metapath expression that is
263 * invalid or if the expression failed to evaluate
264 */
265 @NonNull
266 protected String newMatchDatatypeViolationMessage(
267 @NonNull IMatchesConstraint constraint,
268 @NonNull INodeItem node,
269 @NonNull INodeItem target,
270 @NonNull String value,
271 @NonNull IDataTypeAdapter<?> adapter,
272 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
273 return constraint.getMessage() == null
274 ? ObjectUtils.notNull(String.format("Value '%s' did not conform to the data type '%s' at path '%s'",
275 value,
276 adapter.getPreferredName(),
277 toPath(target)))
278 : constraint.generateMessage(target, dynamicContext);
279 }
280
281 /**
282 * Construct a new violation message for the provided {@code constraint} applied
283 * to the {@code node}.
284 *
285 * @param constraint
286 * the constraint the requested message pertains to
287 * @param node
288 * the item the constraint targeted
289 * @param target
290 * the target matching the constraint
291 * @param dynamicContext
292 * the Metapath dynamic execution context to use for Metapath
293 * evaluation
294 * @return the new message
295 * @throws ConstraintValidationException
296 * if the custom message contains a Metapath expression that is
297 * invalid or if the expression failed to evaluate
298 */
299 @NonNull
300 protected String newExpectViolationMessage(
301 @NonNull IExpectConstraint constraint,
302 @NonNull INodeItem node,
303 @NonNull INodeItem target,
304 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
305 return constraint.getMessage() == null
306 ? ObjectUtils.notNull(String.format("Expect constraint '%s' did not match the data at path '%s'",
307 constraint.getTest().getPath(),
308 toPath(target)))
309 : constraint.generateMessage(target, dynamicContext);
310 }
311
312 /**
313 * Construct a new message for the provided report {@code constraint} applied to
314 * the {@code node}.
315 * <p>
316 * Report constraints generate findings when their test expression evaluates to
317 * {@code true}, which is the opposite of expect constraints.
318 *
319 * @param constraint
320 * the constraint the requested message pertains to
321 * @param node
322 * the item the constraint targeted
323 * @param target
324 * the target matching the constraint
325 * @param dynamicContext
326 * the Metapath dynamic execution context to use for Metapath
327 * evaluation
328 * @return the new message
329 * @throws ConstraintValidationException
330 * if the custom message contains a Metapath expression that is
331 * invalid or if the expression failed to evaluate
332 */
333 @NonNull
334 protected String newReportViolationMessage(
335 @NonNull IReportConstraint constraint,
336 @NonNull INodeItem node,
337 @NonNull INodeItem target,
338 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
339 return constraint.getMessage() == null
340 ? ObjectUtils.notNull(String.format("Report constraint '%s' matched the data at path '%s'",
341 constraint.getTest().getPath(),
342 toPath(target)))
343 : constraint.generateMessage(target, dynamicContext);
344 }
345
346 /**
347 * Construct a new violation message for the provided {@code constraint} applied
348 * to the {@code node}.
349 *
350 * @param constraints
351 * the constraints the requested message pertains to
352 * @param target
353 * the target matching the constraint
354 * @return the new message
355 */
356 @NonNull
357 protected String newAllowedValuesViolationMessage(
358 @NonNull List<IAllowedValuesConstraint> constraints,
359 @NonNull INodeItem target) {
360 String allowedValues = constraints.stream()
361 .flatMap(constraint -> constraint.getAllowedValues().values().stream())
362 .map(IAllowedValue::getValue)
363 .sorted()
364 .distinct()
365 .collect(CustomCollectors.joiningWithOxfordComma("or"));
366
367 return ObjectUtils.notNull(String.format("Value '%s' doesn't match one of '%s' at path '%s'",
368 target.toAtomicItem().asString(),
369 allowedValues,
370 toPath(target)));
371 }
372
373 /**
374 * Construct a new violation message for the provided {@code constraint} applied
375 * to the {@code node}.
376 *
377 * @param constraint
378 * the constraint the requested message pertains to
379 * @param node
380 * the item the constraint targeted
381 * @return the new message
382 */
383 @NonNull
384 protected String newIndexDuplicateViolationMessage(
385 @NonNull IIndexConstraint constraint,
386 @NonNull INodeItem node) {
387 return ObjectUtils.notNull(String.format("Duplicate index named '%s' found at path '%s'",
388 constraint.getName(),
389 node.getMetapath()));
390 }
391
392 /**
393 * Construct a new violation message for the provided {@code constraint} applied
394 * to the {@code node}.
395 *
396 * @param constraint
397 * the constraint the requested message pertains to
398 * @param node
399 * the item the constraint targeted
400 * @param target
401 * the target matching the constraint
402 * @param key
403 * the key derived from the target that failed to be found in the index
404 * @param dynamicContext
405 * the Metapath dynamic execution context to use for Metapath
406 * evaluation
407 * @return the new message
408 * @throws ConstraintValidationException
409 * if the custom message contains a Metapath expression that is
410 * invalid or if the expression failed to evaluate
411 */
412 @NonNull
413 protected String newIndexMissMessage(
414 @NonNull IIndexHasKeyConstraint constraint,
415 @NonNull INodeItem node,
416 @NonNull INodeItem target,
417 @NonNull List<String> key,
418 @NonNull DynamicContext dynamicContext) throws ConstraintValidationException {
419 String keyValues = key.stream()
420 .collect(Collectors.joining(","));
421
422 return constraint.getMessage() == null
423 ? ObjectUtils.notNull(String.format("Key reference [%s] not found in index '%s' for item at path '%s'",
424 keyValues,
425 constraint.getIndexName(),
426 target.getMetapath()))
427 : constraint.generateMessage(target, dynamicContext);
428 }
429
430 /**
431 * Construct a new generic violation message for the provided {@code constraint}
432 * applied to the {@code node}.
433 *
434 * @param constraint
435 * the constraint the requested message pertains to
436 * @param node
437 * the item the constraint targeted
438 * @param target
439 * the target matching the constraint
440 * @param message
441 * the message to be added before information about the target path
442 * @param dynamicContext
443 * the Metapath dynamic execution context to use for Metapath
444 * evaluation
445 * @return the new message
446 */
447 @SuppressWarnings("null")
448 @NonNull
449 protected String newMissingIndexViolationMessage(
450 @NonNull IIndexHasKeyConstraint constraint,
451 @NonNull INodeItem node,
452 @NonNull INodeItem target,
453 @NonNull String message,
454 @NonNull DynamicContext dynamicContext) {
455 return String.format("%s for constraint '%s' for item at path '%s'",
456 message,
457 Objects.requireNonNullElse(constraint.getId(), "?"),
458 target.getMetapath());
459 }
460 }