001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package dev.metaschema.databind.io; 007 008import java.io.IOException; 009import java.util.ArrayList; 010import java.util.Collection; 011import java.util.HashMap; 012import java.util.HashSet; 013import java.util.List; 014import java.util.Map; 015import java.util.Set; 016import java.util.stream.Collectors; 017 018import dev.metaschema.core.model.IAssemblyInstance; 019import dev.metaschema.core.model.IBoundObject; 020import dev.metaschema.core.model.IChoiceInstance; 021import dev.metaschema.core.model.IFieldInstance; 022import dev.metaschema.core.model.IFlagInstance; 023import dev.metaschema.core.model.IModelInstance; 024import dev.metaschema.core.model.INamedModelInstanceAbsolute; 025import dev.metaschema.core.util.ObjectUtils; 026import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; 027import dev.metaschema.databind.model.IBoundDefinitionModelComplex; 028import dev.metaschema.databind.model.IBoundProperty; 029import edu.umd.cs.findbugs.annotations.NonNull; 030import edu.umd.cs.findbugs.annotations.Nullable; 031 032/** 033 * Abstract base class for problem handlers that can validate required fields 034 * during deserialization. 035 */ 036public abstract class AbstractProblemHandler implements IProblemHandler { 037 private final boolean validateRequiredFields; 038 039 /** 040 * Construct a new problem handler with default settings. 041 * <p> 042 * Required field validation is enabled by default. 043 */ 044 protected AbstractProblemHandler() { 045 this(true); 046 } 047 048 /** 049 * Construct a new problem handler with the specified validation setting. 050 * 051 * @param validateRequiredFields 052 * {@code true} to validate that required fields are present, 053 * {@code false} to skip validation 054 */ 055 protected AbstractProblemHandler(boolean validateRequiredFields) { 056 this.validateRequiredFields = validateRequiredFields; 057 } 058 059 /** 060 * Determine if required field validation is enabled. 061 * 062 * @return {@code true} if required fields should be validated, {@code false} 063 * otherwise 064 */ 065 protected boolean isValidateRequiredFields() { 066 return validateRequiredFields; 067 } 068 069 @Override 070 public void handleMissingInstances( 071 IBoundDefinitionModelComplex parentDefinition, 072 IBoundObject targetObject, 073 Collection<? extends IBoundProperty<?>> unhandledInstances) throws IOException { 074 // Delegate to the context-aware version with null context 075 handleMissingInstances(parentDefinition, targetObject, unhandledInstances, null); 076 } 077 078 @Override 079 public void handleMissingInstances( 080 IBoundDefinitionModelComplex parentDefinition, 081 IBoundObject targetObject, 082 Collection<? extends IBoundProperty<?>> unhandledInstances, 083 @Nullable ValidationContext context) throws IOException { 084 if (isValidateRequiredFields()) { 085 validateRequiredFields(parentDefinition, unhandledInstances, context); 086 } 087 applyDefaults(targetObject, unhandledInstances); 088 } 089 090 /** 091 * Validate that all required fields have values or defaults. 092 * <p> 093 * This method handles choice groups correctly: if an instance belongs to a 094 * choice and at least one sibling in that choice was provided, the instance is 095 * not considered missing. 096 * 097 * @param parentDefinition 098 * the definition containing the unhandled instances 099 * @param unhandledInstances 100 * the collection of unhandled instances to validate 101 * @param context 102 * the validation context with location and path information, may be 103 * null 104 * @throws IOException 105 * if a required field is missing and has no default value 106 */ 107 protected void validateRequiredFields( 108 @NonNull IBoundDefinitionModelComplex parentDefinition, 109 @NonNull Collection<? extends IBoundProperty<?>> unhandledInstances, 110 @Nullable ValidationContext context) throws IOException { 111 112 // Build a set of unhandled instance names for quick lookup 113 Set<String> unhandledNames = new HashSet<>(); 114 for (IBoundProperty<?> instance : unhandledInstances) { 115 assert instance != null; 116 unhandledNames.add(getInstanceName(instance, context)); 117 } 118 119 // Build a map from instance name to its choice group (if any) 120 Map<String, IChoiceInstance> instanceToChoice = buildInstanceToChoiceMap(parentDefinition, context); 121 122 // Collect missing required properties grouped by type 123 List<IBoundProperty<?>> missingFlags = new ArrayList<>(); 124 List<IBoundProperty<?>> missingFields = new ArrayList<>(); 125 List<IBoundProperty<?>> missingAssemblies = new ArrayList<>(); 126 127 for (IBoundProperty<?> instance : unhandledInstances) { 128 assert instance != null; 129 if (isRequiredAndMissingDefault(instance)) { 130 String instanceName = getInstanceName(instance, context); 131 IChoiceInstance choice = instanceToChoice.get(instanceName); 132 133 if (choice != null) { 134 // Instance belongs to a choice group - check if any sibling was provided 135 if (!isChoiceSatisfied(choice, unhandledNames, context)) { 136 // All siblings in the choice are missing - report this as an error 137 addToTypeList(instance, missingFlags, missingFields, missingAssemblies); 138 } 139 // else: at least one sibling was provided, choice is satisfied 140 } else { 141 // Not in a choice group - normal required field check 142 addToTypeList(instance, missingFlags, missingFields, missingAssemblies); 143 } 144 } 145 } 146 147 if (!missingFlags.isEmpty() || !missingFields.isEmpty() || !missingAssemblies.isEmpty()) { 148 throw new IOException(formatMissingPropertiesMessage( 149 parentDefinition, missingFlags, missingFields, missingAssemblies, context)); 150 } 151 } 152 153 /** 154 * Add an instance to the appropriate type-specific list. 155 * 156 * @param instance 157 * the instance to categorize 158 * @param missingFlags 159 * list for flag instances 160 * @param missingFields 161 * list for field instances 162 * @param missingAssemblies 163 * list for assembly instances 164 */ 165 private static void addToTypeList( 166 @NonNull IBoundProperty<?> instance, 167 @NonNull List<IBoundProperty<?>> missingFlags, 168 @NonNull List<IBoundProperty<?>> missingFields, 169 @NonNull List<IBoundProperty<?>> missingAssemblies) { 170 if (instance instanceof IFlagInstance) { 171 missingFlags.add(instance); 172 } else if (instance instanceof IFieldInstance) { 173 missingFields.add(instance); 174 } else if (instance instanceof IAssemblyInstance) { 175 missingAssemblies.add(instance); 176 } else { 177 // Default to fields for unknown types 178 missingFields.add(instance); 179 } 180 } 181 182 /** 183 * Format a comprehensive error message for missing required properties. 184 * 185 * @param parentDefinition 186 * the parent definition containing the properties 187 * @param missingFlags 188 * missing flag instances 189 * @param missingFields 190 * missing field instances 191 * @param missingAssemblies 192 * missing assembly instances 193 * @param context 194 * the validation context, may be null 195 * @return a formatted error message 196 */ 197 @NonNull 198 private static String formatMissingPropertiesMessage( 199 @NonNull IBoundDefinitionModelComplex parentDefinition, 200 @NonNull List<IBoundProperty<?>> missingFlags, 201 @NonNull List<IBoundProperty<?>> missingFields, 202 @NonNull List<IBoundProperty<?>> missingAssemblies, 203 @Nullable ValidationContext context) { 204 205 StringBuilder message = new StringBuilder(); 206 String parentName = getParentName(parentDefinition, context); 207 Format format = context != null ? context.getFormat() : Format.JSON; 208 209 int totalMissing = missingFlags.size() + missingFields.size() + missingAssemblies.size(); 210 211 if (totalMissing == 1) { 212 // Single missing property - use specific format 213 IBoundProperty<?> missing = ObjectUtils.notNull(!missingFlags.isEmpty() ? missingFlags.get(0) 214 : !missingFields.isEmpty() ? missingFields.get(0) 215 : missingAssemblies.get(0)); 216 String type = getPropertyTypeName(missing, format, false); 217 String name = getInstanceName(missing, context); 218 message.append(String.format("Missing required %s '%s' in '%s'", type, name, parentName)); 219 } else if (hasSingleType(missingFlags, missingFields, missingAssemblies)) { 220 // Multiple properties of single type 221 List<IBoundProperty<?>> list = !missingFlags.isEmpty() ? missingFlags 222 : !missingFields.isEmpty() ? missingFields 223 : missingAssemblies; 224 String type = getPropertyTypeName(ObjectUtils.notNull(list.get(0)), format, true); 225 String names = formatNameList(list, context); 226 message.append(String.format("Missing required %s in '%s': %s", type, parentName, names)); 227 } else { 228 // Multiple properties of different types 229 message.append(String.format("Missing required properties in '%s':", parentName)); 230 if (!missingFlags.isEmpty()) { 231 message.append("\n ").append(getFormatPropertyGroupLabel(true, format)) 232 .append(": ").append(formatNameList(missingFlags, context)); 233 } 234 if (!missingFields.isEmpty()) { 235 message.append("\n ").append(getFormatPropertyGroupLabel(false, format)) 236 .append(": ").append(formatNameList(missingFields, context)); 237 } 238 if (!missingAssemblies.isEmpty()) { 239 message.append("\n ").append(getFormatPropertyGroupLabel(false, format)) 240 .append(": ").append(formatNameList(missingAssemblies, context)); 241 } 242 } 243 244 // Add location and path context 245 if (context != null) { 246 String location = context.formatLocation(); 247 if (!location.isEmpty()) { 248 message.append("\n Location: ").append(location); 249 } 250 String path = context.getPath(); 251 if (!"/".equals(path) && !path.isEmpty()) { 252 message.append("\n Path: ").append(path); 253 } 254 } 255 256 return ObjectUtils.notNull(message.toString()); 257 } 258 259 /** 260 * Get the user-friendly name for a property type in the given format. 261 * <p> 262 * This method maps Metaschema concepts to format-appropriate terminology: 263 * <ul> 264 * <li>XML: flags are "attribute", fields/assemblies are "element"</li> 265 * <li>JSON: all properties are "property"</li> 266 * <li>YAML: all properties are "property"</li> 267 * </ul> 268 * 269 * @param isFlag 270 * {@code true} if the property is a flag instance 271 * @param format 272 * the format being parsed 273 * @param plural 274 * {@code true} for plural form, {@code false} for singular 275 * @return the user-friendly type name 276 */ 277 @NonNull 278 private static String getFormatPropertyTypeName(boolean isFlag, @NonNull Format format, boolean plural) { 279 switch (format) { 280 case XML: 281 if (isFlag) { 282 return plural ? "attributes" : "attribute"; 283 } 284 return plural ? "elements" : "element"; 285 case JSON: 286 case YAML: 287 return plural ? "properties" : "property"; 288 default: 289 // Fallback for any future formats - use generic "property" 290 return plural ? "properties" : "property"; 291 } 292 } 293 294 /** 295 * Get the user-friendly label for a group of properties in the given format. 296 * <p> 297 * Used when listing multiple properties of the same type in error messages. 298 * Delegates to {@link #getFormatPropertyTypeName(boolean, Format, boolean)} and 299 * capitalizes the result. 300 * 301 * @param isFlags 302 * {@code true} if listing flag instances 303 * @param format 304 * the format being parsed 305 * @return the plural label (e.g., "Attributes", "Elements", "Properties") 306 */ 307 @NonNull 308 private static String getFormatPropertyGroupLabel(boolean isFlags, @NonNull Format format) { 309 String typeName = getFormatPropertyTypeName(isFlags, format, true); 310 return Character.toUpperCase(typeName.charAt(0)) + typeName.substring(1); 311 } 312 313 /** 314 * Check if only one type list has entries. 315 */ 316 private static boolean hasSingleType( 317 List<IBoundProperty<?>> flags, 318 List<IBoundProperty<?>> fields, 319 List<IBoundProperty<?>> assemblies) { 320 int nonEmpty = 0; 321 if (!flags.isEmpty()) { 322 nonEmpty++; 323 } 324 if (!fields.isEmpty()) { 325 nonEmpty++; 326 } 327 if (!assemblies.isEmpty()) { 328 nonEmpty++; 329 } 330 return nonEmpty == 1; 331 } 332 333 /** 334 * Get the property type name for error messages in format-appropriate terms. 335 * <p> 336 * Delegates to {@link #getFormatPropertyTypeName(boolean, Format, boolean)} 337 * based on whether the instance is a flag. 338 * 339 * @param instance 340 * the property instance 341 * @param format 342 * the format being parsed 343 * @param plural 344 * {@code true} for plural form, {@code false} for singular 345 * @return the user-friendly type name appropriate for the format 346 */ 347 @NonNull 348 private static String getPropertyTypeName( 349 @NonNull IBoundProperty<?> instance, 350 @NonNull Format format, 351 boolean plural) { 352 boolean isFlag = instance instanceof IFlagInstance; 353 return getFormatPropertyTypeName(isFlag, format, plural); 354 } 355 356 /** 357 * Format a list of property names as a comma-separated string. 358 */ 359 @NonNull 360 private static String formatNameList( 361 @NonNull List<IBoundProperty<?>> instances, 362 @Nullable ValidationContext context) { 363 return ObjectUtils.notNull(instances.stream() 364 .map(i -> getInstanceName(ObjectUtils.notNull(i), context)) 365 .collect(Collectors.joining(", "))); 366 } 367 368 /** 369 * Get the parent definition name for error messages. 370 * 371 * @param parentDefinition 372 * the parent definition 373 * @param context 374 * the validation context, may be null 375 * @return the effective name of the parent 376 */ 377 @NonNull 378 private static String getParentName( 379 @NonNull IBoundDefinitionModelComplex parentDefinition, 380 @Nullable ValidationContext context) { 381 // Use effective name which is format-appropriate 382 return parentDefinition.getEffectiveName(); 383 } 384 385 /** 386 * Build a map from instance name to its containing choice instance. 387 * 388 * @param parentDefinition 389 * the parent definition to examine 390 * @param context 391 * the validation context, may be null 392 * @return a map of instance names to their choice groups, empty if no choices 393 */ 394 @NonNull 395 private static Map<String, IChoiceInstance> buildInstanceToChoiceMap( 396 @NonNull IBoundDefinitionModelComplex parentDefinition, 397 @Nullable ValidationContext context) { 398 Map<String, IChoiceInstance> result = new HashMap<>(); 399 400 if (parentDefinition instanceof IBoundDefinitionModelAssembly) { 401 IBoundDefinitionModelAssembly assembly = (IBoundDefinitionModelAssembly) parentDefinition; 402 for (IChoiceInstance choice : assembly.getChoiceInstances()) { 403 for (INamedModelInstanceAbsolute modelInstance : choice.getNamedModelInstances()) { 404 // Use effective name for format-appropriate matching 405 result.put(modelInstance.getEffectiveName(), choice); 406 } 407 } 408 } 409 410 return result; 411 } 412 413 /** 414 * Check if a choice is satisfied. 415 * <p> 416 * A choice is satisfied if: 417 * <ul> 418 * <li>At least one alternative was provided, OR</li> 419 * <li>The choice is optional (minOccurs = 0) and no alternative is required 420 * </li> 421 * </ul> 422 * 423 * @param choice 424 * the choice to check 425 * @param unhandledNames 426 * the set of instance names that were NOT provided 427 * @param context 428 * the validation context, may be null 429 * @return {@code true} if the choice requirements are satisfied, {@code false} 430 * if a required alternative is missing 431 */ 432 private static boolean isChoiceSatisfied( 433 @NonNull IChoiceInstance choice, 434 @NonNull Set<String> unhandledNames, 435 @Nullable ValidationContext context) { 436 // Check if any alternative was provided 437 for (INamedModelInstanceAbsolute modelInstance : choice.getNamedModelInstances()) { 438 // Use effective name for format-appropriate matching 439 String name = modelInstance.getEffectiveName(); 440 if (!unhandledNames.contains(name)) { 441 // This sibling was provided (not in unhandled list) 442 return true; 443 } 444 } 445 446 // All siblings are in the unhandled list - check if choice is optional 447 // If choice.getMinOccurs() == 0, having no selection is valid 448 return choice.getMinOccurs() == 0; 449 } 450 451 /** 452 * Determine if the given instance is required and has no default value. 453 * 454 * @param instance 455 * the instance to check 456 * @return {@code true} if the instance is required and has no default value 457 */ 458 private static boolean isRequiredAndMissingDefault(@NonNull IBoundProperty<?> instance) { 459 // Check if the instance has a default value 460 Object defaultValue = instance.getResolvedDefaultValue(); 461 if (defaultValue != null) { 462 // Has a default value, so it's not "missing" 463 return false; 464 } 465 466 // Check if the instance is required 467 if (instance instanceof IFlagInstance) { 468 return ((IFlagInstance) instance).isRequired(); 469 } else if (instance instanceof IModelInstance) { 470 return ((IModelInstance) instance).getMinOccurs() > 0; 471 } 472 473 // Unknown instance type, don't require it 474 return false; 475 } 476 477 /** 478 * Get a human-readable name for the instance. 479 * <p> 480 * Uses {@code getEffectiveName()} when available to return the 481 * format-appropriate name (XML element/attribute name for XML, JSON property 482 * name for JSON). Falls back to {@code getJsonName()} if effective name is not 483 * available. 484 * 485 * @param instance 486 * the instance to get the name for 487 * @param context 488 * the validation context, may be null 489 * @return the instance name 490 */ 491 @NonNull 492 private static String getInstanceName( 493 @NonNull IBoundProperty<?> instance, 494 @Nullable ValidationContext context) { 495 // Check for specific instance types that have getEffectiveName() 496 if (instance instanceof IFlagInstance) { 497 return ((IFlagInstance) instance).getEffectiveName(); 498 } else if (instance instanceof IFieldInstance) { 499 return ((IFieldInstance) instance).getEffectiveName(); 500 } else if (instance instanceof IAssemblyInstance) { 501 return ((IAssemblyInstance) instance).getEffectiveName(); 502 } 503 // Fall back to JSON name for other types 504 return instance.getJsonName(); 505 } 506 507 /** 508 * A utility method for applying default values for the provided 509 * {@code unhandledInstances}. 510 * 511 * @param targetObject 512 * the Java object to apply default values to 513 * @param unhandledInstances 514 * the collection of unhandled instances to assign default values for 515 * @throws IOException 516 * if an error occurred while determining the default value for an 517 * instance 518 */ 519 protected static void applyDefaults( 520 @NonNull Object targetObject, 521 @NonNull Collection<? extends IBoundProperty<?>> unhandledInstances) throws IOException { 522 for (IBoundProperty<?> instance : unhandledInstances) { 523 assert instance != null; 524 Object value = instance.getResolvedDefaultValue(); 525 if (value != null) { 526 instance.setValue(targetObject, value); 527 } 528 } 529 } 530}