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 }