diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 52f9f6f34e..84206fa2c2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -196,6 +196,7 @@ For larger initiatives, use PRDs stored in `PRDs/-/`: | PRD | Description | Completed | |-----|-------------|-----------| +| `PRDs/20260206-any-instance-support/` | Support `any` in Java binding annotations (issue #220) | 2026-02-07 | | `PRDs/20251229-javadoc-coverage/` | Complete Javadoc coverage for schemagen, databind, and maven-plugin modules | 2025-12-29 | | `PRDs/20251228-targeted-report-constraint/` | Add constraint processing support for TargetedReportConstraint (issue #592) | 2025-12-29 | | `PRDs/20251228-validation-errors/` | Validation error message improvements (#595, #596, #205) | 2025-12-28 | diff --git a/.claude/rules/unit-testing.md b/.claude/rules/unit-testing.md index e1d8129c53..7a51ecdc47 100644 --- a/.claude/rules/unit-testing.md +++ b/.claude/rules/unit-testing.md @@ -24,9 +24,12 @@ - Do not proceed with commits or pushes when tests fail **When encountering test failures:** -1. Fix them, even if they predate your changes -2. If truly unrelated, stash your work, fix on a separate branch, and merge -3. The 100% pass rate policy has no exceptions +1. Fix them, even if they predate your changes — always prefer actual fixes over `@Disabled` +2. Never disable a test without asking the user first — explain the situation and propose options +3. If truly unrelated, fix in the current PR or a separate branch — either way, fix before merging +4. The 100% pass rate policy has no exceptions +5. Always use `superpowers:systematic-debugging` skill (4-phase framework: root cause investigation, pattern analysis, hypothesis testing, implementation — see `development-workflow.md` for details) when investigating test failures +6. The full CI build (`mvn clean install -PCI -Prelease`) is the authoritative pass/fail check ## Core Principles diff --git a/PRDs/20260206-any-instance-support/PRD.md b/PRDs/20260206-any-instance-support/PRD.md new file mode 100644 index 0000000000..ead6d851e7 --- /dev/null +++ b/PRDs/20260206-any-instance-support/PRD.md @@ -0,0 +1,196 @@ +# PRD: Support `any` in Java Binding Annotations + +**Issue:** [#220](https://github.com/metaschema-framework/metaschema-java/issues/220) +**Date:** 2026-02-06 +**Status:** Draft + +## Problem Statement + +The Metaschema specification defines an `` instance type that allows assembly definitions to accept additional unmodeled content — content not described by the assembly's explicit model. This is analogous to `xs:any` in XML Schema or `additionalProperties` in JSON Schema. + +The metaschema-java framework currently has no support for this feature: + +- No core model interface (`IAnyInstance`) exists +- No databind annotation (`@BoundAny`) exists +- The `AssemblyModelGenerator` ignores the `Any` binding object when loading modules +- XML and JSON parsers silently skip unmodeled content +- Schema generators produce no wildcard declarations + +OSCAL has `` commented out in at least four modules, waiting for implementation. The Metaschema test suite's anthology example already uses `` in the `author` assembly. + +## Goals + +1. Implement a core model interface for the `any` instance type +2. Implement a `@BoundAny` Java annotation for databind +3. Capture unmodeled content during XML parsing with round-trip fidelity +4. Capture unmodeled content during JSON/YAML parsing with round-trip fidelity +5. Generate correct XML Schema (`xs:any`) and JSON Schema (`additionalProperties`) declarations +6. Handle interaction with JSON value-key and json-key flags + +## Non-Goals + +- Namespace filtering attributes on `` (future enhancement if spec evolves) +- Metapath querying into `any` content +- Constraint validation of `any` content + +## Design + +### Core Model Layer + +#### `IAnyInstance` + +A new interface extending `IModelInstanceAbsolute`, joining the hierarchy alongside `IChoiceInstance` and `IChoiceGroupInstance`. It is a structural marker — no additional methods beyond what it inherits. + +```java +public interface IAnyInstance extends IModelInstanceAbsolute { +} +``` + +#### `IAnyContent` + +A format-neutral interface for representing captured unmodeled content. Lives in the core module so that model-layer code can reference it without depending on databind. + +```java +public interface IAnyContent { + boolean isEmpty(); +} +``` + +No format-specific getters on the interface. Consumers needing format access use `instanceof` checks on the implementation. + +#### Container Model Updates + +`IContainerModelAssemblySupport` gains: + +```java +@Nullable +IAnyInstance getAnyInstance(); +``` + +`DefaultAssemblyModelBuilder` gains an `append(AnyI instance)` method. `DefaultContainerModelAssemblySupport` stores the optional any instance. + +#### Model Visitor Updates + +Visitor interfaces (`IModelDefinitionVisitor`, etc.) gain a `visitAny(IAnyInstance)` callback. + +### Databind Annotation + +```java +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface BoundAny { +} +``` + +Minimal marker annotation with no attributes. Annotates a field of type `IAnyContent` on a bound class: + +```java +@MetaschemaAssembly(name = "author", ...) +public class Author implements IBoundObject { + @BoundAny + private IAnyContent _any; +} +``` + +### Databind Binding Layer + +`IBoundInstanceModelAny` extends both `IAnyInstance` and the databind binding contracts, bridging core and databind layers. Follows the pattern of `IBoundInstanceModelAssembly`. + +`DefinitionAssembly` (class introspection) gains scanning for `@BoundAny` fields. + +### Format-Specific Content Implementations + +```java +// XML: holds List +public class XmlAnyContent implements IAnyContent { + private final List elements; + public List getElements() { ... } + public boolean isEmpty() { return elements.isEmpty(); } +} + +// JSON/YAML: holds com.fasterxml.jackson.databind.node.ObjectNode +public class JsonAnyContent implements IAnyContent { + private final ObjectNode properties; + public ObjectNode getProperties() { ... } + public boolean isEmpty() { return properties.isEmpty(); } +} +``` + +### Metaschema Module Loading + +`AssemblyModelGenerator` and `ChoiceModelGenerator` currently ignore the `_any` field on `AssemblyModel` and `AssemblyModel.Choice` bindings. They need to: + +1. Check `binding.getAny()` after processing instances +2. If non-null, create an `IAnyInstance` and append it to the model builder + +### XML Parsing + +`MetaschemaXmlReader` flow with `any` support: + +1. Read all known model instances as before +2. Check if the definition has an `IAnyInstance` +3. If yes: capture remaining child elements into `List` via StAX-to-DOM conversion +4. Wrap in `XmlAnyContent` and set on the bound object via the `@BoundAny` field +5. If no `IAnyInstance`: existing skip/problem-handler behavior + +A new utility method converts StAX events to `org.w3c.dom.Element` subtrees. + +**Writing:** `MetaschemaXmlWriter` checks for `IAnyInstance` and non-empty content. Serializes `List` to `XMLStreamWriter` after known model instances. + +### JSON/YAML Parsing + +`MetaschemaJsonReader` flow with `any` support: + +1. During property iteration, unmatched properties are captured (not skipped) when the definition has an `IAnyInstance` +2. Each unmatched property value is read via `parser.readValueAsTree()` into a `JsonNode` +3. Collected into an `ObjectNode` (property name to value) +4. Wrapped in `JsonAnyContent` and set on the bound object + +This is a buffer-during-pass approach — no rewinding needed. + +**Writing:** `MetaschemaJsonWriter` iterates `ObjectNode` entries and writes each as additional properties after known instances. + +YAML shares the Jackson-based JSON implementation. + +### Schema Generation + +**XML Schema:** Append after known element declarations: + +```xml + +``` + +Matches the precedent in the Metaschema XSD's `example` type. + +**JSON Schema:** Add to the assembly's object schema: + +```json +"additionalProperties": true +``` + +### JSON Value-Key Interaction + +When an assembly uses `json-value-key` or `json-key` flags, JSON properties are named dynamically. The `any` content capture must correctly distinguish between: + +- Properties matched to known model instances (including those keyed by value-key flags) +- Properties that are truly unmodeled and belong to `any` + +The existing property-matching logic already resolves value-key properties before falling through to the problem handler. The `any` capture replaces the problem-handler fallback, so this should work naturally — but explicit testing is required. + +## Success Criteria + +- [x] `IAnyInstance` interface exists in core with visitor support +- [x] `IAnyContent` interface exists in core +- [x] `@BoundAny` annotation exists in databind +- [x] `IBoundInstanceModelAny` bridges core and databind +- [x] `AssemblyModelGenerator` and `ChoiceModelGenerator` process `` +- [x] XML parsing captures unmodeled elements into `XmlAnyContent` +- [x] XML writing serializes `XmlAnyContent` back to XML +- [x] JSON/YAML parsing captures unmodeled properties into `JsonAnyContent` +- [x] JSON/YAML writing serializes `JsonAnyContent` back +- [x] XML Schema generation produces `xs:any` for assemblies with `` +- [x] JSON Schema generation produces `additionalProperties: true` +- [x] Round-trip tests pass for XML, JSON, and YAML +- [x] JSON value-key interaction tests pass +- [x] CI build passes: `mvn clean install -PCI -Prelease` diff --git a/PRDs/20260206-any-instance-support/implementation-plan.md b/PRDs/20260206-any-instance-support/implementation-plan.md new file mode 100644 index 0000000000..0b944c6af6 --- /dev/null +++ b/PRDs/20260206-any-instance-support/implementation-plan.md @@ -0,0 +1,1138 @@ +# `any` Instance Support Implementation Plan + +**Goal:** Add full support for the Metaschema `` instance type — core model interfaces, `@BoundAny` annotation, XML/JSON/YAML parsing with round-trip fidelity, and schema generation. + +**Architecture:** A format-neutral `IAnyContent` interface in core wraps native content representations (W3C DOM for XML, Jackson `ObjectNode` for JSON/YAML). A new `IAnyInstance` model interface marks assemblies that accept unmodeled content. The databind layer provides `@BoundAny` annotation, parsing capture, and serialization. Schema generators emit `xs:any` (XML) and `additionalProperties` (JSON). + +**Tech Stack:** Java 11, JUnit 5, StAX (XML parsing), Jackson (JSON), W3C DOM, ANTLR4 (existing Metapath grammar — no changes needed). + +**Issue:** [#220](https://github.com/metaschema-framework/metaschema-java/issues/220) + +**Worktree:** `.worktrees/any-instance` (branch `feature/220-any-instance`) + +**Single PR** targeting `develop` branch. + +**Specification Documentation:** Metaschema language-level documentation for `` is tracked in companion PR [metaschema-framework/metaschema#171](https://github.com/metaschema-framework/metaschema/pull/171). + +--- + +## Phase 1: Core Model Layer and `@BoundAny` Annotation [COMPLETE] + +Establishes the foundational interfaces, annotation, container model changes, and module loading support. + +### Task 1: Create `IAnyContent` Interface [COMPLETE] + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/IAnyContent.java` + +**Step 1: Create the interface** + +```java +package dev.metaschema.core.model; + +/** + * A format-neutral representation of unmodeled content captured from an + * assembly instance that declares {@code } in its model. + * + *

Implementations hold native content representations specific to each + * serialization format (e.g., W3C DOM for XML, Jackson ObjectNode for JSON). + * Consumers needing format-specific access should use {@code instanceof} + * checks on the implementation class. + */ +public interface IAnyContent { + /** + * Determine if this content container has no captured content. + * + * @return {@code true} if no unmodeled content was captured, {@code false} + * otherwise + */ + boolean isEmpty(); +} +``` + +**Step 2: Verify compilation** + +Run: `mvn -pl core compile -q` +Expected: BUILD SUCCESS + +**Step 3: Commit** + +```text +feat: add IAnyContent interface for unmodeled content + +Introduces a format-neutral interface for representing captured +unmodeled content from assemblies with declarations. +``` + +--- + +### Task 2: Create `IAnyInstance` Interface [COMPLETE] + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/IAnyInstance.java` +- Reference: `core/src/main/java/dev/metaschema/core/model/IChoiceInstance.java` (pattern to follow) + +**Step 1: Create the interface** + +Model `IAnyInstance` after `IChoiceInstance`. It extends `IModelInstanceAbsolute` since it's a structural member of an assembly model, not a named instance. Key semantics: always optional (`minOccurs=0`), always unbounded (`maxOccurs=-1`), returns `ModelType.ANY`. + +```java +package dev.metaschema.core.model; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Represents an {@code } instance in a Metaschema assembly model, + * declaring that the assembly accepts additional unmodeled content. + * + *

This is analogous to {@code xs:any} in XML Schema or + * {@code additionalProperties} in JSON Schema. + */ +public interface IAnyInstance extends IModelInstanceAbsolute { + + @Override + default ModelType getModelType() { + return ModelType.ANY; + } + + @Override + default int getMinOccurs() { + return 0; + } + + @Override + default int getMaxOccurs() { + return -1; + } +} +``` + +**Step 2: Add `ANY` to `ModelType` enum** + +Modify: `core/src/main/java/dev/metaschema/core/model/ModelType.java` + +Add `ANY` enum constant. Check the existing enum values and add after the last one. + +**Step 3: Verify compilation** + +Run: `mvn -pl core compile -q` +Expected: BUILD SUCCESS + +**Step 4: Commit** + +```text +feat: add IAnyInstance interface and ModelType.ANY + +Introduces the core model interface for instances in +assembly models. Adds ANY to ModelType enum. +``` + +--- + +### Task 3: Update Container Model Interfaces and Implementations [COMPLETE] + +**Files:** +- Modify: `core/src/main/java/dev/metaschema/core/model/IContainerModelAssemblySupport.java` +- Modify: `core/src/main/java/dev/metaschema/core/model/impl/DefaultContainerModelAssemblySupport.java` +- Modify: `core/src/main/java/dev/metaschema/core/model/DefaultAssemblyModelBuilder.java` + +**Step 1: Write tests for container model** + +Create: `core/src/test/java/dev/metaschema/core/model/DefaultAssemblyModelBuilderTest.java` + +Write tests verifying: +- Building an assembly model with no any instance returns `null` from `getAnyInstance()` +- Building an assembly model with an any instance returns the instance from `getAnyInstance()` +- The empty container's `getAnyInstance()` returns `null` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=DefaultAssemblyModelBuilderTest -q` +Expected: FAIL (methods don't exist yet) + +**Step 3: Add `getAnyInstance()` to `IContainerModelAssemblySupport`** + +In `IContainerModelAssemblySupport.java`, add a new type parameter `ANI extends IAnyInstance` and a method: + +```java +/** + * Get the any instance declared in this model, if any. + * + * @return the any instance, or {@code null} if no any is declared + */ +@Nullable +ANI getAnyInstance(); +``` + +Update the `empty()` static method to return `null` for `getAnyInstance()`. + +Note: Adding a type parameter is a breaking change to all implementations. All classes that implement or extend this interface will need updating. Check all implementors and update their type parameter lists. + +**Step 4: Add `anyInstance` field to `DefaultContainerModelAssemblySupport`** + +Add a `@Nullable ANI anyInstance` field. Update both constructors (empty mutable and full). Update the `EMPTY` static constant. Add getter. + +**Step 5: Add `append` and getter to `DefaultAssemblyModelBuilder`** + +Add a `@Nullable` any instance field, an `append(ANI instance)` method, a `getAnyInstance()` getter, and pass it to `buildAssembly()`. + +**Step 6: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=DefaultAssemblyModelBuilderTest -q` +Expected: PASS + +**Step 7: Fix all compilation errors from type parameter changes** + +The type parameter addition to `IContainerModelAssemblySupport` will cause compilation errors in all implementors. Fix each one by adding the new type parameter. This includes classes in both `core` and `databind` modules. + +Run: `mvn -pl core,databind compile -q` +Expected: BUILD SUCCESS + +**Step 8: Run full test suite** + +Run: `mvn -pl core,databind test -q` +Expected: All tests pass + +**Step 9: Commit** + +```text +feat: add any instance support to container model + +Updates IContainerModelAssemblySupport with getAnyInstance(), +DefaultContainerModelAssemblySupport with storage, and +DefaultAssemblyModelBuilder with append support. +``` + +--- + +### Task 4: Update Model Visitor [COMPLETE] + +**Files:** +- Modify: `core/src/main/java/dev/metaschema/core/model/IModelElementVisitor.java` +- Modify all visitor implementations that handle model instance types + +**Step 1: Add `visitAny` to visitor interface** + +In `IModelElementVisitor.java`, add: + +```java +/** + * Visit an any instance. + * + * @param instance + * the any instance to visit + * @param context + * the processing context + * @return the visitation result + */ +RESULT visitAny(@NonNull IAnyInstance instance, CONTEXT context); +``` + +**Step 2: Add `accept` method to `IAnyInstance`** + +Add a default `accept` method similar to `IChoiceInstance`: + +```java +@Override +default RESULT accept(@NonNull IModelElementVisitor visitor, CONTEXT context) { + return visitor.visitAny(this, context); +} +``` + +**Step 3: Fix all visitor implementations** + +Find all classes implementing `IModelElementVisitor` and add the `visitAny` method. Most can return a default/no-op result initially. + +**Step 4: Verify compilation and tests** + +Run: `mvn -pl core,databind compile -q && mvn -pl core,databind test -q` +Expected: BUILD SUCCESS, all tests pass + +**Step 5: Commit** + +```text +feat: add visitAny to model visitor interface + +Extends IModelElementVisitor with visitAny callback for +traversing any instances in assembly models. +``` + +--- + +### Task 5: Create `@BoundAny` Annotation [COMPLETE] + +**Files:** +- Create: `databind/src/main/java/dev/metaschema/databind/model/annotations/BoundAny.java` +- Reference: `databind/src/main/java/dev/metaschema/databind/model/annotations/BoundAssembly.java` (pattern) + +**Step 1: Create the annotation** + +```java +package dev.metaschema.databind.model.annotations; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a field of type {@link dev.metaschema.core.model.IAnyContent} on a + * bound class to receive unmodeled content from assemblies that declare + * {@code } in their model. + * + *

During deserialization, content not matching any declared model instance + * is captured into this field. During serialization, captured content is + * written back after all declared model instances. + */ +@Documented +@Retention(RUNTIME) +@Target(FIELD) +public @interface BoundAny { +} +``` + +**Step 2: Verify compilation** + +Run: `mvn -pl databind compile -q` +Expected: BUILD SUCCESS + +**Step 3: Commit** + +```text +feat: add @BoundAny annotation for unmodeled content + +Minimal marker annotation for IAnyContent fields on bound +classes. Marks fields to receive captured unmodeled content. +``` + +--- + +### Task 6: Create `IBoundInstanceModelAny` and Implementation [COMPLETE] + +**Files:** +- Create: `databind/src/main/java/dev/metaschema/databind/model/IBoundInstanceModelAny.java` +- Create: `databind/src/main/java/dev/metaschema/databind/model/impl/InstanceModelAny.java` +- Modify: `databind/src/main/java/dev/metaschema/databind/model/impl/AssemblyModelGenerator.java` +- Reference: `databind/src/main/java/dev/metaschema/databind/model/IBoundInstanceModelAssembly.java` (pattern) + +**Step 1: Create `IBoundInstanceModelAny` interface** + +This bridges `IAnyInstance` with the databind binding layer. It should extend `IAnyInstance` and the necessary databind interfaces for field access. The exact superinterfaces depend on what's needed for reading/writing the `@BoundAny` field — study `IBoundInstanceModelAssembly` for the pattern. + +Key methods needed: +- Access to the underlying Java `Field` annotated with `@BoundAny` +- Getter/setter for `IAnyContent` on the bound object +- Reference to the containing definition + +**Step 2: Create `InstanceModelAny` implementation** + +Implementation that wraps a `java.lang.reflect.Field` annotated with `@BoundAny`. Provides: +- `getField()` returning the annotated field +- `getValue(Object parent)` reading the `IAnyContent` from the parent object +- `setValue(Object parent, IAnyContent value)` setting it +- `getContainingDefinition()` returning the assembly definition + +**Step 3: Update `AssemblyModelGenerator` to scan for `@BoundAny`** + +In `AssemblyModelGenerator.java`, in the `of()` method or `getModelInstanceStream()`, add scanning for `@BoundAny` fields. When found, create an `InstanceModelAny` and append it to the builder. + +Only one `@BoundAny` field should be allowed per class — validate this and throw if multiple are found. + +**Step 4: Verify compilation and existing tests pass** + +Run: `mvn -pl databind compile -q && mvn -pl databind test -q` +Expected: BUILD SUCCESS, all tests pass + +**Step 5: Commit** + +```text +feat: add IBoundInstanceModelAny and annotation scanning + +Bridges IAnyInstance with the databind layer. AssemblyModelGenerator +now scans for @BoundAny fields during class introspection. +``` + +--- + +### Task 7: Update Module Loading (AssemblyModelGenerator and ChoiceModelGenerator) [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AssemblyModelGenerator.java` (lines ~88-145) +- Modify: `databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/ChoiceModelGenerator.java` (lines ~61-113) +- Create implementation of `IAnyInstance` for module-loaded definitions + +Note: There are TWO `AssemblyModelGenerator` classes — one in `databind/model/impl/` (Task 6, scans annotations) and one in `databind/model/metaschema/impl/` (this task, processes loaded module bindings). This task addresses the module-loading one. + +**Step 1: Write test for module loading** + +Create: `databind/src/test/java/dev/metaschema/databind/model/metaschema/AnyInstanceLoadingTest.java` + +Write a test that: +1. Loads a Metaschema module containing `` in an assembly model +2. Retrieves the assembly definition +3. Asserts `getAnyInstance()` is not null + +Use the anthology metaschema from the test suite submodule, or create a minimal test metaschema. + +**Step 2: Run test to verify it fails** + +Run: `mvn -pl databind test -Dtest=AnyInstanceLoadingTest -q` +Expected: FAIL (any instance not loaded) + +**Step 3: Create `IAnyInstance` implementation for module-loaded definitions** + +Create a concrete class in `databind/model/metaschema/impl/` that implements `IAnyInstance` for module-loaded assemblies. + +**Step 4: Update `AssemblyModelGenerator` (metaschema/impl)** + +After the existing `forEach` loop that processes instances, add: + +```java +Any any = binding.getAny(); +if (any != null) { + generator.append(new ModuleAnyInstance(parent)); +} +``` + +**Step 5: Update `ChoiceModelGenerator`** + +Similarly process the `any` field from `AssemblyModel.Choice`: + +```java +Any any = binding.getAny(); +if (any != null) { + // handle any in choice context +} +``` + +**Step 6: Run test to verify it passes** + +Run: `mvn -pl databind test -Dtest=AnyInstanceLoadingTest -q` +Expected: PASS + +**Step 7: Run full test suite** + +Run: `mvn -pl core,databind test -q` +Expected: All tests pass + +**Step 8: Commit** + +```text +feat: process during module loading + +AssemblyModelGenerator and ChoiceModelGenerator now create +IAnyInstance entries when loading modules with declarations. +``` + +--- + +--- + +## Phase 2: Content Capture and XML/JSON Parsing [COMPLETE] + +Adds format-specific `IAnyContent` implementations and parsing support. + +### Task 8: Create `XmlAnyContent` Implementation [COMPLETE] + +**Files:** +- Create: `databind/src/main/java/dev/metaschema/databind/io/xml/XmlAnyContent.java` + +**Step 1: Write tests** + +Create: `databind/src/test/java/dev/metaschema/databind/io/xml/XmlAnyContentTest.java` + +Test: +- Empty content reports `isEmpty() == true` +- Content with elements reports `isEmpty() == false` +- `getElements()` returns the stored elements + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl databind test -Dtest=XmlAnyContentTest -q` +Expected: FAIL + +**Step 3: Implement `XmlAnyContent`** + +```java +package dev.metaschema.databind.io.xml; + +import dev.metaschema.core.model.IAnyContent; +import org.w3c.dom.Element; + +import java.util.Collections; +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * XML-specific implementation of {@link IAnyContent} that stores captured + * unmodeled content as W3C DOM {@link Element} instances. + */ +public class XmlAnyContent implements IAnyContent { + @NonNull + private final List elements; + + /** + * Construct with captured elements. + * + * @param elements + * the captured DOM elements, must not be null + */ + public XmlAnyContent(@NonNull List elements) { + this.elements = Collections.unmodifiableList(elements); + } + + @Override + public boolean isEmpty() { + return elements.isEmpty(); + } + + /** + * Get the captured DOM elements. + * + * @return an unmodifiable list of captured elements + */ + @NonNull + public List getElements() { + return elements; + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl databind test -Dtest=XmlAnyContentTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: add XmlAnyContent for captured XML elements +``` + +--- + +### Task 9: Create `JsonAnyContent` Implementation [COMPLETE] + +**Files:** +- Create: `databind/src/main/java/dev/metaschema/databind/io/json/JsonAnyContent.java` + +**Step 1: Write tests** + +Create: `databind/src/test/java/dev/metaschema/databind/io/json/JsonAnyContentTest.java` + +Test: +- Empty `ObjectNode` reports `isEmpty() == true` +- `ObjectNode` with properties reports `isEmpty() == false` +- `getProperties()` returns the stored node + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl databind test -Dtest=JsonAnyContentTest -q` +Expected: FAIL + +**Step 3: Implement `JsonAnyContent`** + +```java +package dev.metaschema.databind.io.json; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.metaschema.core.model.IAnyContent; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * JSON/YAML-specific implementation of {@link IAnyContent} that stores + * captured unmodeled content as a Jackson {@link ObjectNode}. + */ +public class JsonAnyContent implements IAnyContent { + @NonNull + private final ObjectNode properties; + + /** + * Construct with captured properties. + * + * @param properties + * the captured JSON properties, must not be null + */ + public JsonAnyContent(@NonNull ObjectNode properties) { + this.properties = properties; + } + + @Override + public boolean isEmpty() { + return properties.isEmpty(); + } + + /** + * Get the captured JSON properties. + * + * @return the captured ObjectNode + */ + @NonNull + public ObjectNode getProperties() { + return properties; + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl databind test -Dtest=JsonAnyContentTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: add JsonAnyContent for captured JSON/YAML properties +``` + +--- + +### Task 10: XML Parsing — Capture Unmodeled Content [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlReader.java` (lines ~274-313) +- Create or modify: utility for StAX-to-DOM conversion + +**Step 1: Write round-trip test** + +Create: `databind/src/test/java/dev/metaschema/databind/io/xml/AnyXmlRoundTripTest.java` + +Create a test Metaschema module with an assembly containing ``, and a bound test class with `@BoundAny`. Create test XML with unknown elements. Verify: +1. Parsing captures the unknown elements into `XmlAnyContent` +2. The captured elements have correct names, attributes, and nested content +3. Writing back produces equivalent XML + +**Step 2: Run test to verify it fails** + +Expected: FAIL (content not captured) + +**Step 3: Use `XmlDomUtil.staxToElement()` for StAX-to-DOM conversion** + +Use the existing `XmlDomUtil.staxToElement()` method in `databind/src/main/java/dev/metaschema/databind/io/xml/XmlDomUtil.java` to convert StAX events to DOM elements. This utility reads from the current start element through the matching end element and builds a `org.w3c.dom.Element`. The `DocumentBuilderFactory` in `XmlDomUtil.newDocument()` is hardened against XXE by setting `ACCESS_EXTERNAL_DTD` and `ACCESS_EXTERNAL_SCHEMA` to empty strings. + +**Step 4: Modify `MetaschemaXmlReader.readModelInstances()`** + +In `readModelInstances()` (around line 297-312), replace the skip logic: + +```java +// Before: XmlEventUtil.skipElement(reader); +// After: +if (definition.getAnyInstance() != null) { + // capture into DOM elements + List captured = new ArrayList<>(); + while (!reader.peek().isEndElement()) { + XmlEventUtil.skipWhitespace(reader); + if (!reader.peek().isEndElement()) { + captured.add(staxToDom(reader)); + XmlEventUtil.skipWhitespace(reader); + } + } + if (!captured.isEmpty()) { + XmlAnyContent anyContent = new XmlAnyContent(captured); + // set on the bound object via the any instance + anyInstance.setValue(targetObject, anyContent); + } +} else { + // existing skip behavior + XmlEventUtil.skipElement(reader); +} +``` + +Adapt this pseudocode to match the actual reader API and field access patterns. + +**Error handling and validation for captured content:** + +- **Malformed content:** If StAX-to-DOM conversion fails (e.g., `XMLStreamException`), propagate the error to fail the parse with a clear message indicating the location and nature of the failure. Do not silently skip malformed any content. +- **Content treatment:** Captured any content is treated as opaque — no schema or constraint validation is applied to the captured DOM/JSON nodes. The content is preserved exactly as received for round-trip fidelity. +- **Resource limits:** The StAX parser's existing resource limits (entity expansion, max attributes) apply during capture. No additional per-capture limits are imposed, as the StAX layer already enforces configurable bounds. +- **Error surfacing:** Parse failures in any content paths produce `IOException` with descriptive messages including the element name and namespace, consistent with how other parse failures are reported in `MetaschemaXmlReader` and `MetaschemaJsonReader`. +- **JSON capture path:** The JSON reader uses `parser.readValueAsTree()` (Jackson `TreeNode`) for value capture, which inherits the parser's configured limits (max string length, max nesting depth). Capture failures propagate as `IOException`. + +**Step 5: Run test to verify it passes** + +Run: `mvn -pl databind test -Dtest=AnyXmlRoundTripTest -q` +Expected: PASS + +**Step 6: Run full test suite** + +Run: `mvn -pl databind test -q` +Expected: All tests pass (existing behavior for assemblies without `` unchanged) + +**Step 7: Commit** + +```text +feat: capture unmodeled XML content into XmlAnyContent + +MetaschemaXmlReader now captures unknown child elements into +XmlAnyContent when the assembly definition has . +``` + +--- + +### Task 11: XML Writing — Serialize Captured Content [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlWriter.java` (around lines 203-210) + +**Step 1: Extend round-trip test from Task 11** + +Add test assertions that verify writing a bound object with `XmlAnyContent` produces XML that includes the captured elements in the correct position (after known model instances). + +**Step 2: Run test to verify it fails** + +Expected: FAIL (captured content not written) + +**Step 3: Modify `MetaschemaXmlWriter`** + +In `writeAssemblyModel()` (around line 203-210), after writing all model instances, check for any content: + +```java +// After the model instance loop: +IBoundInstanceModelAny anyInstance = definition.getAnyInstance(); +if (anyInstance != null) { + IAnyContent anyContent = anyInstance.getValue(parentItem); + if (anyContent instanceof XmlAnyContent) { + for (Element element : ((XmlAnyContent) anyContent).getElements()) { + // write DOM element to XMLStreamWriter + writeDomElement(element, writer); + } + } +} +``` + +Add a `writeDomElement()` utility that walks a DOM `Element` tree and writes to `XMLStreamWriter2`. + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl databind test -Dtest=AnyXmlRoundTripTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: serialize XmlAnyContent back to XML output + +MetaschemaXmlWriter writes captured DOM elements after all +known model instances for assemblies with . +``` + +--- + +### Task 12: JSON Parsing — Capture Unmodeled Properties [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonReader.java` (lines ~658-668) + +**Step 1: Write round-trip test** + +Create: `databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonRoundTripTest.java` + +Create a test with a bound class containing `@BoundAny`. Create JSON with extra properties (string, number, object, array values). Verify: +1. Parsing captures unmatched properties into `JsonAnyContent` +2. Known properties are still parsed correctly +3. The captured `ObjectNode` contains the right property names and values + +**Step 2: Run test to verify it fails** + +Expected: FAIL (properties skipped instead of captured) + +**Step 3: Modify `MetaschemaJsonReader`** + +In `PropertyBodyHandler.accept()` (around lines 658-668), replace the skip logic: + +```java +// Before: JsonUtil.skipNextValue(parser, resource); +// After: +if (definition.getAnyInstance() != null) { + // capture into ObjectNode + if (anyNode == null) { + anyNode = parser.getCodec().createObjectNode(); + } + JsonUtil.assertAndAdvance(parser, resource, JsonToken.FIELD_NAME); + JsonNode value = parser.readValueAsTree(); + anyNode.set(propertyName, value); +} else { + // existing skip behavior + JsonUtil.assertAndAdvance(parser, resource, JsonToken.FIELD_NAME); + JsonUtil.skipNextValue(parser, resource); +} +``` + +After the property loop, if `anyNode` is non-null and non-empty: + +```java +if (anyNode != null && !anyNode.isEmpty()) { + JsonAnyContent anyContent = new JsonAnyContent(anyNode); + anyInstance.setValue(parent, anyContent); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `mvn -pl databind test -Dtest=AnyJsonRoundTripTest -q` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `mvn -pl databind test -q` +Expected: All tests pass + +**Step 6: Commit** + +```text +feat: capture unmodeled JSON properties into JsonAnyContent + +MetaschemaJsonReader now captures unmatched properties into +JsonAnyContent when the assembly definition has . +``` + +--- + +### Task 13: JSON Writing — Serialize Captured Content [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonWriter.java` (around lines 259-273) + +**Step 1: Extend round-trip test from Task 13** + +Add write-back assertions: serialize the bound object to JSON and verify the extra properties appear. + +**Step 2: Run test to verify it fails** + +Expected: FAIL (captured properties not written) + +**Step 3: Modify `MetaschemaJsonWriter`** + +In `writeObjectProperties()` (around line 259-273), after writing all known properties, check for any content: + +```java +// After the property loop: +IBoundInstanceModelAny anyInstance = definition.getAnyInstance(); +if (anyInstance != null) { + IAnyContent anyContent = anyInstance.getValue(parent); + if (anyContent instanceof JsonAnyContent) { + ObjectNode props = ((JsonAnyContent) anyContent).getProperties(); + Iterator> fields = props.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + generator.writeFieldName(entry.getKey()); + generator.writeTree(entry.getValue()); + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl databind test -Dtest=AnyJsonRoundTripTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: serialize JsonAnyContent back to JSON output + +MetaschemaJsonWriter writes captured ObjectNode properties after +all known properties for assemblies with . +``` + +--- + +### Task 14: JSON Value-Key Interaction Tests [COMPLETE] + +**Files:** +- Create: `databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonValueKeyTest.java` + +**Step 1: Write tests for value-key interaction** + +Create a bound class where: +1. An assembly uses `json-key` flag (properties are keyed by flag value) +2. The assembly also has `@BoundAny` +3. JSON input contains both keyed known instances and extra unknown properties + +Verify: +- Known instances keyed by value-key are correctly parsed +- Extra properties (not matching any key) are captured in `JsonAnyContent` +- No known instances are incorrectly captured as "any" content +- No "any" content is incorrectly matched to known instances + +**Step 2: Run tests** + +Run: `mvn -pl databind test -Dtest=AnyJsonValueKeyTest -q` +Expected: PASS (if the property-matching logic correctly resolves before falling through to any capture) + +If tests fail, fix the capture logic to properly check value-key resolution before capturing as "any". + +**Step 3: Commit** + +```text +test: verify any content capture with json-value-key flags + +Ensures unmodeled content capture correctly distinguishes between +value-key-matched properties and truly unmodeled properties. +``` + +--- + +### Task 14A: Security Hardening for Captured Content [COMPLETE] + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/xml/XmlDomUtil.java` + +**Step 1: Harden `XmlDomUtil.newDocument()` against XXE** + +The `DocumentBuilderFactory` used to create DOM documents for captured any content must deny external entity resolution: + +```java +DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); +dbf.setNamespaceAware(true); +dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); +dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); +``` + +This prevents XXE attacks (billion laughs, external entity injection) when building DOM trees from StAX events. Although `newDocument()` only creates blank documents (not parsing external input), this follows defense-in-depth principles. + +**Step 2: Verify existing StAX parser protections** + +The StAX `XMLEventReader` used by `MetaschemaXmlReader` already inherits the JVM's default entity expansion limits and security features. Verify that: +- External entity resolution is controlled by the StAX factory configuration +- Entity expansion limits are enforced by the JVM defaults (`jdk.xml.entityExpansionLimit`) +- No additional `DocumentBuilder.parse()` calls are introduced (all DOM construction uses `newDocument()` + manual population) + +**Step 3: Verify Jackson parser protections** + +Jackson's `JsonParser` used in `MetaschemaJsonReader` enforces: +- Maximum string length (`StreamReadConstraints`) +- Maximum nesting depth (`StreamReadConstraints`) +- Maximum number length + +The `parser.readValueAsTree()` call for any content capture inherits these limits. + +**Step 4: Run existing tests** + +Run: `mvn -pl databind test -q` +Expected: All tests pass (hardening should not change behavior) + +**Step 5: Commit** + +```text +fix: harden XmlDomUtil against XXE in DocumentBuilderFactory + +Sets ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_SCHEMA to empty +strings as defense-in-depth for DOM document creation. +``` + +--- + +--- + +## Phase 3: Schema Generation [COMPLETE] + +### Task 15: XML Schema Generation for `any` [COMPLETE] + +**Files:** +- Modify: `schemagen/src/main/java/dev/metaschema/schemagen/xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java` (lines ~57-77, 92-151) + +**Step 1: Write test** + +Create: `schemagen/src/test/java/dev/metaschema/schemagen/xml/AnyXmlSchemaGenerationTest.java` + +Load a Metaschema module with `` in an assembly. Generate XML Schema. Verify the output contains: + +```xml + +``` + +after the assembly's element declarations. + +**Step 2: Run test to verify it fails** + +Run: `mvn -pl schemagen test -Dtest=AnyXmlSchemaGenerationTest -q` +Expected: FAIL + +**Step 3: Update `generateTypeBody()` or `generateModelInstance()`** + +In `XmlComplexTypeAssemblyDefinition`, after iterating model instances in the ``, check for `IAnyInstance`: + +```java +IAnyInstance anyInstance = definition.getAnyInstance(); +if (anyInstance != null) { + state.writeStartElement(XmlSchemaGenerator.PREFIX_XML_SCHEMA, "any", ...); + state.writeAttribute("namespace", "##other"); + state.writeAttribute("processContents", "lax"); + state.writeAttribute("minOccurs", "0"); + state.writeAttribute("maxOccurs", "unbounded"); + state.writeEndElement(); +} +``` + +Or handle it in `generateModelInstance()` with a `case ANY:` in the switch statement, depending on whether `IAnyInstance` appears in the model instances collection or is accessed separately via `getAnyInstance()`. + +**Step 4: Run test to verify it passes** + +Run: `mvn -pl schemagen test -Dtest=AnyXmlSchemaGenerationTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: generate xs:any in XML Schema for declarations + +XML Schema generator emits xs:any with namespace="##other" +and processContents="lax" for assemblies with . +``` + +--- + +### Task 16: JSON Schema Generation for `any` [COMPLETE] + +**Files:** +- Modify: `schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaDefinitionAssembly.java` +- Possibly modify: `schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaHelper.java` + +**Step 1: Write test** + +Create: `schemagen/src/test/java/dev/metaschema/schemagen/json/AnyJsonSchemaGenerationTest.java` + +Load a Metaschema module with ``. Generate JSON Schema. Verify the assembly's schema object contains `"additionalProperties": true`. + +**Step 2: Run test to verify it fails** + +Run: `mvn -pl schemagen test -Dtest=AnyJsonSchemaGenerationTest -q` +Expected: FAIL + +**Step 3: Update JSON schema generation** + +In `JsonSchemaDefinitionAssembly` or `JsonSchemaHelper`, when building the assembly's JSON Schema object, check for `IAnyInstance`: + +```java +if (definition.getAnyInstance() != null) { + objectNode.put("additionalProperties", true); +} +``` + +Also ensure `IAnyInstance` is filtered out in `buildModelProperties()` (line 292 of `JsonSchemaHelper.java`) similar to how `IChoiceInstance` is filtered: + +```java +.filter(instance -> !(instance instanceof IChoiceInstance)) +.filter(instance -> !(instance instanceof IAnyInstance)) +``` + +**Step 4: Run test to verify it passes** + +Run: `mvn -pl schemagen test -Dtest=AnyJsonSchemaGenerationTest -q` +Expected: PASS + +**Step 5: Commit** + +```text +feat: generate additionalProperties in JSON Schema for + +JSON Schema generator sets additionalProperties: true for +assemblies with declarations. +``` + +--- + +--- + +## Phase 4: Final Verification and PR [COMPLETE] + +### Task 17: Javadoc Completeness [COMPLETE] + +Ensure 100% Javadoc coverage on all new `public`/`protected` members: + +- `IAnyContent` — interface and `isEmpty()` method +- `IAnyInstance` — interface, default methods, `accept()` method +- `@BoundAny` — annotation type Javadoc +- `IBoundInstanceModelAny` — interface and all methods +- `InstanceModelAny` — implementation class and constructor/methods +- `XmlAnyContent` — class, constructor, `getElements()` +- `JsonAnyContent` — class, constructor, `getProperties()` +- `XmlDomUtil` — class, `staxToElement()`, `elementToStax()`, and all helper methods +- `ModelType.ANY` — enum constant Javadoc + +Document namespace behavior for XML any content (captured elements preserve their original namespace URIs and prefixes). Document that JSON any content captures unmatched properties as Jackson `ObjectNode` entries. + +Run: `mvn -pl core,databind checkstyle:check -q` +Expected: No Javadoc violations in new code + +--- + +### Task 18: Full CI Build and PR Creation [COMPLETE] + +**Step 1: Run full CI build** + +Run: `mvn clean install -PCI -Prelease` (in the worktree) +Expected: BUILD SUCCESS with all checks passing + +**Step 2: Create PR** + +Push to personal fork (`me` remote), create PR targeting `develop` branch. +Reference: Issue #220 +Title: `feat: support any in Java binding annotations (#220)` + +Include reference to companion specification PR [metaschema-framework/metaschema#171](https://github.com/metaschema-framework/metaschema/pull/171) in the PR description. + +--- + +## Files Changed Summary + +### Core Module (`core/`) + +| File | Change Type | +|------|-------------| +| `core/.../model/IAnyContent.java` | Create | +| `core/.../model/IAnyInstance.java` | Create | +| `core/.../model/ModelType.java` | Modify (add `ANY`) | +| `core/.../model/IContainerModelAssemblySupport.java` | Modify (add type param, `getAnyInstance()`) | +| `core/.../model/impl/DefaultContainerModelAssemblySupport.java` | Modify (add field, constructor param) | +| `core/.../model/DefaultAssemblyModelBuilder.java` | Modify (add `append`, field) | +| `core/.../model/IModelElementVisitor.java` | Modify (add `visitAny`) | +| All `IContainerModelAssemblySupport` implementors | Modify (type parameter) | +| All `IModelElementVisitor` implementors | Modify (add `visitAny`) | + +### Databind Module (`databind/`) + +| File | Change Type | +|------|-------------| +| `databind/.../model/annotations/BoundAny.java` | Create | +| `databind/.../model/IBoundInstanceModelAny.java` | Create | +| `databind/.../model/impl/InstanceModelAny.java` | Create | +| `databind/.../model/impl/AssemblyModelGenerator.java` | Modify (scan `@BoundAny`) | +| `databind/.../model/metaschema/impl/AssemblyModelGenerator.java` | Modify (process `Any` binding) | +| `databind/.../model/metaschema/impl/ChoiceModelGenerator.java` | Modify (process `Any` binding) | +| `databind/.../io/xml/XmlAnyContent.java` | Create | +| `databind/.../io/xml/XmlDomUtil.java` | Create (StAX-to-DOM conversion, XXE-hardened) | +| `databind/.../io/xml/MetaschemaXmlReader.java` | Modify (capture content) | +| `databind/.../io/xml/MetaschemaXmlWriter.java` | Modify (write content) | +| `databind/.../io/json/JsonAnyContent.java` | Create | +| `databind/.../io/json/MetaschemaJsonReader.java` | Modify (capture properties) | +| `databind/.../io/json/MetaschemaJsonWriter.java` | Modify (write properties) | + +### Schema Generation Module (`schemagen/`) + +| File | Change Type | +|------|-------------| +| `schemagen/.../xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java` | Modify | +| `schemagen/.../json/impl/JsonSchemaDefinitionAssembly.java` | Modify | +| `schemagen/.../json/impl/JsonSchemaHelper.java` | Modify (filter `IAnyInstance`) | + +### Test Files + +| File | Change Type | +|------|-------------| +| `core/.../model/DefaultAssemblyModelBuilderTest.java` | Create | +| `databind/.../model/metaschema/AnyInstanceLoadingTest.java` | Create | +| `databind/.../io/xml/XmlAnyContentTest.java` | Create | +| `databind/.../io/xml/AnyXmlRoundTripTest.java` | Create | +| `databind/.../io/json/JsonAnyContentTest.java` | Create | +| `databind/.../io/json/AnyJsonRoundTripTest.java` | Create | +| `databind/.../io/json/AnyJsonValueKeyTest.java` | Create | +| `schemagen/.../xml/AnyXmlSchemaGenerationTest.java` | Create | +| `schemagen/.../json/AnyJsonSchemaGenerationTest.java` | Create | diff --git a/core/src/main/java/dev/metaschema/core/model/AbstractModelElementVisitor.java b/core/src/main/java/dev/metaschema/core/model/AbstractModelElementVisitor.java index fa46e3a805..24341c1ce3 100644 --- a/core/src/main/java/dev/metaschema/core/model/AbstractModelElementVisitor.java +++ b/core/src/main/java/dev/metaschema/core/model/AbstractModelElementVisitor.java @@ -75,6 +75,12 @@ public RESULT visitChoiceGroupInstance(IChoiceGroupInstance item, CONTEXT contex return defaultResult(item, context); } + @Override + public RESULT visitAny(IAnyInstance item, CONTEXT context) { + // do nothing + return defaultResult(item, context); + } + @Override public RESULT visitFlagDefinition(IFlagDefinition item, CONTEXT context) { // do nothing diff --git a/core/src/main/java/dev/metaschema/core/model/DefaultAssemblyModelBuilder.java b/core/src/main/java/dev/metaschema/core/model/DefaultAssemblyModelBuilder.java index 68ea00392b..0a9a63377c 100644 --- a/core/src/main/java/dev/metaschema/core/model/DefaultAssemblyModelBuilder.java +++ b/core/src/main/java/dev/metaschema/core/model/DefaultAssemblyModelBuilder.java @@ -13,6 +13,7 @@ import dev.metaschema.core.model.impl.DefaultContainerModelAssemblySupport; import dev.metaschema.core.util.CollectionUtil; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; /** * An assembly model builder. @@ -46,6 +47,8 @@ public class DefaultAssemblyModelBuilder< private final List choiceInstances = new LinkedList<>(); @NonNull private final Map choiceGroupInstances = new LinkedHashMap<>(); + @Nullable + private IAnyInstance anyInstance; /** * Append the instance. @@ -91,6 +94,26 @@ protected Map getChoiceGroupInstances() { return choiceGroupInstances; } + /** + * Get the {@code any} instance. + * + * @return the {@code any} instance, or {@code null} if none has been set + */ + @Nullable + public IAnyInstance getAnyInstance() { + return anyInstance; + } + + /** + * Set the {@code any} instance for this model. + * + * @param anyInstance + * the {@code any} instance, or {@code null} to clear it + */ + public void setAnyInstance(@Nullable IAnyInstance anyInstance) { + this.anyInstance = anyInstance; + } + /** * Build an immutable assembly model container based on the appended instances. * @@ -98,7 +121,7 @@ protected Map getChoiceGroupInstances() { */ @NonNull public IContainerModelAssemblySupport buildAssembly() { - return getModelInstances().isEmpty() + return getModelInstances().isEmpty() && anyInstance == null ? IContainerModelAssemblySupport.empty() : new DefaultContainerModelAssemblySupport<>( CollectionUtil.unmodifiableList(getModelInstances()), @@ -106,6 +129,7 @@ public IContainerModelAssemblySupport buildAssembly() CollectionUtil.unmodifiableMap(getFieldInstances()), CollectionUtil.unmodifiableMap(getAssemblyInstances()), CollectionUtil.unmodifiableList(getChoiceInstances()), - CollectionUtil.unmodifiableMap(getChoiceGroupInstances())); + CollectionUtil.unmodifiableMap(getChoiceGroupInstances()), + anyInstance); } } diff --git a/core/src/main/java/dev/metaschema/core/model/IAnyContent.java b/core/src/main/java/dev/metaschema/core/model/IAnyContent.java new file mode 100644 index 0000000000..042e8548a5 --- /dev/null +++ b/core/src/main/java/dev/metaschema/core/model/IAnyContent.java @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.core.model; + +/** + * A format-neutral representation of unmodeled content captured from an + * assembly instance that declares {@code } in its model. + * + *

+ * Implementations hold native content representations specific to each + * serialization format (e.g., W3C DOM for XML, Jackson ObjectNode for JSON). + * Consumers needing format-specific access should use {@code instanceof} checks + * on the implementation class. + */ +@FunctionalInterface +public interface IAnyContent { + /** + * Determine if this content container has no captured content. + * + * @return {@code true} if no unmodeled content was captured, {@code false} + * otherwise + */ + boolean isEmpty(); +} diff --git a/core/src/main/java/dev/metaschema/core/model/IAnyInstance.java b/core/src/main/java/dev/metaschema/core/model/IAnyInstance.java new file mode 100644 index 0000000000..09eda8a295 --- /dev/null +++ b/core/src/main/java/dev/metaschema/core/model/IAnyInstance.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.core.model; + +import java.util.Locale; + +import dev.metaschema.core.qname.IEnhancedQName; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A marker interface for an {@code any} instance in a Metaschema model. + *

+ * An {@code any} instance represents unmodeled content that may appear within + * an assembly's model. Unlike other model instances, an {@code any} instance + * does not have a definition or name; it acts as a wildcard that captures + * content not explicitly declared by the model. + *

+ * An {@code any} instance is always optional ({@link #getMinOccurs()} returns + * {@code 0}) and unbounded ({@link #getMaxOccurs()} returns {@code -1}). + */ +public interface IAnyInstance extends IModelInstanceAbsolute { + + /** + * Provides the Metaschema model type of "ANY". + * + * @return the model type + */ + @Override + default ModelType getModelType() { + return ModelType.ANY; + } + + @Override + default IAssemblyDefinition getContainingDefinition() { + return getParentContainer().getOwningDefinition(); + } + + @Override + default int getMinOccurs() { + return 0; + } + + @Override + default int getMaxOccurs() { + return -1; + } + + @Override + default IEnhancedQName getEffectiveXmlGroupAsQName() { + // never grouped + return null; + } + + @Override + default boolean isEffectiveValueWrappedInXml() { + // any content is never wrapped + return false; + } + + @SuppressWarnings("null") + @Override + default String toCoordinates() { + return String.format("%s-instance:%s:%s@%d", + getModelType().toString().toLowerCase(Locale.ROOT), + getContainingDefinition().getContainingModule().getShortName(), + getContainingDefinition().getName(), + hashCode()); + } + + /** + * A visitor callback. + * + * @param + * the type of the context parameter + * @param + * the type of the visitor result + * @param visitor + * the calling visitor + * @param context + * a parameter used to pass contextual information between visitors + * @return the visitor result + */ + @Override + default RESULT accept(@NonNull IModelElementVisitor visitor, CONTEXT context) { + return visitor.visitAny(this, context); + } +} diff --git a/core/src/main/java/dev/metaschema/core/model/IContainerModelAssembly.java b/core/src/main/java/dev/metaschema/core/model/IContainerModelAssembly.java index a4c584b5df..9ca74fc730 100644 --- a/core/src/main/java/dev/metaschema/core/model/IContainerModelAssembly.java +++ b/core/src/main/java/dev/metaschema/core/model/IContainerModelAssembly.java @@ -48,4 +48,18 @@ public interface IContainerModelAssembly extends IContainerModelAbsolute { */ @NonNull Map getChoiceGroupInstances(); + + /** + * Get the {@code any} instance for this container, if one is defined. + *

+ * An {@code any} instance represents unmodeled content that may appear within + * an assembly's model. + * + * @return the {@code any} instance, or {@code null} if no {@code any} instance + * is defined + */ + @Nullable + default IAnyInstance getAnyInstance() { + return null; + } } diff --git a/core/src/main/java/dev/metaschema/core/model/IContainerModelAssemblySupport.java b/core/src/main/java/dev/metaschema/core/model/IContainerModelAssemblySupport.java index 487837b7bb..4c54667f12 100644 --- a/core/src/main/java/dev/metaschema/core/model/IContainerModelAssemblySupport.java +++ b/core/src/main/java/dev/metaschema/core/model/IContainerModelAssemblySupport.java @@ -10,6 +10,7 @@ import dev.metaschema.core.model.impl.DefaultContainerModelAssemblySupport; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; /** * Common interface for model container support classes. @@ -79,4 +80,18 @@ CGI extends IChoiceGroupInstance> IContainerModelAssemblySupport getChoiceGroupInstanceMap(); + + /** + * Get the {@code any} instance for this container, if one is defined. + *

+ * An {@code any} instance represents unmodeled content that may appear within + * an assembly's model. + * + * @return the {@code any} instance, or {@code null} if no {@code any} instance + * is defined + */ + @Nullable + default IAnyInstance getAnyInstance() { + return null; + } } diff --git a/core/src/main/java/dev/metaschema/core/model/IFeatureContainerModelAssembly.java b/core/src/main/java/dev/metaschema/core/model/IFeatureContainerModelAssembly.java index f1553bad78..60f5e43461 100644 --- a/core/src/main/java/dev/metaschema/core/model/IFeatureContainerModelAssembly.java +++ b/core/src/main/java/dev/metaschema/core/model/IFeatureContainerModelAssembly.java @@ -9,6 +9,7 @@ import java.util.Map; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; /** * Provides assembly-specific container model functionality through delegation. @@ -63,4 +64,10 @@ default CGI getChoiceGroupInstanceByName(String name) { default Map getChoiceGroupInstances() { return getModelContainer().getChoiceGroupInstanceMap(); } + + @Override + @Nullable + default IAnyInstance getAnyInstance() { + return getModelContainer().getAnyInstance(); + } } diff --git a/core/src/main/java/dev/metaschema/core/model/IModelElementVisitor.java b/core/src/main/java/dev/metaschema/core/model/IModelElementVisitor.java index 4a8ae71769..df8da3a47a 100644 --- a/core/src/main/java/dev/metaschema/core/model/IModelElementVisitor.java +++ b/core/src/main/java/dev/metaschema/core/model/IModelElementVisitor.java @@ -98,6 +98,17 @@ public interface IModelElementVisitor { */ RESULT visitChoiceGroupInstance(@NonNull IChoiceGroupInstance item, CONTEXT context); + /** + * This callback is called when an {@link IAnyInstance} is visited. + * + * @param item + * the visited item + * @param context + * provides contextual information for use by the visitor + * @return the visitation result + */ + RESULT visitAny(@NonNull IAnyInstance item, CONTEXT context); + /** * This callback is called when an {@link IFlagDefinition} is visited. * diff --git a/core/src/main/java/dev/metaschema/core/model/ModelType.java b/core/src/main/java/dev/metaschema/core/model/ModelType.java index 07bd74ccf3..a853809aa3 100644 --- a/core/src/main/java/dev/metaschema/core/model/ModelType.java +++ b/core/src/main/java/dev/metaschema/core/model/ModelType.java @@ -30,7 +30,11 @@ public enum ModelType { /** * Represents a grouped choice construct. */ - CHOICE_GROUP("choice-group"); + CHOICE_GROUP("choice-group"), + /** + * Represents an any instance that allows unmodeled content. + */ + ANY("any"); private final String name; diff --git a/core/src/main/java/dev/metaschema/core/model/constraint/impl/ConstraintComposingVisitor.java b/core/src/main/java/dev/metaschema/core/model/constraint/impl/ConstraintComposingVisitor.java index f37f070931..58c8c97456 100644 --- a/core/src/main/java/dev/metaschema/core/model/constraint/impl/ConstraintComposingVisitor.java +++ b/core/src/main/java/dev/metaschema/core/model/constraint/impl/ConstraintComposingVisitor.java @@ -6,6 +6,7 @@ package dev.metaschema.core.model.constraint.impl; import dev.metaschema.core.model.AbstractModelElementVisitor; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IAssemblyDefinition; import dev.metaschema.core.model.IAssemblyInstanceAbsolute; import dev.metaschema.core.model.IAssemblyInstanceGrouped; @@ -40,6 +41,12 @@ public Void visitChoiceGroupInstance(IChoiceGroupInstance instance, ITargetedCon return null; } + @Override + public Void visitAny(IAnyInstance instance, ITargetedConstraints context) { + illegalTargetError(instance, context); + return null; + } + @Override public Void visitFlagInstance(IFlagInstance instance, ITargetedConstraints context) { if (instance.isInlineDefinition()) { diff --git a/core/src/main/java/dev/metaschema/core/model/impl/DefaultContainerModelAssemblySupport.java b/core/src/main/java/dev/metaschema/core/model/impl/DefaultContainerModelAssemblySupport.java index ab7f23d838..36cbfb263b 100644 --- a/core/src/main/java/dev/metaschema/core/model/impl/DefaultContainerModelAssemblySupport.java +++ b/core/src/main/java/dev/metaschema/core/model/impl/DefaultContainerModelAssemblySupport.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IAssemblyInstance; import dev.metaschema.core.model.IChoiceGroupInstance; import dev.metaschema.core.model.IChoiceInstance; @@ -19,6 +20,7 @@ import dev.metaschema.core.model.INamedModelInstance; import dev.metaschema.core.util.CollectionUtil; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; /** * Supports model instance operations on assembly model instances. @@ -60,12 +62,15 @@ public class DefaultContainerModelAssemblySupport< CollectionUtil.emptyMap(), CollectionUtil.emptyMap(), CollectionUtil.emptyList(), - CollectionUtil.emptyMap()); + CollectionUtil.emptyMap(), + null); @NonNull private final List choiceInstances; @NonNull private final Map choiceGroupInstances; + @Nullable + private final IAnyInstance anyInstance; /** * Construct an empty, mutable container. @@ -77,11 +82,12 @@ public DefaultContainerModelAssemblySupport() { new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedList<>(), - new LinkedHashMap<>()); + new LinkedHashMap<>(), + null); } /** - * Construct an new container using the provided collections. + * Construct a new container using the provided collections. * * @param instances * a collection of model instances @@ -95,6 +101,9 @@ public DefaultContainerModelAssemblySupport() { * a collection of choice instances * @param choiceGroupInstances * a collection of choice group instances + * @param anyInstance + * the {@code any} instance, or {@code null} if no {@code any} instance + * is defined */ public DefaultContainerModelAssemblySupport( @NonNull List instances, @@ -102,10 +111,12 @@ public DefaultContainerModelAssemblySupport( @NonNull Map fieldInstances, @NonNull Map assemblyInstances, @NonNull List choiceInstances, - @NonNull Map choiceGroupInstances) { + @NonNull Map choiceGroupInstances, + @Nullable IAnyInstance anyInstance) { super(instances, namedModelInstances, fieldInstances, assemblyInstances); this.choiceInstances = choiceInstances; this.choiceGroupInstances = choiceGroupInstances; + this.anyInstance = anyInstance; } @Override @@ -117,4 +128,9 @@ public List getChoiceInstances() { public Map getChoiceGroupInstanceMap() { return choiceGroupInstances; } + + @Override + public IAnyInstance getAnyInstance() { + return anyInstance; + } } diff --git a/core/src/test/java/dev/metaschema/core/model/ContainerModelAnyInstanceTest.java b/core/src/test/java/dev/metaschema/core/model/ContainerModelAnyInstanceTest.java new file mode 100644 index 0000000000..ad9cc7ba92 --- /dev/null +++ b/core/src/test/java/dev/metaschema/core/model/ContainerModelAnyInstanceTest.java @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import dev.metaschema.core.model.impl.DefaultContainerModelAssemblySupport; +import dev.metaschema.core.qname.IEnhancedQName; +import dev.metaschema.core.util.CollectionUtil; + +/** + * Tests for {@code any} instance support in container model classes. + */ +class ContainerModelAnyInstanceTest { + + @Test + void testInterfaceDefaultReturnsNull() { + IContainerModelAssemblySupport empty + = IContainerModelAssemblySupport.empty(); + assertNull(empty.getAnyInstance(), "Default getAnyInstance() should return null"); + } + + @Test + void testEmptyContainerReturnsNull() { + @SuppressWarnings("unchecked") + DefaultContainerModelAssemblySupport empty + = DefaultContainerModelAssemblySupport.EMPTY; + assertNull(empty.getAnyInstance(), "EMPTY container should return null for getAnyInstance()"); + } + + @Test + void testContainerStoresAnyInstance() { + IAnyInstance mockAny = Mockito.mock(IAnyInstance.class); + + DefaultContainerModelAssemblySupport container + = new DefaultContainerModelAssemblySupport<>( + CollectionUtil.emptyList(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyList(), + CollectionUtil.emptyMap(), + mockAny); + assertSame(mockAny, container.getAnyInstance(), + "Container should return the IAnyInstance passed to the constructor"); + } + + @Test + void testContainerConstructorWithNullAnyInstance() { + DefaultContainerModelAssemblySupport container + = new DefaultContainerModelAssemblySupport<>( + CollectionUtil.emptyList(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyMap(), + CollectionUtil.emptyList(), + CollectionUtil.emptyMap(), + null); + assertNull(container.getAnyInstance(), + "Container should return null when constructed with null anyInstance"); + } + + @SuppressWarnings("unchecked") + @Test + void testBuilderDefaultAnyInstanceIsNull() { + DefaultAssemblyModelBuilder builder + = new DefaultAssemblyModelBuilder<>(); + + assertNull(builder.getAnyInstance(), + "Builder should return null by default for getAnyInstance()"); + } + + @SuppressWarnings("unchecked") + @Test + void testBuilderStoresAnyInstance() { + IAnyInstance mockAny = Mockito.mock(IAnyInstance.class); + + DefaultAssemblyModelBuilder builder + = new DefaultAssemblyModelBuilder<>(); + builder.setAnyInstance(mockAny); + + assertSame(mockAny, builder.getAnyInstance(), + "Builder should return the IAnyInstance that was set"); + } + + @SuppressWarnings("unchecked") + @Test + void testBuilderPassesAnyInstanceToContainer() { + IAnyInstance mockAny = Mockito.mock(IAnyInstance.class); + IFieldInstance mockField = Mockito.mock(IFieldInstance.class); + IEnhancedQName mockQName = Mockito.mock(IEnhancedQName.class); + Mockito.when(mockQName.getIndexPosition()).thenReturn(1); + Mockito.when(mockField.getQName()).thenReturn(mockQName); + + DefaultAssemblyModelBuilder builder + = new DefaultAssemblyModelBuilder<>(); + + // Add a model instance so the builder doesn't return EMPTY + builder.append(mockField); + builder.setAnyInstance(mockAny); + + IContainerModelAssemblySupport container + = builder.buildAssembly(); + + assertSame(mockAny, container.getAnyInstance(), + "Built container should contain the IAnyInstance from the builder"); + assertEquals(1, container.getModelInstances().size(), + "Built container should contain the field instance"); + } + + @SuppressWarnings("unchecked") + @Test + void testBuilderBuildEmptyStillReturnsNullAny() { + DefaultAssemblyModelBuilder builder + = new DefaultAssemblyModelBuilder<>(); + + // Don't add any instances - should return empty container + IContainerModelAssemblySupport container + = builder.buildAssembly(); + + assertNull(container.getAnyInstance(), + "Empty built container should return null for getAnyInstance()"); + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/io/json/JsonAnyContent.java b/databind/src/main/java/dev/metaschema/databind/io/json/JsonAnyContent.java new file mode 100644 index 0000000000..f26145f82b --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/io/json/JsonAnyContent.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.json; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import dev.metaschema.core.model.IAnyContent; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * JSON/YAML-specific implementation of {@link IAnyContent} that stores captured + * unmodeled content as a Jackson {@link ObjectNode}. + */ +public class JsonAnyContent implements IAnyContent { + @NonNull + private final ObjectNode properties; + + /** + * Construct a new instance with the provided captured properties. + * + * @param properties + * the captured JSON properties, must not be null + */ + public JsonAnyContent(@NonNull ObjectNode properties) { + this.properties = properties; + } + + @Override + public boolean isEmpty() { + return properties.isEmpty(); + } + + /** + * Get the captured JSON properties. + * + * @return the captured ObjectNode + */ + @NonNull + public ObjectNode getProperties() { + return properties; + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonReader.java b/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonReader.java index 0107b2a12e..46e3c36a05 100644 --- a/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonReader.java +++ b/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonReader.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.logging.log4j.LogManager; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Map; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IBoundObject; import dev.metaschema.core.model.IResourceLocation; import dev.metaschema.core.model.SimpleResourceLocation; @@ -41,6 +43,7 @@ import dev.metaschema.databind.model.IBoundInstance; import dev.metaschema.databind.model.IBoundInstanceFlag; import dev.metaschema.databind.model.IBoundInstanceModel; +import dev.metaschema.databind.model.IBoundInstanceModelAny; import dev.metaschema.databind.model.IBoundInstanceModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex; @@ -601,6 +604,98 @@ public void accept( } } + /** + * Capture the next JSON property value as a {@link JsonNode} tree and advance + * the parser past it. This method handles the parser being positioned at either + * a {@link JsonToken#FIELD_NAME} or a value token. After this method returns, + * the parser is positioned at the token immediately following the captured + * value. + * + * @param parser + * the JSON parser + * @param resource + * the resource being parsed + * @return the captured value as a {@link JsonNode} + * @throws IOException + * if an error occurred while reading + */ + @SuppressWarnings({ + "resource", // parser not owned + "PMD.CyclomaticComplexity" // acceptable + }) + @NonNull + private static JsonNode capturePropertyValue( + @NonNull JsonParser parser, + @NonNull URI resource) throws IOException { + + // skip the field name if present + if (parser.currentToken() == JsonToken.FIELD_NAME) { + parser.nextToken(); + } + + JsonNode retval = buildJsonValue(parser); + // advance past the current value token (or past END_OBJECT/END_ARRAY + // for containers which are left at the closing token by buildJsonValue) + parser.nextToken(); + return retval; + } + + /** + * Recursively build a {@link JsonNode} from the current parser position. For + * scalar values, reads the current token's value. For containers (objects and + * arrays), recursively reads all nested content. After this method returns for + * a container, the parser is positioned at the container's closing token + * ({@code END_OBJECT} or {@code END_ARRAY}). + * + * @param parser + * the parser positioned at a value token + * @return the value as a JsonNode + * @throws IOException + * if an error occurred while reading + */ + @SuppressWarnings("PMD.CyclomaticComplexity") // token type switch + @NonNull + private static JsonNode buildJsonValue(@NonNull JsonParser parser) throws IOException { + JsonNodeFactory nodeFactory = JsonNodeFactory.instance; + JsonToken token = parser.currentToken(); + + switch (token) { + case START_OBJECT: { + ObjectNode obj = nodeFactory.objectNode(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + String fieldName = ObjectUtils.requireNonNull(parser.currentName()); + parser.nextToken(); // advance to value + obj.set(fieldName, buildJsonValue(parser)); + } + // parser is now at END_OBJECT + return obj; + } + case START_ARRAY: { + com.fasterxml.jackson.databind.node.ArrayNode arr = nodeFactory.arrayNode(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + arr.add(buildJsonValue(parser)); + } + // parser is now at END_ARRAY + return arr; + } + case VALUE_STRING: + return nodeFactory.textNode(ObjectUtils.requireNonNull(parser.getText())); + case VALUE_NUMBER_INT: + return nodeFactory.numberNode(parser.getBigIntegerValue()); + case VALUE_NUMBER_FLOAT: + return nodeFactory.numberNode(parser.getDecimalValue()); + case VALUE_TRUE: + return nodeFactory.booleanNode(true); + case VALUE_FALSE: + return nodeFactory.booleanNode(false); + case VALUE_NULL: + return nodeFactory.nullNode(); + default: + throw new IOException( + String.format("Unexpected token '%s' when capturing JSON value.", token)); + } + } + private final class PropertyBodyHandler implements DefinitionBodyHandler { @NonNull private final Map> jsonProperties; @@ -629,6 +724,12 @@ public void accept( // make a copy, since we use the remaining values to initialize default values Map> remainingInstances = new HashMap<>(jsonProperties); // NOPMD not concurrent + // Determine if this definition supports any content capture + IBoundInstanceModelAny boundAny = resolveAnyInstance(definition); + + // Accumulator for unmodeled properties when any instance is present + ObjectNode anyAccumulator = null; + // handle each property while (JsonToken.FIELD_NAME.equals(parser.currentToken())) { @@ -660,11 +761,23 @@ public void accept( parent, propertyName, MetaschemaJsonReader.this)) { - if (LOGGER.isWarnEnabled()) { - LOGGER.warn("Skipping unhandled JSON field '{}' {}.", propertyName, JsonUtil.toString(parser, resource)); + if (boundAny != null) { + // Capture the unmodeled property value into the any accumulator. + // Advance past the field name to the value, then skip the value + // using the same pattern as skipNextValue, but capture it. + JsonNode value = capturePropertyValue(parser, resource); + if (anyAccumulator == null) { + anyAccumulator = new ObjectNode(JsonNodeFactory.instance); + } + anyAccumulator.set(propertyName, value); + } else { + if (LOGGER.isWarnEnabled()) { + LOGGER.warn("Skipping unhandled JSON field '{}' {}.", + propertyName, JsonUtil.toString(parser, resource)); + } + JsonUtil.assertAndAdvance(parser, resource, JsonToken.FIELD_NAME); + JsonUtil.skipNextValue(parser, resource); } - JsonUtil.assertAndAdvance(parser, resource, JsonToken.FIELD_NAME); - JsonUtil.skipNextValue(parser, resource); } // the current token will be either the next instance field name or the end of @@ -672,6 +785,11 @@ public void accept( JsonUtil.assertCurrent(parser, resource, JsonToken.FIELD_NAME, JsonToken.END_OBJECT); } + // Set any captured content on the parent object + if (boundAny != null && anyAccumulator != null && !anyAccumulator.isEmpty()) { + boundAny.setAnyContent(parent, new JsonAnyContent(anyAccumulator)); + } + // Build validation context with current location and path ValidationContext context = buildValidationContext(); problemHandler.handleMissingInstances( @@ -686,6 +804,27 @@ public void accept( pathTracker.pop(); } } + + /** + * Resolve the bound any instance from the given definition, if available. + * + * @param definition + * the complex definition to check + * @return the bound any instance, or {@code null} if the definition does not + * support any content + */ + @Nullable + private IBoundInstanceModelAny resolveAnyInstance( + @NonNull IBoundDefinitionModelComplex definition) { + if (definition instanceof IBoundDefinitionModelAssembly) { + IAnyInstance anyInstance + = ((IBoundDefinitionModelAssembly) definition).getModelContainer().getAnyInstance(); + if (anyInstance instanceof IBoundInstanceModelAny) { + return (IBoundInstanceModelAny) anyInstance; + } + } + return null; + } } private static final class GroupedInstanceProblemHandler implements IJsonProblemHandler { diff --git a/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonWriter.java b/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonWriter.java index c70d9f1470..cf0f6bc5bc 100644 --- a/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonWriter.java +++ b/databind/src/main/java/dev/metaschema/databind/io/json/MetaschemaJsonWriter.java @@ -6,12 +6,18 @@ package dev.metaschema.databind.io.json; import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.eclipse.jdt.annotation.NotOwning; import java.io.IOException; +import java.util.Iterator; import java.util.List; +import java.util.Map; +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IBoundObject; import dev.metaschema.core.model.JsonGroupAsBehavior; import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; @@ -21,6 +27,7 @@ import dev.metaschema.databind.model.IBoundInstance; import dev.metaschema.databind.model.IBoundInstanceFlag; import dev.metaschema.databind.model.IBoundInstanceModel; +import dev.metaschema.databind.model.IBoundInstanceModelAny; import dev.metaschema.databind.model.IBoundInstanceModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex; @@ -270,6 +277,48 @@ private void writeObjectProperties( writeFieldValue((IBoundFieldValue) property, parent); } } + + // Write any captured unmodeled content + writeAnyContent(parent, handler); + } + + /** + * Write any captured unmodeled content from the parent object's + * {@code @BoundAny} field. If the definition has an any instance and the parent + * object has captured {@link JsonAnyContent}, each property is written as a + * top-level field in the current JSON object. + * + * @param parent + * the parent bound object + * @param handler + * the complex item value handler providing the definition + * @throws IOException + * if an error occurred while writing + */ + private void writeAnyContent( + @NonNull IBoundObject parent, + @NonNull IFeatureComplexItemValueHandler handler) throws IOException { + IBoundDefinitionModelComplex definition = handler.getDefinition(); + if (definition instanceof IBoundDefinitionModelAssembly) { + IAnyInstance anyInstance + = ((IBoundDefinitionModelAssembly) definition).getModelContainer().getAnyInstance(); + if (anyInstance instanceof IBoundInstanceModelAny) { + IBoundInstanceModelAny boundAny = (IBoundInstanceModelAny) anyInstance; + IAnyContent anyContent = boundAny.getAnyContent(parent); + if (anyContent instanceof JsonAnyContent) { + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + if (!jsonAny.isEmpty()) { + ObjectNode props = jsonAny.getProperties(); + Iterator> fields = props.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + generator.writeFieldName(entry.getKey()); + generator.writeTree(entry.getValue()); + } + } + } + } + } } private void writeDefinitionObject( diff --git a/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlReader.java b/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlReader.java index 664f20b8a5..ca4c62d8d3 100644 --- a/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlReader.java +++ b/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlReader.java @@ -8,9 +8,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.codehaus.stax2.XMLEventReader2; +import org.w3c.dom.Element; import java.io.IOException; import java.net.URI; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.LinkedHashMap; @@ -29,6 +31,7 @@ import javax.xml.stream.events.StartElement; import javax.xml.stream.events.XMLEvent; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IBoundObject; import dev.metaschema.core.model.IResourceLocation; import dev.metaschema.core.model.SimpleResourceLocation; @@ -46,6 +49,7 @@ import dev.metaschema.databind.model.IBoundFieldValue; import dev.metaschema.databind.model.IBoundInstanceFlag; import dev.metaschema.databind.model.IBoundInstanceModel; +import dev.metaschema.databind.model.IBoundInstanceModelAny; import dev.metaschema.databind.model.IBoundInstanceModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex; @@ -297,11 +301,25 @@ protected void readModelInstances( XMLEventReader2 reader = getReader(); URI resource = getSource(); - // handle any + // handle any content try { - if (!getReader().peek().isEndElement()) { - // handle any - XmlEventUtil.skipWhitespace(reader); + XmlEventUtil.skipWhitespace(reader); + + IAnyInstance anyInstance = targetDefinition.getModelContainer().getAnyInstance(); + + if (anyInstance instanceof IBoundInstanceModelAny && !reader.peek().isEndElement()) { + IBoundInstanceModelAny boundAny = (IBoundInstanceModelAny) anyInstance; + // Capture remaining child elements as DOM elements + List capturedElements = new ArrayList<>(); + while (reader.peek().isStartElement()) { + capturedElements.add(XmlDomUtil.staxToElement(reader)); + XmlEventUtil.skipWhitespace(reader); + } + if (!capturedElements.isEmpty()) { + boundAny.setAnyContent(targetObject, new XmlAnyContent(capturedElements)); + } + } else if (!reader.peek().isEndElement()) { + // No any instance defined; fall through to existing skip behavior XmlEventUtil.skipElement(reader); XmlEventUtil.skipWhitespace(reader); } diff --git a/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlWriter.java b/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlWriter.java index 705f27c568..d1311cee74 100644 --- a/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlWriter.java +++ b/databind/src/main/java/dev/metaschema/databind/io/xml/MetaschemaXmlWriter.java @@ -6,12 +6,16 @@ package dev.metaschema.databind.io.xml; import org.codehaus.stax2.XMLStreamWriter2; +import org.w3c.dom.Element; import java.io.IOException; +import java.util.List; import javax.xml.namespace.NamespaceContext; import javax.xml.stream.XMLStreamException; +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IBoundObject; import dev.metaschema.core.qname.IEnhancedQName; import dev.metaschema.databind.io.json.DefaultJsonProblemHandler; @@ -22,6 +26,7 @@ import dev.metaschema.databind.model.IBoundFieldValue; import dev.metaschema.databind.model.IBoundInstanceFlag; import dev.metaschema.databind.model.IBoundInstanceModel; +import dev.metaschema.databind.model.IBoundInstanceModelAny; import dev.metaschema.databind.model.IBoundInstanceModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex; @@ -207,6 +212,26 @@ private void writeAssemblyModel( assert modelInstance != null; writeModelInstance(modelInstance, parentItem, this); } + + // Write any content if present + IAnyInstance anyInstance = definition.getModelContainer().getAnyInstance(); + if (anyInstance instanceof IBoundInstanceModelAny) { + IBoundInstanceModelAny boundAny = (IBoundInstanceModelAny) anyInstance; + IAnyContent anyContent = boundAny.getAnyContent(parentItem); + if (anyContent instanceof XmlAnyContent) { + XmlAnyContent xmlAnyContent = (XmlAnyContent) anyContent; + if (!xmlAnyContent.isEmpty()) { + try { + List elements = xmlAnyContent.getElements(); + for (Element element : elements) { + XmlDomUtil.elementToStax(element, writer); + } + } catch (XMLStreamException ex) { + throw new IOException(ex); + } + } + } + } } private void writeFieldValue( diff --git a/databind/src/main/java/dev/metaschema/databind/io/xml/XmlAnyContent.java b/databind/src/main/java/dev/metaschema/databind/io/xml/XmlAnyContent.java new file mode 100644 index 0000000000..fd69f892fb --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/io/xml/XmlAnyContent.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.xml; + +import org.w3c.dom.Element; + +import java.util.Collections; +import java.util.List; + +import dev.metaschema.core.model.IAnyContent; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * XML-specific implementation of {@link IAnyContent} that stores captured + * unmodeled content as W3C DOM {@link Element} instances. + */ +public class XmlAnyContent implements IAnyContent { + @NonNull + private final List elements; + + /** + * Construct a new instance with the provided captured elements. + * + * @param elements + * the captured DOM elements, must not be null + */ + public XmlAnyContent(@NonNull List elements) { + this.elements = Collections.unmodifiableList(List.copyOf(elements)); + } + + @Override + public boolean isEmpty() { + return elements.isEmpty(); + } + + /** + * Get the captured DOM elements. + * + * @return an unmodifiable list of captured elements + */ + @NonNull + public List getElements() { + return elements; + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/io/xml/XmlDomUtil.java b/databind/src/main/java/dev/metaschema/databind/io/xml/XmlDomUtil.java new file mode 100644 index 0000000000..78e804a845 --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/io/xml/XmlDomUtil.java @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.xml; + +import org.codehaus.stax2.XMLEventReader2; +import org.codehaus.stax2.XMLStreamWriter2; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.Iterator; + +import javax.xml.XMLConstants; +import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.events.Attribute; +import javax.xml.stream.events.Characters; +import javax.xml.stream.events.Namespace; +import javax.xml.stream.events.StartElement; +import javax.xml.stream.events.XMLEvent; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Utility methods for converting between StAX events and W3C DOM elements. + * + *

+ * These methods support the {@code any} content feature by converting unmodeled + * XML content between the StAX event stream used during parsing and the DOM + * representation stored in {@link XmlAnyContent}. + */ +public final class XmlDomUtil { + + private XmlDomUtil() { + // disable construction + } + + /** + * Read an XML element from a StAX event reader and return it as a DOM + * {@link Element}. + * + *

+ * The reader must be positioned so that the next event is a + * {@link XMLStreamConstants#START_ELEMENT}. After this method returns, the + * reader will be positioned just past the matching + * {@link XMLStreamConstants#END_ELEMENT}. + * + * @param reader + * the StAX event reader, positioned before a start element + * @return the DOM element containing the full subtree + * @throws XMLStreamException + * if an error occurs while reading XML events + */ + @NonNull + public static Element staxToElement(@NonNull XMLEventReader2 reader) + throws XMLStreamException { + Document doc = newDocument(); + XMLEvent event = reader.nextEvent(); + if (!event.isStartElement()) { + throw new XMLStreamException("Expected START_ELEMENT but found " + event.getEventType()); + } + StartElement startElement = event.asStartElement(); + Element root = createDomElement(doc, startElement); + doc.appendChild(root); + + readChildren(reader, doc, root); + return root; + } + + /** + * Write a DOM {@link Element} to a StAX stream writer. + * + *

+ * This writes the complete element subtree including attributes, namespace + * declarations, child elements, and text content. + * + * @param element + * the DOM element to write + * @param writer + * the StAX stream writer to write to + * @throws XMLStreamException + * if an error occurs while writing to the stream + */ + public static void elementToStax( + @NonNull Element element, + @NonNull XMLStreamWriter2 writer) + throws XMLStreamException { + String namespaceUri = element.getNamespaceURI(); + String localName = element.getLocalName(); + String prefix = element.getPrefix(); + + if (namespaceUri != null && !namespaceUri.isEmpty()) { + if (prefix != null && !prefix.isEmpty()) { + writer.writeStartElement(prefix, localName, namespaceUri); + // Declare the namespace if the writer doesn't know about it + String existingPrefix = writer.getNamespaceContext().getPrefix(namespaceUri); + if (existingPrefix == null || !existingPrefix.equals(prefix)) { + writer.writeNamespace(prefix, namespaceUri); + } + } else { + writer.writeStartElement(namespaceUri, localName); + } + } else { + writer.writeStartElement(localName); + } + + // Write attributes + writeAttributes(element, writer); + + // Write child nodes + writeChildren(element, writer); + + writer.writeEndElement(); + } + + @NonNull + private static Document newDocument() throws XMLStreamException { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + // Harden against XXE: deny external DTD and schema access + dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + DocumentBuilder builder = dbf.newDocumentBuilder(); + return builder.newDocument(); + } catch (ParserConfigurationException ex) { + throw new XMLStreamException("Failed to create DOM DocumentBuilder", ex); + } + } + + @NonNull + private static Element createDomElement( + @NonNull Document doc, + @NonNull StartElement startElement) { + QName name = startElement.getName(); + String namespaceUri = name.getNamespaceURI(); + String localName = name.getLocalPart(); + String prefix = name.getPrefix(); + + Element element; + if (namespaceUri != null && !namespaceUri.isEmpty()) { + String qualifiedName = (prefix != null && !prefix.isEmpty()) + ? prefix + ":" + localName + : localName; + element = doc.createElementNS(namespaceUri, qualifiedName); + } else { + element = doc.createElement(localName); + } + + // Copy attributes + @SuppressWarnings("unchecked") + Iterator attrs = startElement.getAttributes(); + while (attrs.hasNext()) { + Attribute attr = attrs.next(); + QName attrName = attr.getName(); + String attrNs = attrName.getNamespaceURI(); + String attrLocal = attrName.getLocalPart(); + String attrPrefix = attrName.getPrefix(); + + if (attrNs != null && !attrNs.isEmpty()) { + String attrQualified = (attrPrefix != null && !attrPrefix.isEmpty()) + ? attrPrefix + ":" + attrLocal + : attrLocal; + element.setAttributeNS(attrNs, attrQualified, attr.getValue()); + } else { + element.setAttribute(attrLocal, attr.getValue()); + } + } + + // Copy namespace declarations as xmlns attributes + @SuppressWarnings("unchecked") + Iterator namespaces = startElement.getNamespaces(); + while (namespaces.hasNext()) { + Namespace ns = namespaces.next(); + String nsPrefix = ns.getPrefix(); + if (nsPrefix != null && !nsPrefix.isEmpty()) { + element.setAttributeNS( + "http://www.w3.org/2000/xmlns/", + "xmlns:" + nsPrefix, + ns.getNamespaceURI()); + } + // default namespace is handled by createElementNS + } + + return element; + } + + private static void readChildren( + @NonNull XMLEventReader2 reader, + @NonNull Document doc, + @NonNull Element parent) throws XMLStreamException { + while (reader.hasNext()) { + XMLEvent event = reader.peek(); + if (event.isEndElement()) { + // Consume the end element and return + reader.nextEvent(); + return; + } else if (event.isStartElement()) { + StartElement childStart = reader.nextEvent().asStartElement(); + Element child = createDomElement(doc, childStart); + parent.appendChild(child); + readChildren(reader, doc, child); + } else if (event.isCharacters()) { + Characters chars = reader.nextEvent().asCharacters(); + parent.appendChild(doc.createTextNode(chars.getData())); + } else { + // Skip other event types (comments, processing instructions, etc.) + reader.nextEvent(); + } + } + } + + private static void writeAttributes( + @NonNull Element element, + @NonNull XMLStreamWriter2 writer) throws XMLStreamException { + NamedNodeMap attrs = element.getAttributes(); + for (int i = 0; i < attrs.getLength(); i++) { + Node attr = attrs.item(i); + String attrNs = attr.getNamespaceURI(); + // getLocalName() may return null for attributes created without + // namespace awareness; fall back to getNodeName() + String attrName = attr.getLocalName(); + if (attrName == null) { + attrName = attr.getNodeName(); + } + String attrValue = attr.getNodeValue(); + + // Skip xmlns declarations - they are handled by + // writeStartElement/writeNamespace + if ("http://www.w3.org/2000/xmlns/".equals(attrNs)) { + continue; + } + + if (attrNs != null && !attrNs.isEmpty()) { + String attrPrefix = attr.getPrefix(); + if (attrPrefix != null && !attrPrefix.isEmpty()) { + writer.writeAttribute(attrPrefix, attrNs, attrName, attrValue); + } else { + writer.writeAttribute(attrNs, attrName, attrValue); + } + } else { + writer.writeAttribute(attrName, attrValue); + } + } + } + + private static void writeChildren( + @NonNull Element element, + @NonNull XMLStreamWriter2 writer) throws XMLStreamException { + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + switch (child.getNodeType()) { + case Node.ELEMENT_NODE: + elementToStax((Element) child, writer); + break; + case Node.TEXT_NODE: + writer.writeCharacters(child.getTextContent()); + break; + case Node.CDATA_SECTION_NODE: + writer.writeCData(child.getTextContent()); + break; + default: + // Skip other node types + break; + } + } + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/model/IBoundInstanceModelAny.java b/databind/src/main/java/dev/metaschema/databind/model/IBoundInstanceModelAny.java new file mode 100644 index 0000000000..05f0a7916f --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/model/IBoundInstanceModelAny.java @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model; + +import java.lang.reflect.Field; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IAnyInstance; +import dev.metaschema.databind.model.impl.InstanceModelAny; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Represents an {@code any} instance bound to a Java field annotated with + * {@link dev.metaschema.databind.model.annotations.BoundAny @BoundAny}. + * + *

+ * This interface bridges the core {@link IAnyInstance} with the databind + * binding layer, providing reflective access to the {@link IAnyContent} field + * on a bound object. + */ +public interface IBoundInstanceModelAny extends IAnyInstance, IFeatureJavaField { + + /** + * Create a new bound {@code any} instance. + * + * @param field + * the Java field annotated with {@code @BoundAny} + * @param containingDefinition + * the assembly definition containing this instance + * @return the new bound {@code any} instance + */ + @NonNull + static IBoundInstanceModelAny newInstance( + @NonNull Field field, + @NonNull IBoundDefinitionModelAssembly containingDefinition) { + return InstanceModelAny.newInstance(field, containingDefinition); + } + + /** + * Get the containing assembly definition for this instance. + * + * @return the containing assembly definition + */ + @Override + @NonNull + IBoundDefinitionModelAssembly getContainingDefinition(); + + /** + * Get the {@link IAnyContent} value from the parent bound object. + * + * @param parent + * the parent object containing the bound field + * @return the captured unmodeled content, or {@code null} if no content has + * been captured + */ + @Nullable + default IAnyContent getAnyContent(@NonNull Object parent) { + return (IAnyContent) getValue(parent); + } + + /** + * Set the {@link IAnyContent} value on the parent bound object. + * + * @param parent + * the parent object containing the bound field + * @param value + * the unmodeled content to set, or {@code null} to clear it + */ + default void setAnyContent(@NonNull Object parent, @Nullable IAnyContent value) { + setValue(parent, value); + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/model/annotations/BoundAny.java b/databind/src/main/java/dev/metaschema/databind/model/annotations/BoundAny.java new file mode 100644 index 0000000000..5a51ec22e1 --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/model/annotations/BoundAny.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.annotations; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a field of type {@link dev.metaschema.core.model.IAnyContent} on a + * bound class to receive unmodeled content from assemblies that declare + * {@code } in their model. + * + *

+ * During deserialization, content not matching any declared model instance is + * captured into this field. During serialization, captured content is written + * back after all declared model instances. + */ +@Documented +@Retention(RUNTIME) +@Target(FIELD) +public @interface BoundAny { +} diff --git a/databind/src/main/java/dev/metaschema/databind/model/impl/AssemblyModelGenerator.java b/databind/src/main/java/dev/metaschema/databind/model/impl/AssemblyModelGenerator.java index fefefa380f..4a56c6e24f 100644 --- a/databind/src/main/java/dev/metaschema/databind/model/impl/AssemblyModelGenerator.java +++ b/databind/src/main/java/dev/metaschema/databind/model/impl/AssemblyModelGenerator.java @@ -22,16 +22,19 @@ import dev.metaschema.core.util.ObjectUtils; import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModel; +import dev.metaschema.databind.model.IBoundInstanceModelAny; import dev.metaschema.databind.model.IBoundInstanceModelAssembly; import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelField; import dev.metaschema.databind.model.IBoundInstanceModelNamed; +import dev.metaschema.databind.model.annotations.BoundAny; import dev.metaschema.databind.model.annotations.BoundAssembly; import dev.metaschema.databind.model.annotations.BoundChoice; import dev.metaschema.databind.model.annotations.BoundChoiceGroup; import dev.metaschema.databind.model.annotations.BoundField; import dev.metaschema.databind.model.annotations.Ignore; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; /** * Generates assembly model containers for annotation-based bindings. @@ -141,6 +144,13 @@ IBoundInstanceModelChoiceGroup> of(@NonNull DefinitionAssembly containingDefinit builder.appendChoiceOnly(choice); } + // Scan for @BoundAny field (handled separately from model instances) + IBoundInstanceModelAny anyInstance + = findBoundAnyInstance(containingDefinition, containingDefinition.getBoundClass()); + if (anyInstance != null) { + builder.setAnyInstance(anyInstance); + } + return builder.buildAssembly(); } @@ -280,6 +290,48 @@ private static Stream> getModelInstanceStream( .filter(Objects::nonNull))); } + /** + * Scans the bound class hierarchy for a field annotated with + * {@link BoundAny @BoundAny}. + * + * @param containingDefinition + * the assembly definition containing the bound class + * @param clazz + * the class to scan (including its superclass hierarchy) + * @return the bound {@code any} instance, or {@code null} if no + * {@code @BoundAny} field is found + * @throws IllegalStateException + * if more than one {@code @BoundAny} field is found in the class + * hierarchy + */ + @Nullable + private static IBoundInstanceModelAny findBoundAnyInstance( + @NonNull IBoundDefinitionModelAssembly containingDefinition, + @NonNull Class clazz) { + IBoundInstanceModelAny result = null; + + // Walk the class hierarchy (superclass first) + Class superClass = clazz.getSuperclass(); + if (superClass != null) { + result = findBoundAnyInstance(containingDefinition, superClass); + } + + // Scan declared fields for @BoundAny + for (Field field : clazz.getDeclaredFields()) { + if (field.isAnnotationPresent(BoundAny.class)) { + if (result != null) { + throw new IllegalStateException(String.format( + "Multiple @BoundAny fields found in class hierarchy of '%s'." + + " Only one @BoundAny field is allowed per assembly.", + containingDefinition.getBoundClass().getName())); + } + result = IBoundInstanceModelAny.newInstance(field, containingDefinition); + } + } + + return result; + } + private AssemblyModelGenerator() { // disable construction } diff --git a/databind/src/main/java/dev/metaschema/databind/model/impl/InstanceModelAny.java b/databind/src/main/java/dev/metaschema/databind/model/impl/InstanceModelAny.java new file mode 100644 index 0000000000..e6fcf410e3 --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/model/impl/InstanceModelAny.java @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.impl; + +import java.lang.reflect.Field; + +import dev.metaschema.core.datatype.markup.MarkupMultiline; +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IContainerModel; +import dev.metaschema.core.model.IModule; +import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; +import dev.metaschema.databind.model.IBoundInstanceModelAny; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Implements a bound {@code any} instance backed by a Java field annotated with + * {@link dev.metaschema.databind.model.annotations.BoundAny @BoundAny}. + * + *

+ * This class uses reflection to get and set the {@link IAnyContent} field on a + * bound object, following the same pattern as other bound instance + * implementations in this package. + */ +public final class InstanceModelAny implements IBoundInstanceModelAny { + @NonNull + private final Field javaField; + @NonNull + private final IBoundDefinitionModelAssembly containingDefinition; + + /** + * Construct a new bound {@code any} instance. + * + * @param javaField + * the Java field annotated with {@code @BoundAny} + * @param containingDefinition + * the assembly definition containing this instance + * @return the new instance + */ + @NonNull + public static InstanceModelAny newInstance( + @NonNull Field javaField, + @NonNull IBoundDefinitionModelAssembly containingDefinition) { + return new InstanceModelAny(javaField, containingDefinition); + } + + private InstanceModelAny( + @NonNull Field javaField, + @NonNull IBoundDefinitionModelAssembly containingDefinition) { + FieldSupport.bindField(javaField); + this.javaField = javaField; + this.containingDefinition = containingDefinition; + } + + @Override + public Field getField() { + return javaField; + } + + @Override + public IBoundDefinitionModelAssembly getContainingDefinition() { + return containingDefinition; + } + + @Override + public IContainerModel getParentContainer() { + return getContainingDefinition(); + } + + @Override + public IModule getContainingModule() { + return getContainingDefinition().getContainingModule(); + } + + @Override + @Nullable + public MarkupMultiline getRemarks() { + // any instances do not have remarks + return null; + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AnyInstanceImpl.java b/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AnyInstanceImpl.java new file mode 100644 index 0000000000..fe3d772617 --- /dev/null +++ b/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AnyInstanceImpl.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.metaschema.impl; + +import dev.metaschema.core.datatype.markup.MarkupMultiline; +import dev.metaschema.core.model.IAnyInstance; +import dev.metaschema.core.model.IContainerModelAbsolute; +import dev.metaschema.core.model.IModule; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Implements an {@code any} instance for module-loaded assembly definitions. + *

+ * This class provides a simple implementation of {@link IAnyInstance} that is + * used when a Metaschema module is loaded from its binding representation. It + * stores a reference to the containing model container (typically an assembly + * definition) and delegates other behavior to the default methods on + * {@link IAnyInstance}. + */ +public final class AnyInstanceImpl implements IAnyInstance { + @NonNull + private final IContainerModelAbsolute parentContainer; + + /** + * Construct a new {@code any} instance for a module-loaded assembly. + * + * @param parentContainer + * the model container (assembly definition) that owns this instance + */ + public AnyInstanceImpl(@NonNull IContainerModelAbsolute parentContainer) { + this.parentContainer = parentContainer; + } + + @Override + public IContainerModelAbsolute getParentContainer() { + return parentContainer; + } + + @Override + public IModule getContainingModule() { + return getContainingDefinition().getContainingModule(); + } + + @Override + @Nullable + public MarkupMultiline getRemarks() { + // any instances do not have remarks + return null; + } +} diff --git a/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AssemblyModelGenerator.java b/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AssemblyModelGenerator.java index cecb801016..789ff3c537 100644 --- a/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AssemblyModelGenerator.java +++ b/databind/src/main/java/dev/metaschema/databind/model/metaschema/impl/AssemblyModelGenerator.java @@ -19,6 +19,7 @@ import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup; import dev.metaschema.databind.model.IBoundInstanceModelGroupedAssembly; import dev.metaschema.databind.model.metaschema.IBindingDefinitionModelAssembly; +import dev.metaschema.databind.model.metaschema.binding.Any; import dev.metaschema.databind.model.metaschema.binding.AssemblyModel; import dev.metaschema.databind.model.metaschema.binding.AssemblyReference; import dev.metaschema.databind.model.metaschema.binding.FieldReference; @@ -76,7 +77,7 @@ IChoiceGroupInstance> of( @NonNull IBoundInstanceModelAssembly bindingInstance, @NonNull IBindingDefinitionModelAssembly parent, @NonNull INodeItemFactory nodeItemFactory) { - return binding == null || binding.getInstances().isEmpty() + return binding == null || (binding.getInstances().isEmpty() && binding.getAny() == null) ? IContainerModelAssemblySupport.empty() : newInstance( binding, @@ -125,6 +126,12 @@ IChoiceGroupInstance> newInstance( } }); + // Process the any binding if present + Any any = binding.getAny(); + if (any != null) { + generator.getBuilder().setAnyInstance(new AnyInstanceImpl(parent)); + } + return generator.getBuilder().buildAssembly(); } diff --git a/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonRoundTripTest.java b/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonRoundTripTest.java new file mode 100644 index 0000000000..01c8e2f40d --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonRoundTripTest.java @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.URI; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import dev.metaschema.databind.codegen.AbstractMetaschemaTest; +import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; +import dev.metaschema.databind.model.test.AnyAssembly; + +class AnyJsonRoundTripTest + extends AbstractMetaschemaTest { + + private static final URI SOURCE = ObjectUtils.notNull(URI.create("https://example.com/test")); + + private IBoundDefinitionModelAssembly getAssemblyDefinition() throws IOException { + IBindingContext bindingContext = newBindingContext(); + return ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + } + + private AnyAssembly readJson(String json) throws IOException { + IBoundDefinitionModelAssembly assembly = getAssemblyDefinition(); + JsonFactory factory = JsonFactoryFactory.instance(); + try (JsonParser parser = factory.createParser(new StringReader(json))) { + MetaschemaJsonReader reader = new MetaschemaJsonReader(parser, SOURCE); + return reader.readObjectRoot( + assembly, + ObjectUtils.requireNonNull(assembly.getRootJsonName())); + } + } + + private String writeJson(AnyAssembly obj) throws IOException { + IBoundDefinitionModelAssembly assembly = getAssemblyDefinition(); + StringWriter sw = new StringWriter(); + JsonFactory factory = JsonFactoryFactory.instance(); + try (JsonGenerator generator = factory.createGenerator(sw)) { + generator.writeStartObject(); + generator.writeFieldName(ObjectUtils.requireNonNull(assembly.getRootJsonName())); + + MetaschemaJsonWriter writer = new MetaschemaJsonWriter(generator); + writer.write(assembly, obj); + + generator.writeEndObject(); + } + return sw.toString(); + } + + @Test + void testReadWithNoExtraProperties() throws IOException { + String json = "{\"any-assembly\":{\"known-field\":\"hello\"}}"; + AnyAssembly result = readJson(json); + + assertEquals("hello", result.getKnownField()); + assertNull(result.getAny(), "Any content should be null when no extra properties"); + } + + @Test + void testReadCapturesExtraStringProperty() throws IOException { + String json = "{\"any-assembly\":{\"known-field\":\"hello\",\"extra-string\":\"value\"}}"; + AnyAssembly result = readJson(json); + + assertEquals("hello", result.getKnownField()); + + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent, "Any content should capture extra properties"); + assertInstanceOf(JsonAnyContent.class, anyContent); + + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + assertFalse(jsonAny.isEmpty()); + ObjectNode props = jsonAny.getProperties(); + assertNotNull(props.get("extra-string")); + assertEquals("value", props.get("extra-string").asText()); + } + + @Test + void testReadCapturesMultipleExtraProperties() throws IOException { + String json = "{\"any-assembly\":{\"known-field\":\"hello\"" + + ",\"extra-string\":\"value\"" + + ",\"extra-number\":42" + + ",\"extra-object\":{\"nested\":\"data\"}" + + ",\"extra-array\":[1,2,3]" + + "}}"; + AnyAssembly result = readJson(json); + + assertEquals("hello", result.getKnownField()); + + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent, "Any content should capture extra properties"); + assertInstanceOf(JsonAnyContent.class, anyContent); + + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + ObjectNode props = jsonAny.getProperties(); + + // Verify string property + assertNotNull(props.get("extra-string")); + assertEquals("value", props.get("extra-string").asText()); + + // Verify number property + assertNotNull(props.get("extra-number")); + assertEquals(42, props.get("extra-number").asInt()); + + // Verify object property + JsonNode objectProp = props.get("extra-object"); + assertNotNull(objectProp); + assertTrue(objectProp.isObject()); + assertEquals("data", objectProp.get("nested").asText()); + + // Verify array property + JsonNode arrayProp = props.get("extra-array"); + assertNotNull(arrayProp); + assertTrue(arrayProp.isArray()); + assertEquals(3, arrayProp.size()); + } + + @Test + void testWriteSerializesAnyContent() throws IOException { + AnyAssembly obj = new AnyAssembly(); + obj.setKnownField("hello"); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode extraProps = mapper.createObjectNode(); + extraProps.put("extra-string", "value"); + extraProps.put("extra-number", 42); + obj.setAny(new JsonAnyContent(extraProps)); + + String json = writeJson(obj); + assertNotNull(json); + + // Parse the output back and verify the extra properties are present + JsonNode root = mapper.readTree(json); + JsonNode assembly = root.get("any-assembly"); + assertNotNull(assembly, "Root wrapper should exist"); + + assertEquals("hello", assembly.get("known-field").asText()); + assertEquals("value", assembly.get("extra-string").asText()); + assertEquals(42, assembly.get("extra-number").asInt()); + } + + @Test + void testWriteWithNullAnyContent() throws IOException { + AnyAssembly obj = new AnyAssembly(); + obj.setKnownField("hello"); + // any is null + + String json = writeJson(obj); + assertNotNull(json); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(json); + JsonNode assembly = root.get("any-assembly"); + assertNotNull(assembly); + assertEquals("hello", assembly.get("known-field").asText()); + + // Should only have the known-field, no extra properties + assertEquals(1, assembly.size(), "Should only have known-field"); + } + + @Test + void testWriteWithEmptyAnyContent() throws IOException { + AnyAssembly obj = new AnyAssembly(); + obj.setKnownField("hello"); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode emptyProps = mapper.createObjectNode(); + obj.setAny(new JsonAnyContent(emptyProps)); + + String json = writeJson(obj); + assertNotNull(json); + + JsonNode root = mapper.readTree(json); + JsonNode assembly = root.get("any-assembly"); + assertNotNull(assembly); + + // Empty any content should not produce extra fields + assertEquals(1, assembly.size(), "Empty any content should not add extra fields"); + } + + @Test + void testRoundTrip() throws IOException { + String json = "{\"any-assembly\":{\"known-field\":\"hello\"" + + ",\"extra-string\":\"value\"" + + ",\"extra-number\":42" + + "}}"; + + // Read + AnyAssembly result = readJson(json); + assertEquals("hello", result.getKnownField()); + assertNotNull(result.getAny()); + + // Write back + String output = writeJson(result); + + // Re-read + AnyAssembly result2 = readJson(output); + + // Verify the round trip preserved everything + assertEquals("hello", result2.getKnownField()); + assertNotNull(result2.getAny()); + assertInstanceOf(JsonAnyContent.class, result2.getAny()); + + JsonAnyContent jsonAny = (JsonAnyContent) result2.getAny(); + ObjectNode props = jsonAny.getProperties(); + assertEquals("value", props.get("extra-string").asText()); + assertEquals(42, props.get("extra-number").asInt()); + } + + @Test + void testRoundTripWithNestedObject() throws IOException { + String json = "{\"any-assembly\":{\"known-field\":\"test\"" + + ",\"complex\":{\"a\":1,\"b\":{\"c\":\"deep\"}}" + + "}}"; + + // Read + AnyAssembly result = readJson(json); + assertEquals("test", result.getKnownField()); + assertNotNull(result.getAny()); + + // Write back + String output = writeJson(result); + + // Re-read + AnyAssembly result2 = readJson(output); + assertNotNull(result2.getAny()); + + JsonAnyContent jsonAny = (JsonAnyContent) result2.getAny(); + ObjectNode props = jsonAny.getProperties(); + JsonNode complex = props.get("complex"); + assertNotNull(complex); + assertEquals(1, complex.get("a").asInt()); + assertEquals("deep", complex.get("b").get("c").asText()); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonValueKeyTest.java b/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonValueKeyTest.java new file mode 100644 index 0000000000..03b33eac17 --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/io/json/AnyJsonValueKeyTest.java @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.util.Map; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import dev.metaschema.databind.codegen.AbstractMetaschemaTest; +import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; +import dev.metaschema.databind.model.test.AnyWithJsonKeyAssembly; + +/** + * Tests verifying that {@code @BoundAny} content capture correctly + * distinguishes between properties matched by json-key flags and truly + * unmodeled properties. + * + *

+ * When an assembly uses json-key with {@code inJson = KEYED}, the group name is + * a known property in the parent's JSON properties map. This test verifies that + * such properties are resolved as known instances and NOT captured as "any" + * content, while genuinely unknown properties are correctly captured. + */ +class AnyJsonValueKeyTest + extends AbstractMetaschemaTest { + + private static final URI SOURCE = ObjectUtils.notNull(URI.create("https://example.com/test")); + + private IBoundDefinitionModelAssembly getAssemblyDefinition() throws IOException { + IBindingContext bindingContext = newBindingContext(); + return ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass( + AnyWithJsonKeyAssembly.class)); + } + + private AnyWithJsonKeyAssembly readJson(String json) throws IOException { + IBoundDefinitionModelAssembly assembly = getAssemblyDefinition(); + JsonFactory factory = JsonFactoryFactory.instance(); + try (JsonParser parser = factory.createParser(new StringReader(json))) { + MetaschemaJsonReader reader = new MetaschemaJsonReader(parser, SOURCE); + return reader.readObjectRoot( + assembly, + ObjectUtils.requireNonNull(assembly.getRootJsonName())); + } + } + + @Test + void testKeyedFieldParsedCorrectlyWithNoExtra() throws IOException { + // JSON with a known-field and keyed-fields, but no unknown properties. + // The "keyed-fields" property is a known json-key group name. + String json = "{\"any-json-key-assembly\":{" + + "\"known-field\":\"hello\"," + + "\"keyed-fields\":{\"key1\":\"value1\",\"key2\":\"value2\"}" + + "}}"; + + AnyWithJsonKeyAssembly result = readJson(json); + + // Verify known field is correctly parsed + assertEquals("hello", result.getKnownField()); + + // Verify keyed fields are correctly parsed + Map keyed = result.getKeyedField(); + assertNotNull(keyed, "Keyed field map should not be null"); + assertEquals(2, keyed.size(), "Should have 2 keyed entries"); + + AnyWithJsonKeyAssembly.KeyedField entry1 = keyed.get("key1"); + assertNotNull(entry1, "Entry 'key1' should exist"); + assertEquals("key1", entry1.getId()); + assertEquals("value1", entry1.getValue()); + + AnyWithJsonKeyAssembly.KeyedField entry2 = keyed.get("key2"); + assertNotNull(entry2, "Entry 'key2' should exist"); + assertEquals("key2", entry2.getId()); + assertEquals("value2", entry2.getValue()); + + // No unknown properties, so any should be null + assertNull(result.getAny(), + "Any content should be null when all properties are known"); + } + + @Test + void testKeyedFieldWithExtraPropertyGoesToAny() throws IOException { + // JSON with known-field, keyed-fields, AND an unknown "extra" property. + // The "keyed-fields" group name should be matched as a known property. + // The "extra" property should be captured as any content. + String json = "{\"any-json-key-assembly\":{" + + "\"known-field\":\"hello\"," + + "\"keyed-fields\":{\"item1\":\"val1\"}" + + ",\"extra-prop\":\"extra-value\"" + + "}}"; + + AnyWithJsonKeyAssembly result = readJson(json); + + // Known field correctly parsed + assertEquals("hello", result.getKnownField()); + + // Keyed field correctly parsed + Map keyed = result.getKeyedField(); + assertNotNull(keyed); + assertEquals(1, keyed.size()); + assertNotNull(keyed.get("item1")); + assertEquals("val1", keyed.get("item1").getValue()); + + // Unknown property captured in any + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent, "Any content should capture the unknown property"); + assertInstanceOf(JsonAnyContent.class, anyContent); + + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + assertFalse(jsonAny.isEmpty()); + ObjectNode props = jsonAny.getProperties(); + assertNotNull(props.get("extra-prop")); + assertEquals("extra-value", props.get("extra-prop").asText()); + + // The keyed-fields group name must NOT appear in any content + assertNull(props.get("keyed-fields"), + "The json-key group name should not be captured as any content"); + // The known-field must NOT appear in any content + assertNull(props.get("known-field"), + "The known field should not be captured as any content"); + } + + @Test + void testOnlyUnknownPropertiesCapturedAsAny() throws IOException { + // JSON with ONLY unknown properties (no known-field, no keyed-fields). + // All properties should go to any. + String json = "{\"any-json-key-assembly\":{" + + "\"unknown1\":\"value1\"," + + "\"unknown2\":42" + + "}}"; + + AnyWithJsonKeyAssembly result = readJson(json); + + // Known fields are null + assertNull(result.getKnownField()); + assertTrue(result.getKeyedField() == null || result.getKeyedField().isEmpty()); + + // Unknown properties captured in any + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent, "Any content should capture unknown properties"); + assertInstanceOf(JsonAnyContent.class, anyContent); + + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + ObjectNode props = jsonAny.getProperties(); + assertEquals(2, props.size()); + assertEquals("value1", props.get("unknown1").asText()); + assertEquals(42, props.get("unknown2").asInt()); + } + + @Test + void testMultipleUnknownWithKeyedAndKnown() throws IOException { + // Comprehensive test with all three types of properties: + // known-field, keyed-fields, and multiple unknown properties. + String json = "{\"any-json-key-assembly\":{" + + "\"known-field\":\"test\"," + + "\"keyed-fields\":{\"a\":\"alpha\",\"b\":\"beta\"}" + + ",\"extra-string\":\"foo\"" + + ",\"extra-object\":{\"nested\":true}" + + ",\"extra-array\":[1,2,3]" + + "}}"; + + AnyWithJsonKeyAssembly result = readJson(json); + + // Verify known field + assertEquals("test", result.getKnownField()); + + // Verify keyed fields + Map keyed = result.getKeyedField(); + assertNotNull(keyed); + assertEquals(2, keyed.size()); + assertEquals("alpha", keyed.get("a").getValue()); + assertEquals("beta", keyed.get("b").getValue()); + + // Verify any content captures only the unknown properties + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent); + assertInstanceOf(JsonAnyContent.class, anyContent); + + JsonAnyContent jsonAny = (JsonAnyContent) anyContent; + ObjectNode props = jsonAny.getProperties(); + assertEquals(3, props.size(), + "Should capture exactly 3 unknown properties"); + + // Verify each unknown property + assertEquals("foo", props.get("extra-string").asText()); + assertTrue(props.get("extra-object").isObject()); + assertTrue(props.get("extra-object").get("nested").asBoolean()); + assertTrue(props.get("extra-array").isArray()); + assertEquals(3, props.get("extra-array").size()); + + // Verify known properties are NOT in any content + assertNull(props.get("known-field")); + assertNull(props.get("keyed-fields")); + } + + @Test + void testPropertyNameMatchingKnownFieldGoesToField() throws IOException { + // Verify that a JSON property whose name exactly matches a known field's + // use-name is directed to the field, not to any content. + String json = "{\"any-json-key-assembly\":{" + + "\"known-field\":\"matched-value\"" + + "}}"; + + AnyWithJsonKeyAssembly result = readJson(json); + + assertEquals("matched-value", result.getKnownField()); + assertNull(result.getAny(), + "No unknown properties means any should be null"); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/io/json/JsonAnyContentTest.java b/databind/src/test/java/dev/metaschema/databind/io/json/JsonAnyContentTest.java new file mode 100644 index 0000000000..3ad37076af --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/io/json/JsonAnyContentTest.java @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.Test; + +class JsonAnyContentTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void testEmptyObjectNodeIsEmpty() { + ObjectNode node = MAPPER.createObjectNode(); + JsonAnyContent content = new JsonAnyContent(node); + + assertTrue(content.isEmpty(), "Empty ObjectNode should report isEmpty() as true"); + } + + @Test + void testEmptyObjectNodeGetProperties() { + ObjectNode node = MAPPER.createObjectNode(); + JsonAnyContent content = new JsonAnyContent(node); + + assertNotNull(content.getProperties()); + assertSame(node, content.getProperties()); + assertEquals(0, content.getProperties().size()); + } + + @Test + void testNonEmptyObjectNodeIsNotEmpty() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("key", "value"); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty(), "Non-empty ObjectNode should report isEmpty() as false"); + } + + @Test + void testGetPropertiesReturnsSameNode() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("key", "value"); + JsonAnyContent content = new JsonAnyContent(node); + + assertSame(node, content.getProperties(), "getProperties() should return the same ObjectNode instance"); + } + + @Test + void testStringProperty() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("name", "test-value"); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertEquals("test-value", content.getProperties().get("name").asText()); + } + + @Test + void testNumericProperty() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("count", 42); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertEquals(42, content.getProperties().get("count").asInt()); + } + + @Test + void testBooleanProperty() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("active", true); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertTrue(content.getProperties().get("active").asBoolean()); + } + + @Test + void testArrayProperty() { + ObjectNode node = MAPPER.createObjectNode(); + node.putArray("items").add("a").add("b").add("c"); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertTrue(content.getProperties().get("items").isArray()); + assertEquals(3, content.getProperties().get("items").size()); + } + + @Test + void testNestedObjectProperty() { + ObjectNode node = MAPPER.createObjectNode(); + ObjectNode nested = MAPPER.createObjectNode(); + nested.put("inner-key", "inner-value"); + node.set("nested", nested); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertTrue(content.getProperties().get("nested").isObject()); + assertEquals("inner-value", content.getProperties().get("nested").get("inner-key").asText()); + } + + @Test + void testMultipleProperties() { + ObjectNode node = MAPPER.createObjectNode(); + node.put("string-prop", "hello"); + node.put("number-prop", 99); + node.put("bool-prop", false); + JsonAnyContent content = new JsonAnyContent(node); + + assertFalse(content.isEmpty()); + assertEquals(3, content.getProperties().size()); + assertEquals("hello", content.getProperties().get("string-prop").asText()); + assertEquals(99, content.getProperties().get("number-prop").asInt()); + assertFalse(content.getProperties().get("bool-prop").asBoolean()); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/io/xml/AnyXmlRoundTripTest.java b/databind/src/test/java/dev/metaschema/databind/io/xml/AnyXmlRoundTripTest.java new file mode 100644 index 0000000000..babc5103df --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/io/xml/AnyXmlRoundTripTest.java @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.xml; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.codehaus.stax2.XMLEventReader2; +import org.codehaus.stax2.XMLStreamWriter2; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Element; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.URI; +import java.util.List; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import dev.metaschema.databind.codegen.AbstractMetaschemaTest; +import dev.metaschema.databind.model.IBoundDefinitionModelAssembly; +import dev.metaschema.databind.model.test.AnyAssembly; + +class AnyXmlRoundTripTest + extends AbstractMetaschemaTest { + private static final String NS = "https://csrc.nist.gov/ns/test/xml"; + private static final String FOREIGN_NS = "http://example.com/ns/foreign"; + + @Test + void testReadCapturesUnknownElements() throws IOException, XMLStreamException { + String xml = "" + + " hello" + + " foreign-value" + + ""; + + IBindingContext bindingContext = newBindingContext(); + IBoundDefinitionModelAssembly assembly + = ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + + XMLInputFactory factory = XMLInputFactory.newInstance(); + XMLEventReader2 eventReader = (XMLEventReader2) factory.createXMLEventReader(new StringReader(xml)); + + URI source = ObjectUtils.notNull(URI.create("https://example.com/test")); + MetaschemaXmlReader reader = new MetaschemaXmlReader(eventReader, source); + + AnyAssembly result = reader.read(assembly); + + // Known field should be parsed normally + assertEquals("hello", result.getKnownField()); + + // Any content should capture the foreign element + IAnyContent anyContent = result.getAny(); + assertNotNull(anyContent, "Any content should not be null"); + assertInstanceOf(XmlAnyContent.class, anyContent); + + XmlAnyContent xmlAny = (XmlAnyContent) anyContent; + List elements = xmlAny.getElements(); + assertEquals(1, elements.size(), "Should capture exactly one foreign element"); + + Element foreignElement = elements.get(0); + assertEquals("extra", foreignElement.getLocalName()); + assertEquals(FOREIGN_NS, foreignElement.getNamespaceURI()); + assertEquals("foreign-value", foreignElement.getTextContent()); + } + + @Test + void testReadWithNoUnknownElements() throws IOException, XMLStreamException { + String xml = "" + + " hello" + + ""; + + IBindingContext bindingContext = newBindingContext(); + IBoundDefinitionModelAssembly assembly + = ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + + XMLInputFactory factory = XMLInputFactory.newInstance(); + XMLEventReader2 eventReader = (XMLEventReader2) factory.createXMLEventReader(new StringReader(xml)); + + URI source = ObjectUtils.notNull(URI.create("https://example.com/test")); + MetaschemaXmlReader reader = new MetaschemaXmlReader(eventReader, source); + + AnyAssembly result = reader.read(assembly); + + // Known field should be parsed normally + assertEquals("hello", result.getKnownField()); + + // Any content should be null when there are no unknown elements + IAnyContent anyContent = result.getAny(); + assertEquals(null, anyContent, "Any content should be null when no unknown elements"); + } + + @Test + void testWriteSerializesAnyContent() throws IOException, XMLStreamException { + // Build an AnyAssembly with known field and any content + AnyAssembly obj = new AnyAssembly(); + obj.setKnownField("hello"); + + // Create a DOM element to use as foreign content + javax.xml.parsers.DocumentBuilderFactory dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + javax.xml.parsers.DocumentBuilder docBuilder; + try { + docBuilder = dbf.newDocumentBuilder(); + } catch (javax.xml.parsers.ParserConfigurationException ex) { + throw new IOException(ex); + } + org.w3c.dom.Document doc = docBuilder.newDocument(); + Element foreignEl = doc.createElementNS(FOREIGN_NS, "foreign:extra"); + foreignEl.setTextContent("foreign-value"); + obj.setAny(new XmlAnyContent(List.of(foreignEl))); + + IBindingContext bindingContext = newBindingContext(); + IBoundDefinitionModelAssembly assembly + = ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + + // Write to XML using writeRoot with namespace-repairing factory + StringWriter sw = new StringWriter(); + XMLOutputFactory outputFactory = XMLOutputFactory.newInstance(); + outputFactory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true); + XMLStreamWriter2 xmlWriter = (XMLStreamWriter2) outputFactory.createXMLStreamWriter(sw); + + xmlWriter.writeStartDocument("UTF-8", "1.0"); + MetaschemaXmlWriter writer = new MetaschemaXmlWriter(xmlWriter); + writer.writeRoot(assembly, obj); + xmlWriter.writeEndDocument(); + xmlWriter.close(); + + String xmlOutput = sw.toString(); + + // Verify the output contains the foreign element + assertNotNull(xmlOutput); + // The output should contain the foreign element + assertTrue(xmlOutput.contains("extra"), + "Output should contain foreign element 'extra': " + xmlOutput); + assertTrue(xmlOutput.contains("foreign-value"), + "Output should contain foreign element text: " + xmlOutput); + } + + @Test + void testRoundTrip() throws IOException, XMLStreamException { + String xml = "" + + " hello" + + " foreign-value" + + ""; + + IBindingContext bindingContext = newBindingContext(); + IBoundDefinitionModelAssembly assembly + = ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + + // Read + XMLInputFactory inputFactory = XMLInputFactory.newInstance(); + XMLEventReader2 eventReader = (XMLEventReader2) inputFactory.createXMLEventReader(new StringReader(xml)); + URI source = ObjectUtils.notNull(URI.create("https://example.com/test")); + MetaschemaXmlReader reader = new MetaschemaXmlReader(eventReader, source); + AnyAssembly result = reader.read(assembly); + + // Write back using writeRoot with namespace-repairing factory + StringWriter sw = new StringWriter(); + XMLOutputFactory outputFactory = XMLOutputFactory.newInstance(); + outputFactory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true); + XMLStreamWriter2 xmlWriter = (XMLStreamWriter2) outputFactory.createXMLStreamWriter(sw); + xmlWriter.writeStartDocument("UTF-8", "1.0"); + MetaschemaXmlWriter writer = new MetaschemaXmlWriter(xmlWriter); + writer.writeRoot(assembly, result); + xmlWriter.writeEndDocument(); + xmlWriter.close(); + + String xmlOutput = sw.toString(); + + // Re-read the output + XMLEventReader2 eventReader2 = (XMLEventReader2) inputFactory.createXMLEventReader(new StringReader(xmlOutput)); + MetaschemaXmlReader reader2 = new MetaschemaXmlReader(eventReader2, source); + AnyAssembly result2 = reader2.read(assembly); + + // Verify the round-trip preserved data + assertEquals("hello", result2.getKnownField()); + assertNotNull(result2.getAny()); + assertInstanceOf(XmlAnyContent.class, result2.getAny()); + + XmlAnyContent xmlAny = (XmlAnyContent) result2.getAny(); + assertEquals(1, xmlAny.getElements().size()); + assertEquals("extra", xmlAny.getElements().get(0).getLocalName()); + assertEquals(FOREIGN_NS, xmlAny.getElements().get(0).getNamespaceURI()); + assertEquals("foreign-value", xmlAny.getElements().get(0).getTextContent()); + assertEquals("val1", xmlAny.getElements().get(0).getAttribute("attr1")); + } + + @Test + void testReadMultipleUnknownElements() throws IOException, XMLStreamException { + String xml = "" + + " hello" + + " value1" + + " value2" + + ""; + + IBindingContext bindingContext = newBindingContext(); + IBoundDefinitionModelAssembly assembly + = ObjectUtils.requireNonNull( + (IBoundDefinitionModelAssembly) bindingContext.getBoundDefinitionForClass(AnyAssembly.class)); + + XMLInputFactory factory = XMLInputFactory.newInstance(); + XMLEventReader2 eventReader = (XMLEventReader2) factory.createXMLEventReader(new StringReader(xml)); + URI source = ObjectUtils.notNull(URI.create("https://example.com/test")); + MetaschemaXmlReader reader = new MetaschemaXmlReader(eventReader, source); + AnyAssembly result = reader.read(assembly); + + assertEquals("hello", result.getKnownField()); + assertNotNull(result.getAny()); + + XmlAnyContent xmlAny = (XmlAnyContent) result.getAny(); + assertEquals(2, xmlAny.getElements().size(), "Should capture two foreign elements"); + assertEquals("item1", xmlAny.getElements().get(0).getLocalName()); + assertEquals("item2", xmlAny.getElements().get(1).getLocalName()); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/io/xml/XmlAnyContentTest.java b/databind/src/test/java/dev/metaschema/databind/io/xml/XmlAnyContentTest.java new file mode 100644 index 0000000000..ab3f252612 --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/io/xml/XmlAnyContentTest.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.io.xml; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +class XmlAnyContentTest { + + private static Element createElement(String name) throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + Document doc = factory.newDocumentBuilder().newDocument(); + return doc.createElement(name); + } + + @Test + void testEmptyListIsEmpty() { + XmlAnyContent content = new XmlAnyContent(Collections.emptyList()); + assertTrue(content.isEmpty(), "isEmpty() should return true for empty list"); + } + + @Test + void testEmptyListGetElements() { + XmlAnyContent content = new XmlAnyContent(Collections.emptyList()); + assertTrue(content.getElements().isEmpty(), "getElements() should return empty list"); + } + + @Test + void testNonEmptyListIsNotEmpty() throws ParserConfigurationException { + Element elem = createElement("test"); + XmlAnyContent content = new XmlAnyContent(List.of(elem)); + assertFalse(content.isEmpty(), "isEmpty() should return false for non-empty list"); + } + + @Test + void testNonEmptyListGetElements() throws ParserConfigurationException { + Element elem1 = createElement("first"); + Element elem2 = createElement("second"); + List elements = List.of(elem1, elem2); + + XmlAnyContent content = new XmlAnyContent(elements); + List result = content.getElements(); + + assertEquals(2, result.size(), "getElements() should return all elements"); + assertEquals(elem1, result.get(0), "First element should match"); + assertEquals(elem2, result.get(1), "Second element should match"); + } + + @Test + void testReturnedListIsUnmodifiable() throws ParserConfigurationException { + Element elem = createElement("test"); + List mutableList = new ArrayList<>(); + mutableList.add(elem); + + XmlAnyContent content = new XmlAnyContent(mutableList); + List returned = content.getElements(); + + assertThrows(UnsupportedOperationException.class, () -> returned.add(createElement("extra")), + "Returned list should be unmodifiable"); + } + + @Test + void testDefensiveCopyFromMutableInput() throws ParserConfigurationException { + Element elem = createElement("original"); + List mutableList = new ArrayList<>(); + mutableList.add(elem); + + XmlAnyContent content = new XmlAnyContent(mutableList); + + // Modify the original list after construction + mutableList.add(createElement("added-after")); + + assertEquals(1, content.getElements().size(), + "XmlAnyContent should not be affected by changes to the original list"); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/model/metaschema/AnyInstanceLoadingTest.java b/databind/src/test/java/dev/metaschema/databind/model/metaschema/AnyInstanceLoadingTest.java new file mode 100644 index 0000000000..dd5482257f --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/model/metaschema/AnyInstanceLoadingTest.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.metaschema; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Paths; + +import dev.metaschema.core.model.IAssemblyDefinition; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.MetaschemaException; +import dev.metaschema.core.model.util.ModuleUtils; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.codegen.AbstractMetaschemaTest; +import dev.metaschema.databind.model.metaschema.impl.DefinitionAssemblyGlobal; + +class AnyInstanceLoadingTest + extends AbstractMetaschemaTest { + + @Test + void testAssemblyWithAnyInstanceIsLoaded() throws MetaschemaException, IOException { + IBindingModuleLoader loader = newBindingContext().newModuleLoader(); + loader.allowEntityResolution(); + + IModule module = loader.load(ObjectUtils.notNull( + Paths.get("src/test/resources/metaschema/any/metaschema.xml"))); + + Integer rootIndex = ModuleUtils.parseModelName(module, "root").getIndexPosition(); + IAssemblyDefinition rootDef = module.getScopedAssemblyDefinitionByName(rootIndex); + assertNotNull(rootDef, "root assembly definition should be found"); + + // Access the model container to verify the any instance was loaded + DefinitionAssemblyGlobal globalDef = assertInstanceOf(DefinitionAssemblyGlobal.class, rootDef); + assertNotNull(globalDef.getModelContainer().getAnyInstance(), + "any instance should not be null for assembly with "); + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/model/test/AnyAssembly.java b/databind/src/test/java/dev/metaschema/databind/model/test/AnyAssembly.java new file mode 100644 index 0000000000..d68a6bb989 --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/model/test/AnyAssembly.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.test; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IBoundObject; +import dev.metaschema.core.model.IMetaschemaData; +import dev.metaschema.databind.model.annotations.BoundAny; +import dev.metaschema.databind.model.annotations.BoundField; +import dev.metaschema.databind.model.annotations.MetaschemaAssembly; +import edu.umd.cs.findbugs.annotations.Nullable; + +@SuppressWarnings("PMD") +@MetaschemaAssembly(name = "any-assembly", rootName = "any-assembly", moduleClass = TestMetaschema.class) +public class AnyAssembly implements IBoundObject { + private final IMetaschemaData metaschemaData; + + @BoundField(useName = "known-field") + private String knownField; + + @BoundAny + @Nullable + private IAnyContent any; + + public AnyAssembly() { + this(null); + } + + public AnyAssembly(IMetaschemaData metaschemaData) { + this.metaschemaData = metaschemaData; + } + + @Override + public IMetaschemaData getMetaschemaData() { + return metaschemaData; + } + + public String getKnownField() { + return knownField; + } + + public void setKnownField(String knownField) { + this.knownField = knownField; + } + + @Nullable + public IAnyContent getAny() { + return any; + } + + public void setAny(@Nullable IAnyContent any) { + this.any = any; + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/model/test/AnyWithJsonKeyAssembly.java b/databind/src/test/java/dev/metaschema/databind/model/test/AnyWithJsonKeyAssembly.java new file mode 100644 index 0000000000..73d44ba423 --- /dev/null +++ b/databind/src/test/java/dev/metaschema/databind/model/test/AnyWithJsonKeyAssembly.java @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.databind.model.test; + +import java.util.Map; + +import dev.metaschema.core.model.IAnyContent; +import dev.metaschema.core.model.IBoundObject; +import dev.metaschema.core.model.IMetaschemaData; +import dev.metaschema.core.model.JsonGroupAsBehavior; +import dev.metaschema.core.model.XmlGroupAsBehavior; +import dev.metaschema.databind.model.annotations.BoundAny; +import dev.metaschema.databind.model.annotations.BoundField; +import dev.metaschema.databind.model.annotations.BoundFieldValue; +import dev.metaschema.databind.model.annotations.BoundFlag; +import dev.metaschema.databind.model.annotations.GroupAs; +import dev.metaschema.databind.model.annotations.JsonKey; +import dev.metaschema.databind.model.annotations.MetaschemaAssembly; +import dev.metaschema.databind.model.annotations.MetaschemaField; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * A test assembly combining {@code @BoundAny} with JSON-key-based fields to + * verify that json-key-matched properties are correctly resolved and not + * captured as "any" content. + */ +@SuppressWarnings("PMD") +@MetaschemaAssembly( + name = "any-json-key-assembly", + rootName = "any-json-key-assembly", + moduleClass = TestMetaschema.class) +public class AnyWithJsonKeyAssembly implements IBoundObject { + private final IMetaschemaData metaschemaData; + + @BoundField(useName = "known-field") + private String knownField; + + @BoundField( + maxOccurs = -1, + groupAs = @GroupAs(name = "keyed-fields", + inXml = XmlGroupAsBehavior.UNGROUPED, + inJson = JsonGroupAsBehavior.KEYED)) + private Map keyedField; + + @BoundAny + @Nullable + private IAnyContent any; + + /** + * Constructs a new instance with no Metaschema data. + */ + public AnyWithJsonKeyAssembly() { + this(null); + } + + /** + * Constructs a new instance with the specified Metaschema data. + * + * @param metaschemaData + * the Metaschema data associated with this instance, or {@code null} + */ + public AnyWithJsonKeyAssembly(@Nullable IMetaschemaData metaschemaData) { + this.metaschemaData = metaschemaData; + } + + @Override + public IMetaschemaData getMetaschemaData() { + return metaschemaData; + } + + /** + * Gets the known field value. + * + * @return the known field value, or {@code null} if not set + */ + @Nullable + public String getKnownField() { + return knownField; + } + + /** + * Sets the known field value. + * + * @param knownField + * the value to set, or {@code null} to clear + */ + public void setKnownField(@Nullable String knownField) { + this.knownField = knownField; + } + + /** + * Gets the keyed field map. + * + * @return the map of keyed fields, or {@code null} if not set + */ + @Nullable + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "this is a data holder") + public Map getKeyedField() { + return keyedField; + } + + /** + * Sets the keyed field map. + * + * @param keyedField + * the map to set, or {@code null} to clear + */ + public void setKeyedField(@Nullable Map keyedField) { + this.keyedField = keyedField; + } + + /** + * Gets the any content. + * + * @return the any content, or {@code null} if not set + */ + @Nullable + public IAnyContent getAny() { + return any; + } + + /** + * Sets the any content. + * + * @param any + * the any content to set, or {@code null} to clear + */ + public void setAny(@Nullable IAnyContent any) { + this.any = any; + } + + /** + * A simple field with a JSON key flag, used to test keyed field serialization. + */ + @SuppressWarnings("PMD") + @MetaschemaField( + name = "keyed-field", + moduleClass = TestMetaschema.class) + public static class KeyedField implements IBoundObject { + private final IMetaschemaData metaschemaData; + + @BoundFlag + @JsonKey + private String id; + + @BoundFieldValue + private String _value; + + /** + * Constructs a new instance with no Metaschema data. + */ + public KeyedField() { + this(null); + } + + /** + * Constructs a new instance with the specified Metaschema data. + * + * @param metaschemaData + * the Metaschema data associated with this instance, or {@code null} + */ + public KeyedField(@Nullable IMetaschemaData metaschemaData) { + this.metaschemaData = metaschemaData; + } + + @Override + public IMetaschemaData getMetaschemaData() { + return metaschemaData; + } + + /** + * Gets the key identifier. + * + * @return the key, or {@code null} if not set + */ + @Nullable + public String getId() { + return id; + } + + /** + * Sets the key identifier. + * + * @param id + * the key to set, or {@code null} to clear + */ + public void setId(@Nullable String id) { + this.id = id; + } + + /** + * Gets the field value. + * + * @return the field value, or {@code null} if not set + */ + @Nullable + public String getValue() { + return _value; + } + + /** + * Sets the field value. + * + * @param value + * the value to set, or {@code null} to clear + */ + public void setValue(@Nullable String value) { + this._value = value; + } + } +} diff --git a/databind/src/test/java/dev/metaschema/databind/model/test/TestMetaschema.java b/databind/src/test/java/dev/metaschema/databind/model/test/TestMetaschema.java index 657959e9e0..1a03381d1f 100644 --- a/databind/src/test/java/dev/metaschema/databind/model/test/TestMetaschema.java +++ b/databind/src/test/java/dev/metaschema/databind/model/test/TestMetaschema.java @@ -20,6 +20,8 @@ @MetaschemaModule( assemblies = { + AnyAssembly.class, + AnyWithJsonKeyAssembly.class, EmptyBoundAssembly.class, FlaggedBoundAssembly.class, OnlyModelBoundAssembly.class, diff --git a/databind/src/test/resources/metaschema/any/metaschema.xml b/databind/src/test/resources/metaschema/any/metaschema.xml new file mode 100644 index 0000000000..a5502599b0 --- /dev/null +++ b/databind/src/test/resources/metaschema/any/metaschema.xml @@ -0,0 +1,25 @@ + + + Any Test Module + 1.0 + any-test + http://example.com/ns/any-test + http://example.com/ns/any-test + + + Root Assembly + A test assembly that contains an any instance. + root + + + + + + + + Name + A name field. + + diff --git a/schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaHelper.java b/schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaHelper.java index a8e988b18d..7ae86c55aa 100644 --- a/schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaHelper.java +++ b/schemagen/src/main/java/dev/metaschema/schemagen/json/impl/JsonSchemaHelper.java @@ -31,6 +31,7 @@ import dev.metaschema.core.datatype.IDataTypeAdapter; import dev.metaschema.core.datatype.markup.MarkupLine; import dev.metaschema.core.datatype.markup.MarkupMultiline; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IChoiceInstance; import dev.metaschema.core.model.IContainerModelAbsolute; import dev.metaschema.core.model.IFlagInstance; @@ -276,22 +277,23 @@ public static List buildFlagProperties( /** * Builds a list of model property schemas for the given container definition. *

- * Choice instances are excluded from the returned list as they are handled - * separately. + * Choice instances and any instances are excluded from the returned list as + * they are handled separately. * * @param definition * the container model definition containing the model instances * @param state * the JSON generation state - * @return a list of model property schemas, excluding choice instances + * @return a list of model property schemas, excluding choice and any instances */ @NonNull public static List buildModelProperties( @NonNull IContainerModelAbsolute definition, @NonNull IJsonGenerationState state) { return ObjectUtils.notNull(definition.getModelInstances().stream() - // filter out choice instances, which will be handled separately + // filter out choice and any instances, which will be handled separately .filter(instance -> !(instance instanceof IChoiceInstance)) + .filter(instance -> !(instance instanceof IAnyInstance)) .map(instance -> state.getJsonSchemaPropertyModel(ObjectUtils.notNull(instance))) .collect(Collectors.toUnmodifiableList())); } @@ -393,6 +395,9 @@ public static void generateAssemblyBody( @NonNull IJsonGenerationState state) { node.put("type", "object"); + // when an any instance is present, additional properties are allowed + boolean hasAny = assembly.getDefinition().getAnyInstance() != null; + List availableChoices = assembly.getChoices(); if (availableChoices.size() == 1) { @@ -400,7 +405,7 @@ public static void generateAssemblyBody( availableChoices.iterator().next().getCombinations(), node, state); - node.put("additionalProperties", false); + node.put("additionalProperties", hasAny); } else if (availableChoices.size() > 1) { ArrayNode oneOf = node.putArray("anyOf"); availableChoices.forEach(choice -> { @@ -410,7 +415,7 @@ public static void generateAssemblyBody( choice.getCombinations(), schemaNode, state); - schemaNode.put("additionalProperties", false); + schemaNode.put("additionalProperties", hasAny); }); } } diff --git a/schemagen/src/main/java/dev/metaschema/schemagen/xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java b/schemagen/src/main/java/dev/metaschema/schemagen/xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java index 1a65870e77..78589e5da4 100644 --- a/schemagen/src/main/java/dev/metaschema/schemagen/xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java +++ b/schemagen/src/main/java/dev/metaschema/schemagen/xml/impl/schematype/XmlComplexTypeAssemblyDefinition.java @@ -11,6 +11,7 @@ import javax.xml.stream.XMLStreamException; import dev.metaschema.core.datatype.markup.MarkupDataTypeProvider; +import dev.metaschema.core.model.IAnyInstance; import dev.metaschema.core.model.IAssemblyDefinition; import dev.metaschema.core.model.IChoiceGroupInstance; import dev.metaschema.core.model.IChoiceInstance; @@ -58,13 +59,24 @@ protected void generateTypeBody(IXmlGenerationState state) throws XMLStreamExcep IAssemblyDefinition definition = getDefinition(); Collection modelInstances = definition.getModelInstances(); - if (!modelInstances.isEmpty()) { + IAnyInstance anyInstance = definition.getAnyInstance(); + + boolean hasModelContent = !modelInstances.isEmpty(); + boolean hasAny = anyInstance != null; + + if (hasModelContent || hasAny) { state.writeStartElement(XmlDatatypeManager.PREFIX_XML_SCHEMA, "sequence", XmlDatatypeManager.NS_XML_SCHEMA); + for (IModelInstanceAbsolute modelInstance : modelInstances) { assert modelInstance != null; generateModelInstance(modelInstance, state); } - state.writeEndElement(); + + if (hasAny) { + generateAnyInstance(state); + } + + state.writeEndElement(); // xs:sequence } Collection flagInstances = definition.getFlagInstances(); @@ -76,6 +88,28 @@ protected void generateTypeBody(IXmlGenerationState state) throws XMLStreamExcep } } + /** + * Generate an {@code xs:any} wildcard element for an any instance. + *

+ * Emits {@code } to allow unmodeled content from other + * namespaces within the assembly. + * + * @param state + * the schema generation state for writing output + * @throws XMLStreamException + * if an error occurs while writing XML + */ + protected static void generateAnyInstance( + @NonNull IXmlGenerationState state) throws XMLStreamException { + state.writeStartElement(XmlDatatypeManager.PREFIX_XML_SCHEMA, "any", XmlDatatypeManager.NS_XML_SCHEMA); + state.writeAttribute("namespace", "##other"); + state.writeAttribute("processContents", "lax"); + state.writeAttribute("minOccurs", "0"); + state.writeAttribute("maxOccurs", "unbounded"); + state.writeEndElement(); // xs:any + } + /** * Generate XML Schema elements for a model instance. *

diff --git a/schemagen/src/test/java/dev/metaschema/schemagen/json/impl/AnyJsonSchemaGenerationTest.java b/schemagen/src/test/java/dev/metaschema/schemagen/json/impl/AnyJsonSchemaGenerationTest.java new file mode 100644 index 0000000000..da9c8e4f0c --- /dev/null +++ b/schemagen/src/test/java/dev/metaschema/schemagen/json/impl/AnyJsonSchemaGenerationTest.java @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.schemagen.json.impl; + +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 com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import dev.metaschema.core.configuration.DefaultConfiguration; +import dev.metaschema.core.configuration.IMutableConfiguration; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.MetaschemaException; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import dev.metaschema.databind.model.metaschema.IBindingModuleLoader; +import dev.metaschema.schemagen.ISchemaGenerator; +import dev.metaschema.schemagen.SchemaGenerationFeature; +import dev.metaschema.schemagen.json.JsonSchemaGenerator; + +class AnyJsonSchemaGenerationTest { + + private static final Path METASCHEMA_FILE + = ObjectUtils.notNull(Paths.get("src/test/resources/metaschema/any-test_metaschema.xml")); + + private static IBindingContext getBindingContext() throws IOException { + return IBindingContext.builder() + .compilePath(ObjectUtils.notNull(Files.createTempDirectory(Paths.get("target"), "modules-"))) + .build(); + } + + @Test + void testAnyAssemblyHasAdditionalPropertiesTrue() throws MetaschemaException, IOException { + IBindingContext bindingContext = getBindingContext(); + IBindingModuleLoader loader = bindingContext.newModuleLoader(); + loader.allowEntityResolution(); + + IModule module = loader.load(METASCHEMA_FILE); + + IMutableConfiguration> features + = new DefaultConfiguration<>(); + features.disableFeature(SchemaGenerationFeature.INLINE_DEFINITIONS); + + ISchemaGenerator schemaGenerator = new JsonSchemaGenerator(); + StringWriter writer = new StringWriter(); + schemaGenerator.generateFromModule(module, writer, features); + + String schemaJson = writer.toString(); + assertNotNull(schemaJson, "Generated schema should not be null"); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode schema = mapper.readTree(schemaJson); + + // The root assembly 'root' should be in the definitions + JsonNode definitions = schema.get("definitions"); + assertNotNull(definitions, "Schema should have definitions"); + + // Find the root assembly definition (named "AssemblyAnyTestRootType") + JsonNode rootDef = null; + var fieldNames = definitions.fieldNames(); + while (fieldNames.hasNext()) { + String name = fieldNames.next(); + if (name.contains("Root")) { + rootDef = definitions.get(name); + break; + } + } + assertNotNull(rootDef, "Should find 'root' assembly definition"); + + // The root assembly has , so additionalProperties should be true + JsonNode additionalProperties = rootDef.get("additionalProperties"); + assertNotNull(additionalProperties, + "Root assembly with should have additionalProperties defined. Schema: " + rootDef); + assertTrue(additionalProperties.isBoolean(), + "additionalProperties should be a boolean value"); + assertEquals(true, additionalProperties.booleanValue(), + "additionalProperties should be true for assembly with "); + } + + @Test + void testAssemblyWithoutAnyHasAdditionalPropertiesFalse() throws MetaschemaException, IOException { + IBindingContext bindingContext = getBindingContext(); + IBindingModuleLoader loader = bindingContext.newModuleLoader(); + loader.allowEntityResolution(); + + // Use the metaschema-module-metaschema as an example of an assembly without + // + Path metaschemaFile = ObjectUtils.notNull( + Paths.get("../core/metaschema/schema/metaschema/metaschema-module-metaschema.xml")); + IModule module = loader.load(metaschemaFile); + + IMutableConfiguration> features + = new DefaultConfiguration<>(); + features.disableFeature(SchemaGenerationFeature.INLINE_DEFINITIONS); + + ISchemaGenerator schemaGenerator = new JsonSchemaGenerator(); + StringWriter writer = new StringWriter(); + schemaGenerator.generateFromModule(module, writer, features); + + String schemaJson = writer.toString(); + ObjectMapper mapper = new ObjectMapper(); + JsonNode schema = mapper.readTree(schemaJson); + + JsonNode definitions = schema.get("definitions"); + assertNotNull(definitions, "Schema should have definitions"); + + // Check that at least one definition has additionalProperties: false + // (meaning assemblies without retain the existing behavior) + var fieldNames = definitions.fieldNames(); + boolean foundFalse = false; + while (fieldNames.hasNext()) { + String name = fieldNames.next(); + JsonNode def = definitions.get(name); + JsonNode additionalProperties = def.get("additionalProperties"); + if (additionalProperties != null && additionalProperties.isBoolean() + && !additionalProperties.booleanValue()) { + foundFalse = true; + break; + } + } + assertTrue(foundFalse, + "At least one assembly without should have additionalProperties: false"); + } +} diff --git a/schemagen/src/test/java/dev/metaschema/schemagen/xml/AnyXmlSchemaGenerationTest.java b/schemagen/src/test/java/dev/metaschema/schemagen/xml/AnyXmlSchemaGenerationTest.java new file mode 100644 index 0000000000..23b9b802a2 --- /dev/null +++ b/schemagen/src/test/java/dev/metaschema/schemagen/xml/AnyXmlSchemaGenerationTest.java @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.schemagen.xml; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import dev.metaschema.core.configuration.DefaultConfiguration; +import dev.metaschema.core.configuration.IMutableConfiguration; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.MetaschemaException; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import dev.metaschema.databind.model.metaschema.IBindingModuleLoader; +import dev.metaschema.schemagen.SchemaGenerationFeature; + +class AnyXmlSchemaGenerationTest { + + private static final Path ANY_METASCHEMA + = ObjectUtils.notNull(Paths.get("src/test/resources/metaschema/any-test_metaschema.xml")); + + private static final Path NO_ANY_METASCHEMA + = ObjectUtils.notNull(Paths.get("src/test/resources/metaschema/no-any-test_metaschema.xml")); + + private String generateXmlSchema(Path metaschemaPath) throws MetaschemaException, IOException { + IBindingContext bindingContext = IBindingContext.builder() + .compilePath(ObjectUtils.notNull(Files.createTempDirectory(Paths.get("target"), "modules-"))) + .build(); + IBindingModuleLoader loader = bindingContext.newModuleLoader(); + loader.allowEntityResolution(); + + IModule module = loader.load(metaschemaPath); + + IMutableConfiguration> features + = new DefaultConfiguration<>(); + features.disableFeature(SchemaGenerationFeature.INLINE_DEFINITIONS); + + StringWriter writer = new StringWriter(); + XmlSchemaGenerator schemaGenerator = new XmlSchemaGenerator(); + schemaGenerator.generateFromModule(module, writer, features); + + return writer.toString(); + } + + @Test + void testAnyGeneratesXsAny() throws MetaschemaException, IOException { + String schema = generateXmlSchema(ANY_METASCHEMA); + + // The generated XSD should contain an xs:any element with the correct + // attributes + assertTrue(schema.contains("xs:any"), + "Generated XML Schema should contain xs:any element for in assembly model.\n" + + "Actual schema:\n" + schema); + assertTrue(schema.contains("namespace=\"##other\""), + "xs:any element should have namespace=\"##other\" attribute.\n" + + "Actual schema:\n" + schema); + assertTrue(schema.contains("processContents=\"lax\""), + "xs:any element should have processContents=\"lax\" attribute.\n" + + "Actual schema:\n" + schema); + } + + @Test + void testAssemblyWithoutAnyDoesNotGenerateXsAny() throws MetaschemaException, IOException { + String schema = generateXmlSchema(NO_ANY_METASCHEMA); + + // A schema without should NOT contain xs:any + assertFalse(schema.contains("xs:any"), + "Generated XML Schema should NOT contain xs:any element when assembly has no .\n" + + "Actual schema:\n" + schema); + } +} diff --git a/schemagen/src/test/resources/metaschema/any-test_metaschema.xml b/schemagen/src/test/resources/metaschema/any-test_metaschema.xml new file mode 100644 index 0000000000..2a2fcb28b7 --- /dev/null +++ b/schemagen/src/test/resources/metaschema/any-test_metaschema.xml @@ -0,0 +1,25 @@ + + + Any Test Module + 1.0 + any-test + http://example.com/ns/any-test + http://example.com/ns/any-test + + + Root Assembly + A test assembly that contains an any instance. + root + + + + + + + + Name + A name field. + + diff --git a/schemagen/src/test/resources/metaschema/no-any-test_metaschema.xml b/schemagen/src/test/resources/metaschema/no-any-test_metaschema.xml new file mode 100644 index 0000000000..70c29c8aa1 --- /dev/null +++ b/schemagen/src/test/resources/metaschema/no-any-test_metaschema.xml @@ -0,0 +1,24 @@ + + + No Any Test Module + 1.0 + no-any-test + http://example.com/ns/no-any-test + http://example.com/ns/no-any-test + + + Root Assembly + A test assembly without an any instance. + root + + + + + + + Name + A name field. + +