diff --git a/.claude/rules/new-metaschema-feature-checklist.md b/.claude/rules/new-metaschema-feature-checklist.md new file mode 100644 index 000000000..2402e4e8c --- /dev/null +++ b/.claude/rules/new-metaschema-feature-checklist.md @@ -0,0 +1,61 @@ +# New Metaschema Feature Checklist + +When adding a new Metaschema model feature (e.g., a new instance type), systematically evaluate **all** areas below. Missing any area risks incomplete support. + +## Mandatory Areas + +### 1. Core Model + +- [ ] Create interface extending appropriate base (`IModelInstanceAbsolute`, etc.) +- [ ] Add `ModelType` enum value if applicable +- [ ] Update container model interfaces and implementations (`IContainerModelAssemblySupport`, `DefaultContainerModelAssemblySupport`, model builder) +- [ ] Update visitor pattern: `IModelElementVisitor`, `AbstractModelElementVisitor`, and ALL implementing visitors +- [ ] Write core model tests + +### 2. Databind Binding Layer + +- [ ] Create annotation in `databind/model/annotations/` +- [ ] Create binding interface in `databind/model/` and implementation in `databind/model/impl/` +- [ ] Create Metaschema binding implementation in `databind/model/metaschema/impl/` +- [ ] Update `AssemblyModelGenerator` (and `ChoiceModelGenerator` if applicable) +- [ ] Update class introspection (`DefinitionAssembly` or similar) to scan for new annotation + +### 3. Databind I/O (if feature handles data) + +- [ ] Update `MetaschemaXmlReader` and `MetaschemaXmlWriter` +- [ ] Update `MetaschemaJsonReader` and `MetaschemaJsonWriter` +- [ ] Create format-specific wrapper classes if needed +- [ ] Write round-trip tests + +### 4. Code Generation + +- [ ] Create type info class in `databind/codegen/typeinfo/` following existing patterns +- [ ] Update `AssemblyDefinitionTypeInfoImpl` to process the new instance type +- [ ] Update `ITypeResolver` if definition resolution is needed +- [ ] Write codegen tests (compile test module, verify generated annotations/fields) + +### 5. Schema Generation + +- [ ] Update XML Schema generator for XSD output +- [ ] Update JSON Schema generator for JSON Schema output +- [ ] Write schema generation tests for both formats + +### 6. Constraint Processing + +- [ ] Update `ConstraintComposingVisitor` visitor method +- [ ] Determine if constraints can target the feature; if not, use `illegalTargetError()` + +## Conditional Areas + +| Area | When Needed | Key Files | +|------|-------------|-----------| +| Metapath/Query | Feature is queryable or affects traversal | `core/.../metapath/` | +| Maven Plugin | New build configuration needed | `metaschema-maven-plugin/` | +| CLI | New commands or output options | `metaschema-cli/`, `cli-processor/` | +| Testing Module | Test infrastructure changes | `metaschema-testing/`, `unit-tests.yaml` | + +## Verification + +```bash +mvn clean install -PCI -Prelease +``` diff --git a/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/AnyInstanceTypeInfoImpl.java b/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/AnyInstanceTypeInfoImpl.java new file mode 100644 index 000000000..e64cf2354 --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/AnyInstanceTypeInfoImpl.java @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.codegen.typeinfo; + +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.FieldSpec; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; + +import java.util.Set; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IAnyInstance; +import dev.metaschema.core.model.IModelDefinition; +import dev.metaschema.core.util.CollectionUtil; +import dev.metaschema.databind.codegen.typeinfo.def.IAssemblyDefinitionTypeInfo; +import dev.metaschema.databind.model.annotations.BoundAny; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Type information for generating a {@link BoundAny}-annotated field in a Java + * class corresponding to an {@link IAnyInstance} in a Metaschema assembly + * model. + *

+ * The generated field captures unmodeled content using the {@link IAnyContent} + * interface, allowing round-trip preservation of arbitrary properties not + * defined by the Metaschema model. + */ +public class AnyInstanceTypeInfoImpl + extends AbstractInstanceTypeInfo { + + /** + * Constructs a new type information object for an any instance. + * + * @param instance + * the any instance + * @param parentDefinition + * the type information for the parent assembly definition containing + * this instance + */ + public AnyInstanceTypeInfoImpl( + @NonNull IAnyInstance instance, + @NonNull IAssemblyDefinitionTypeInfo parentDefinition) { + super(instance, parentDefinition); + } + + @Override + public String getBaseName() { + return "any"; + } + + @Override + public boolean isRequired() { + return false; + } + + @Override + public TypeName getJavaFieldType() { + return ClassName.get(IAnyContent.class); + } + + @Override + public Set buildField( + TypeSpec.Builder typeBuilder, + FieldSpec.Builder fieldBuilder) { + super.buildField(typeBuilder, fieldBuilder); + fieldBuilder.addAnnotation(BoundAny.class); + return CollectionUtil.emptySet(); + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/def/AssemblyDefinitionTypeInfoImpl.java b/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/def/AssemblyDefinitionTypeInfoImpl.java index 9f210da45..ee766a1ff 100644 --- a/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/def/AssemblyDefinitionTypeInfoImpl.java +++ b/databind/src/main/java/dev/metaschema/databind/codegen/typeinfo/def/AssemblyDefinitionTypeInfoImpl.java @@ -14,6 +14,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IAssemblyDefinition; import dev.metaschema.core.model.IChoiceGroupInstance; import dev.metaschema.core.model.IChoiceInstance; @@ -23,6 +24,7 @@ import dev.metaschema.core.model.INamedModelInstanceAbsolute; import dev.metaschema.core.util.CustomCollectors; import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.codegen.typeinfo.AnyInstanceTypeInfoImpl; import dev.metaschema.databind.codegen.typeinfo.IInstanceTypeInfo; import dev.metaschema.databind.codegen.typeinfo.IModelInstanceTypeInfo; import dev.metaschema.databind.codegen.typeinfo.INamedModelInstanceTypeInfo; @@ -45,19 +47,29 @@ class AssemblyDefinitionTypeInfoImpl public AssemblyDefinitionTypeInfoImpl(@NonNull IAssemblyDefinition definition, @NonNull ITypeResolver typeResolver) { super(definition, typeResolver); - this.instanceToTypeInfoMap = ObjectUtils.notNull(Lazy.of(() -> Stream.concat( - getFlagInstanceTypeInfos().stream(), - processModel(definition)) - .collect(CustomCollectors.toMap( - IInstanceTypeInfo::getInstance, - CustomCollectors.identity(), - (key, v1, v2) -> { - if (LOGGER.isErrorEnabled()) { - LOGGER.error(String.format("Unexpected duplicate property name '%s'", key)); - } - return ObjectUtils.notNull(v2); - }, - LinkedHashMap::new)))); + this.instanceToTypeInfoMap = ObjectUtils.notNull(Lazy.of(() -> { + Stream instances = Stream.concat( + getFlagInstanceTypeInfos().stream(), + processModel(definition)); + + // Add any instance if present on the assembly definition + IAnyInstance anyInstance = definition.getAnyInstance(); + if (anyInstance != null) { + instances = Stream.concat(instances, + Stream.of(new AnyInstanceTypeInfoImpl(anyInstance, this))); + } + + return instances.collect(CustomCollectors.toMap( + IInstanceTypeInfo::getInstance, + CustomCollectors.identity(), + (key, v1, v2) -> { + if (LOGGER.isErrorEnabled()) { + LOGGER.error(String.format("Unexpected duplicate property name '%s'", key)); + } + return ObjectUtils.notNull(v2); + }, + LinkedHashMap::new)); + })); this.propertyNameToTypeInfoMap = ObjectUtils.notNull(Lazy.of(() -> getInstanceTypeInfoMap().values().stream() .collect(Collectors.toMap( IInstanceTypeInfo::getPropertyName, diff --git a/databind/src/test/java/dev/metaschema/databind/codegen/AnyCodegenTest.java b/databind/src/test/java/dev/metaschema/databind/codegen/AnyCodegenTest.java new file mode 100644 index 000000000..b95bb18ed --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/codegen/AnyCodegenTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.codegen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Paths; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IBoundObject; +import dev.metaschema.core.model.MetaschemaException; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.io.BindingException; +import dev.metaschema.databind.model.annotations.BoundAny; + +/** + * Tests that the code generator properly emits {@link BoundAny}-annotated + * fields for assemblies containing {@code } in their model. + */ +class AnyCodegenTest + extends AbstractMetaschemaTest { + + @Test + void testAnyFieldGenerated() + throws MetaschemaException, IOException, ClassNotFoundException, BindingException, + NoSuchMethodException { + Class rootClass = compileModule( + ObjectUtils.notNull(Paths.get("src/test/resources/metaschema/any/metaschema.xml")), + null, + "com.example.ns.any_test.Root", + ObjectUtils.notNull(generationDir)); + + // Verify a field annotated with @BoundAny exists + Field anyField = findBoundAnyField(rootClass); + assertNotNull(anyField, "Generated class should have a field annotated with @BoundAny"); + + // Verify the field type is IAnyContent + assertEquals(IAnyContent.class, anyField.getType(), + "@BoundAny field should be of type IAnyContent"); + + // Verify getter exists and returns IAnyContent + Method getter = rootClass.getMethod("getAny"); + assertNotNull(getter, "Generated class should have getAny() method"); + assertTrue(IAnyContent.class.isAssignableFrom(getter.getReturnType()), + "getAny() should return IAnyContent"); + + // Verify setter exists and accepts IAnyContent + Method setter = rootClass.getMethod("setAny", IAnyContent.class); + assertNotNull(setter, "Generated class should have setAny(IAnyContent) method"); + } + + /** + * Find the field annotated with {@link BoundAny} in the given class. + * + * @param clazz + * the class to search + * @return the field, or {@code null} if not found + */ + private static Field findBoundAnyField(Class clazz) { + for (Field field : clazz.getDeclaredFields()) { + if (field.isAnnotationPresent(BoundAny.class)) { + return field; + } + } + return null; + } +}