Skip to content

Add choice instance support for annotation-based bindings #594

@david-waltermire

Description

@david-waltermire

Summary

Currently, the annotation-based binding classes (DefinitionAssembly) don't support IChoiceInstance - the Metaschema concept of mutually exclusive field alternatives (e.g., "either field A or field B"). This limits required field validation for dynamically compiled modules.

Background

Metaschema has two related but distinct concepts:

Concept Interface Purpose Current Support
Choice IChoiceInstance Mutually exclusive alternatives ❌ Not supported
Choice Group IChoiceGroupInstance Polymorphic collection with discriminator ✅ Supported via @BoundChoiceGroup

The @BoundChoice annotation exists for choice groups (polymorphic collections), but there's no equivalent for choices (mutual exclusion).

Impact

When required field validation is enabled (DESERIALIZE_VALIDATE_REQUIRED_FIELDS), the validator needs to know which fields are in a choice group to avoid false positives. Currently:

  • For DefinitionAssemblyGlobal (Metaschema-loaded modules): Works correctly via getChoiceInstances()
  • For DefinitionAssembly (annotation-based bindings): Returns empty list, causing false validation errors

This affects dynamically compiled modules where the code generator creates annotation-based bindings that lose choice information.

Workarounds to Remove

When this issue is implemented, the following workarounds should be removed:

File Workaround Description
BindingModuleLoader.java boundLoader.disableFeature(DESERIALIZE_VALIDATE_REQUIRED_FIELDS) Disables validation for module loading
MetaschemaModuleMetaschemaTest.java deserializeWithValidationDisabled() helper Disables validation in tests
JsonParserTest.java loader.disableFeature(DESERIALIZE_VALIDATE_REQUIRED_FIELDS) Disables validation in choice regression test
AbstractConvertSubcommand.java loader.disableFeature(DESERIALIZE_VALIDATE_REQUIRED_FIELDS) Disables validation in CLI convert command
AbstractValidateContentCommand.java loader.disableFeature(DESERIALIZE_VALIDATE_REQUIRED_FIELDS) Disables validation in CLI validate command
EvaluateMetapathCommand.java loader.disableFeature(DESERIALIZE_VALIDATE_REQUIRED_FIELDS) Disables validation in CLI metapath command

These workarounds exist because pre-generated and dynamically compiled binding classes don't preserve choice group information.

Proposed Solution

1. New Annotation: @BoundChoice

@Retention(RUNTIME)
@Target({ FIELD })
public @interface BoundChoice {
  /**
   * Identifies which choice this field belongs to.
   * Fields with the same choiceId are mutually exclusive.
   */
  String choiceId();
}

2. New Class: InstanceModelChoice

Implement IChoiceInstance for annotation-based bindings:

public class InstanceModelChoice implements IChoiceInstance {
  private final IBoundDefinitionModelAssembly parent;
  private final List<IBoundInstanceModelNamed<?>> instances;
  
  @Override
  public Collection<? extends INamedModelInstanceAbsolute> getNamedModelInstances() {
    return instances;
  }
  // ... other IChoiceInstance methods
}

3. Update AssemblyModelGenerator

Group fields by @BoundChoice annotation and create IChoiceInstance objects:

// Group fields by @BoundChoice choiceId
Map<String, List<IBoundInstanceModelNamed<?>>> choiceGroups = new LinkedHashMap<>();
for (IBoundInstanceModel<?> instance : modelInstances) {
  if (instance instanceof IBoundInstanceModelNamed) {
    BoundChoice annotation = ((IBoundInstanceModelNamed<?>) instance)
        .getField().getAnnotation(BoundChoice.class);
    if (annotation != null) {
      choiceGroups.computeIfAbsent(annotation.choiceId(), k -> new ArrayList<>())
          .add((IBoundInstanceModelNamed<?>) instance);
    }
  }
}

// Create IChoiceInstance for each group
for (List<IBoundInstanceModelNamed<?>> group : choiceGroups.values()) {
  builder.append(new InstanceModelChoice(containingDefinition, group));
}

4. Update Code Generator

Emit @BoundChoice(choiceId = "choice-N") annotations on fields within Metaschema <choice> elements.

5. Adjacency Validation (Important)

Choice fields must be adjacent in the model. In Metaschema, a <choice> element defines alternatives that occupy the same position in the serialization order. Therefore:

  • All fields with the same choiceId must appear consecutively in the class
  • Non-adjacent choice fields indicate a malformed binding class
  • Validation should occur at binding initialization time
// During AssemblyModelGenerator initialization
private void validateChoiceAdjacency(Map<String, List<IBoundInstanceModelNamed<?>>> choiceGroups) {
  for (Map.Entry<String, List<IBoundInstanceModelNamed<?>>> entry : choiceGroups.entrySet()) {
    String choiceId = entry.getKey();
    List<IBoundInstanceModelNamed<?>> instances = entry.getValue();
    
    // Verify all instances have consecutive indices in the model
    if (instances.size() > 1) {
      List<Integer> indices = instances.stream()
          .map(i -> getModelInstanceIndex(i))
          .sorted()
          .collect(Collectors.toList());
      
      for (int i = 1; i < indices.size(); i++) {
        if (indices.get(i) != indices.get(i-1) + 1) {
          throw new IllegalStateException(
              "Choice fields with choiceId '" + choiceId + "' are not adjacent");
        }
      }
    }
  }
}

This catches:

  • Bugs in the code generator that don't maintain field order
  • Manual editing that breaks the choice structure
  • Inheritance issues where fields get interleaved

Files to Modify

File Changes
databind/.../annotations/BoundChoice.java New - annotation definition
databind/.../model/impl/InstanceModelChoice.java New - IChoiceInstance implementation
databind/.../model/impl/AssemblyModelGenerator.java Handle @BoundChoice annotation, validate adjacency
databind/.../codegen/typeinfo/AbstractModelInstanceTypeInfo.java Emit @BoundChoice during code generation
Bootstrap binding classes Regenerate with new annotations

Existing Infrastructure

The model builder already supports choice instances:

  • DefaultAssemblyModelBuilder.append(CI instance) - accepts choice instances
  • DefaultAssemblyModelBuilder.getChoiceInstances() - retrieves them
  • DefaultAssemblyModelBuilder.buildAssembly() - includes them in final model

Acceptance Criteria

  • New @BoundChoice annotation created
  • InstanceModelChoice implements IChoiceInstance
  • AssemblyModelGenerator groups fields by choice and creates instances
  • Adjacency validation - Verify choice fields are consecutive at initialization
  • Code generator emits @BoundChoice for fields in Metaschema choices
  • DefinitionAssembly.getChoiceInstances() returns proper choice instances
  • Required field validation works correctly for dynamically compiled modules
  • Remove workarounds listed above
  • Bootstrap binding classes regenerated
  • Unit tests for choice instance creation and validation
  • Unit tests for adjacency validation (positive and negative cases)
  • All existing tests pass

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions