diff --git a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/XsdGenerator.java b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/XsdGenerator.java
index e54a596f0..90d72b2c4 100644
--- a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/XsdGenerator.java
+++ b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/XsdGenerator.java
@@ -41,6 +41,7 @@
import org.codehaus.modello.model.ModelClass;
import org.codehaus.modello.model.ModelField;
import org.codehaus.modello.plugin.xsd.metadata.XsdClassMetadata;
+import org.codehaus.modello.plugin.xsd.metadata.XsdFieldMetadata;
import org.codehaus.modello.plugins.xml.AbstractXmlGenerator;
import org.codehaus.modello.plugins.xml.metadata.XmlAssociationMetadata;
import org.codehaus.modello.plugins.xml.metadata.XmlFieldMetadata;
@@ -261,7 +262,10 @@ private void writeComplexTypeDescriptor(
} else if ((xsdType != null) || "char".equals(field.getType()) || "Character".equals(field.getType())) {
w.addAttribute("name", fieldTagName);
- if (xsdType != null) {
+ XsdFieldMetadata xsdFieldMetadata = (XsdFieldMetadata) field.getMetadata(XsdFieldMetadata.ID);
+ boolean hasConstraint = hasXsdConstraint(xsdFieldMetadata);
+
+ if (xsdType != null && !hasConstraint) {
// schema built-in datatype
w.addAttribute("type", xsdType);
}
@@ -277,6 +281,8 @@ private void writeComplexTypeDescriptor(
if (xsdType == null) {
writeCharElement(w);
+ } else if (hasConstraint) {
+ writeConstrainedStringElement(w, xsdFieldMetadata);
}
} else {
// TODO cleanup/split this part it's no really human readable :-)
@@ -364,7 +370,10 @@ private void writeComplexTypeDescriptor(
w.addAttribute("name", tagName);
- if (xsdType != null) {
+ XsdFieldMetadata xsdFieldMetadata = (XsdFieldMetadata) field.getMetadata(XsdFieldMetadata.ID);
+ boolean hasConstraint = hasXsdConstraint(xsdFieldMetadata);
+
+ if (xsdType != null && !hasConstraint) {
w.addAttribute("type", xsdType);
}
@@ -378,6 +387,8 @@ private void writeComplexTypeDescriptor(
if ("char".equals(field.getType()) || "Character".equals(field.getType())) {
writeCharElement(w);
+ } else if (hasConstraint) {
+ writeConstrainedStringElement(w, xsdFieldMetadata);
} else if (xsdType == null) {
throw new IllegalStateException("Attribute field of a non-primitive type '" + field.getType()
+ "' for '" + field.getName() + "' in '" + modelClass.getName() + "' model class");
@@ -488,6 +499,74 @@ private void writeListElement(
w.endElement();
}
+ private static boolean hasXsdConstraint(XsdFieldMetadata xsdFieldMetadata) {
+ return !xsdFieldMetadata.getEnumerations().isEmpty()
+ || !xsdFieldMetadata.getSuggestions().isEmpty()
+ || (xsdFieldMetadata.getPattern() != null
+ && !xsdFieldMetadata.getPattern().isEmpty());
+ }
+
+ /**
+ * Writes an inline {@code xs:simpleType} for a constrained String field.
+ *
+ * - If {@link XsdFieldMetadata#getEnumerations()} is non-empty, generates a strict
+ * {@code xs:restriction} with one {@code xs:enumeration} per value.
+ * - If {@link XsdFieldMetadata#getSuggestions()} is non-empty, generates an
+ * {@code xs:union} of a fixed enumeration and plain {@code xs:string}, giving
+ * IDE autocompletion without schema-level rejection of unknown values.
+ * - If {@link XsdFieldMetadata#getPattern()} is set, generates a
+ * {@code xs:restriction base="xs:string"} with an {@code xs:pattern} facet.
+ *
+ */
+ private static void writeConstrainedStringElement(XMLWriter w, XsdFieldMetadata xsdFieldMetadata) {
+ if (!xsdFieldMetadata.getEnumerations().isEmpty()) {
+ // Strict enumeration: only the listed values are schema-valid.
+ w.startElement("xs:simpleType");
+ w.startElement("xs:restriction");
+ w.addAttribute("base", "xs:string");
+ for (String value : xsdFieldMetadata.getEnumerations()) {
+ w.startElement("xs:enumeration");
+ w.addAttribute("value", value);
+ w.endElement();
+ }
+ w.endElement(); // xs:restriction
+ w.endElement(); // xs:simpleType
+ } else if (!xsdFieldMetadata.getSuggestions().isEmpty()) {
+ // Non-strict union: IDE offers completion while still accepting any custom value.
+ w.startElement("xs:simpleType");
+ w.startElement("xs:union");
+ // memberTypes must reference named types — use an anonymous inner simpleType instead
+ w.startElement("xs:simpleType");
+ w.startElement("xs:restriction");
+ w.addAttribute("base", "xs:string");
+ for (String value : xsdFieldMetadata.getSuggestions()) {
+ w.startElement("xs:enumeration");
+ w.addAttribute("value", value);
+ w.endElement();
+ }
+ w.endElement(); // xs:restriction
+ w.endElement(); // xs:simpleType (enumeration member)
+ w.startElement("xs:simpleType");
+ w.startElement("xs:restriction");
+ w.addAttribute("base", "xs:string");
+ w.endElement(); // xs:restriction
+ w.endElement(); // xs:simpleType (xs:string member)
+ w.endElement(); // xs:union
+ w.endElement(); // xs:simpleType
+ } else if (xsdFieldMetadata.getPattern() != null
+ && !xsdFieldMetadata.getPattern().isEmpty()) {
+ // Pattern restriction.
+ w.startElement("xs:simpleType");
+ w.startElement("xs:restriction");
+ w.addAttribute("base", "xs:string");
+ w.startElement("xs:pattern");
+ w.addAttribute("value", xsdFieldMetadata.getPattern());
+ w.endElement(); // xs:pattern
+ w.endElement(); // xs:restriction
+ w.endElement(); // xs:simpleType
+ }
+ }
+
private static String getXsdType(String type) {
if ("String".equals(type)) {
return "xs:string";
diff --git a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdFieldMetadata.java b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdFieldMetadata.java
index 90fc76e7e..92490d084 100644
--- a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdFieldMetadata.java
+++ b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdFieldMetadata.java
@@ -16,6 +16,9 @@
* limitations under the License.
*/
+import java.util.Collections;
+import java.util.List;
+
import org.codehaus.modello.metadata.FieldMetadata;
/**
@@ -25,4 +28,84 @@
*/
public class XsdFieldMetadata implements FieldMetadata {
public static final String ID = XsdFieldMetadata.class.getName();
+
+ /**
+ * Comma-separated allowed values for a strict {@code xs:restriction/xs:enumeration} constraint.
+ * When set, the generated XSD uses a {@code simpleType} with {@code restriction base="xs:string"}
+ * containing one {@code xs:enumeration} element per value. Instance documents that contain values
+ * outside this list are schema-invalid.
+ *
+ * Mutually exclusive with {@link #suggestions} and {@link #pattern}.
+ *
+ *
+ * Modello syntax:
+ *
{@code
+ *
+ * scope
+ * String
+ *
+ * }
+ */
+ private List enumerations = Collections.emptyList();
+
+ /**
+ * Comma-separated suggested values for a non-strict {@code xs:union} completion hint.
+ * When set, the generated XSD uses a {@code simpleType} with {@code union} of a fixed
+ * enumeration and {@code xs:string}, so IDEs offer autocompletion while still accepting
+ * any custom value.
+ *
+ * Mutually exclusive with {@link #enumerations} and {@link #pattern}.
+ *
+ *
+ * Modello syntax:
+ *
{@code
+ *
+ * packaging
+ * String
+ *
+ * }
+ */
+ private List suggestions = Collections.emptyList();
+
+ /**
+ * A regex pattern for a {@code xs:restriction/xs:pattern} constraint.
+ * When set, the generated XSD uses a {@code simpleType} with {@code restriction base="xs:string"}
+ * containing an {@code xs:pattern} element.
+ *
+ * Mutually exclusive with {@link #enumerations} and {@link #suggestions}.
+ *
+ *
+ * Modello syntax:
+ *
{@code
+ *
+ * groupId
+ * String
+ *
+ * }
+ */
+ private String pattern;
+
+ public List getEnumerations() {
+ return Collections.unmodifiableList(enumerations);
+ }
+
+ public void setEnumerations(List enumerations) {
+ this.enumerations = enumerations != null ? enumerations : Collections.emptyList();
+ }
+
+ public List getSuggestions() {
+ return Collections.unmodifiableList(suggestions);
+ }
+
+ public void setSuggestions(List suggestions) {
+ this.suggestions = suggestions != null ? suggestions : Collections.emptyList();
+ }
+
+ public String getPattern() {
+ return pattern;
+ }
+
+ public void setPattern(String pattern) {
+ this.pattern = pattern;
+ }
}
diff --git a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdMetadataPlugin.java b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdMetadataPlugin.java
index 7ffaec06f..ebdf59a21 100644
--- a/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdMetadataPlugin.java
+++ b/modello-plugins/modello-plugin-xsd/src/main/java/org/codehaus/modello/plugin/xsd/metadata/XsdMetadataPlugin.java
@@ -19,7 +19,11 @@
import javax.inject.Named;
import javax.inject.Singleton;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import org.codehaus.modello.ModelloException;
import org.codehaus.modello.metadata.AbstractMetadataPlugin;
@@ -48,6 +52,12 @@ public class XsdMetadataPlugin extends AbstractMetadataPlugin {
public static final String COMPOSITOR = "xsd.compositor";
+ public static final String ENUMERATIONS = "xsd.enumerations";
+
+ public static final String SUGGESTIONS = "xsd.suggestions";
+
+ public static final String PATTERN = "xsd.pattern";
+
public AssociationMetadata getAssociationMetadata(ModelAssociation association, Map data)
throws ModelloException {
return new XsdAssociationMetadata();
@@ -67,7 +77,27 @@ public InterfaceMetadata getInterfaceMetadata(ModelInterface iface, Map data) throws ModelloException {
- return new XsdFieldMetadata();
+ XsdFieldMetadata metadata = new XsdFieldMetadata();
+
+ List enumerations = parseCommaSeparated(getString(data, ENUMERATIONS));
+ List suggestions = parseCommaSeparated(getString(data, SUGGESTIONS));
+ String pattern = getString(data, PATTERN);
+
+ long constraintCount = (enumerations.isEmpty() ? 0 : 1)
+ + (suggestions.isEmpty() ? 0 : 1)
+ + (pattern != null && !pattern.isEmpty() ? 1 : 0);
+ if (constraintCount > 1) {
+ throw new ModelloException("Field '"
+ + field.getName()
+ + "': xsd.enumerations, xsd.suggestions and xsd.pattern are mutually exclusive."
+ + " Only one may be specified per field.");
+ }
+
+ metadata.setEnumerations(enumerations);
+ metadata.setSuggestions(suggestions);
+ metadata.setPattern(pattern);
+
+ return metadata;
}
public ModelMetadata getModelMetadata(Model model, Map data) throws ModelloException {
@@ -79,4 +109,14 @@ public ModelMetadata getModelMetadata(Model model, Map data) thr
return metadata;
}
+
+ private static List parseCommaSeparated(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+ return Arrays.stream(value.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+ }
}
diff --git a/modello-plugins/modello-plugin-xsd/src/test/java/org/codehaus/modello/plugin/xsd/EnumerationXsdGeneratorTest.java b/modello-plugins/modello-plugin-xsd/src/test/java/org/codehaus/modello/plugin/xsd/EnumerationXsdGeneratorTest.java
new file mode 100644
index 000000000..e74892856
--- /dev/null
+++ b/modello-plugins/modello-plugin-xsd/src/test/java/org/codehaus/modello/plugin/xsd/EnumerationXsdGeneratorTest.java
@@ -0,0 +1,175 @@
+package org.codehaus.modello.plugin.xsd;
+
+/*
+ * Copyright (c) 2024, Codehaus.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import javax.inject.Inject;
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.stream.StreamSource;
+import javax.xml.validation.Schema;
+import javax.xml.validation.SchemaFactory;
+import javax.xml.validation.Validator;
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathFactory;
+
+import java.io.File;
+import java.io.StringReader;
+import java.util.Map;
+
+import org.codehaus.modello.AbstractModelloGeneratorTest;
+import org.codehaus.modello.core.ModelloCore;
+import org.codehaus.modello.model.Model;
+import org.codehaus.plexus.testing.PlexusTest;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXParseException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Tests that {@code xsd.enumerations}, {@code xsd.suggestions} and {@code xsd.pattern} field
+ * metadata attributes are correctly translated to XSD facets by {@link XsdGenerator}.
+ *
+ * @see issue 633
+ */
+@PlexusTest
+public class EnumerationXsdGeneratorTest extends AbstractModelloGeneratorTest {
+
+ @Inject
+ private ModelloCore modello;
+
+ public EnumerationXsdGeneratorTest() {
+ super("xsd-enum-test");
+ }
+
+ @Test
+ public void testEnumerationConstraints() throws Throwable {
+ Model model = modello.loadModel(getXmlResourceReader("/xsd-enum-test.mdo"));
+
+ Map parameters = getModelloParameters("1.0.0");
+ modello.generate(model, "xsd", parameters);
+
+ File xsdFile = new File(getOutputDirectory(), "xsd-enum-test-1.0.0.xsd");
+ assertTrue(xsdFile.exists(), "Generated XSD file should exist: " + xsdFile);
+
+ // ---- Structural assertions on the generated XSD ----
+
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ dbf.setNamespaceAware(true);
+ Document doc = dbf.newDocumentBuilder().parse(xsdFile);
+ XPath xpath = XPathFactory.newInstance().newXPath();
+
+ // 1. scope must have a xs:restriction with xs:enumeration children
+ NodeList scopeEnums = (NodeList) xpath.evaluate(
+ "//*[local-name()='element' and @name='scope']"
+ + "//*[local-name()='restriction' and @base='xs:string']"
+ + "/*[local-name()='enumeration']",
+ doc,
+ XPathConstants.NODESET);
+ assertEquals(5, scopeEnums.getLength(), "scope should have 5 strict enumerations");
+
+ // 2. packaging must have a xs:union containing xs:enumeration members
+ NodeList packagingEnums = (NodeList) xpath.evaluate(
+ "//*[local-name()='element' and @name='packaging']"
+ + "//*[local-name()='union']"
+ + "//*[local-name()='enumeration']",
+ doc,
+ XPathConstants.NODESET);
+ assertEquals(5, packagingEnums.getLength(), "packaging should have 5 enumeration suggestions inside a union");
+
+ // 3. artifactId must have a xs:pattern facet
+ NodeList patternNodes = (NodeList) xpath.evaluate(
+ "//*[local-name()='element' and @name='artifactId']" + "//*[local-name()='pattern']",
+ doc,
+ XPathConstants.NODESET);
+ assertEquals(1, patternNodes.getLength(), "artifactId should have exactly one xs:pattern facet");
+ assertEquals(
+ "[a-zA-Z0-9._\\-]+",
+ patternNodes.item(0).getAttributes().getNamedItem("value").getTextContent(),
+ "artifactId pattern value should match");
+
+ // 4. description must still be plain xs:string (no inline simpleType)
+ NodeList descType = (NodeList) xpath.evaluate(
+ "//*[local-name()='element' and @name='description' and @type='xs:string']",
+ doc,
+ XPathConstants.NODESET);
+ assertEquals(1, descType.getLength(), "description should remain a plain xs:string element");
+
+ // 5. attributeScope (xml.attribute="true") must have xs:restriction with 2 xs:enumeration children
+ // — exercises the attribute-field loop in XsdGenerator
+ NodeList attributeScopeEnums = (NodeList) xpath.evaluate(
+ "//*[local-name()='attribute' and @name='attributeScope']"
+ + "//*[local-name()='restriction' and @base='xs:string']"
+ + "/*[local-name()='enumeration']",
+ doc,
+ XPathConstants.NODESET);
+ assertEquals(
+ 2,
+ attributeScopeEnums.getLength(),
+ "attributeScope attribute should have 2 strict enumerations (compile, runtime)");
+
+ // ---- Validation assertions ----
+
+ SchemaFactory sf = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+ Schema schema = sf.newSchema(new StreamSource(xsdFile));
+ Validator validator = schema.newValidator();
+
+ // Valid document passes
+ validator.validate(new StreamSource(getClass().getResourceAsStream("/xsd-enum-test-valid.xml")));
+
+ // Invalid scope (strict enumeration) must be rejected
+ try {
+ validator.validate(new StreamSource(getClass().getResourceAsStream("/xsd-enum-test-invalid-scope.xml")));
+ fail("Validation should have failed for invalid scope value");
+ } catch (SAXParseException e) {
+ // expected
+ assertTrue(
+ e.getMessage().contains("scope") || e.getMessage().contains("unknown-scope"),
+ "Error should mention the invalid scope value: " + e.getMessage());
+ }
+
+ // Invalid artifactId (pattern violation) must be rejected
+ try {
+ validator.validate(new StreamSource(getClass().getResourceAsStream("/xsd-enum-test-invalid-pattern.xml")));
+ fail("Validation should have failed for invalid artifactId pattern");
+ } catch (SAXParseException e) {
+ // expected
+ assertTrue(
+ e.getMessage().contains("artifactId") || e.getMessage().contains("pattern"),
+ "Error should mention the invalid artifactId value: " + e.getMessage());
+ }
+
+ // Suggested value outside the enumeration (custom packaging type) must PASS validation
+ String customPackagingXml = ""
+ + ""
+ + "test"
+ + "custom-type"
+ + "my-lib"
+ + "";
+ validator.validate(new StreamSource(new StringReader(customPackagingXml)));
+ }
+}
diff --git a/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-pattern.xml b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-pattern.xml
new file mode 100644
index 000000000..4e7a090af
--- /dev/null
+++ b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-pattern.xml
@@ -0,0 +1,6 @@
+
+
+
+ compile
+ my artifact!
+
diff --git a/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-scope.xml b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-scope.xml
new file mode 100644
index 000000000..6b7b573b1
--- /dev/null
+++ b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-invalid-scope.xml
@@ -0,0 +1,7 @@
+
+
+
+ unknown-scope
+ jar
+ my-artifact
+
diff --git a/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-valid.xml b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-valid.xml
new file mode 100644
index 000000000..613a3476b
--- /dev/null
+++ b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test-valid.xml
@@ -0,0 +1,7 @@
+
+
+ compile
+ jar
+ my-artifact
+ A valid artifact.
+
diff --git a/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test.mdo b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test.mdo
new file mode 100644
index 000000000..b430a494b
--- /dev/null
+++ b/modello-plugins/modello-plugin-xsd/src/test/resources/xsd-enum-test.mdo
@@ -0,0 +1,76 @@
+
+
+ xsd-enum-test
+ XsdEnumTest
+
+ Model used to test xs:enumeration / xs:union / xs:pattern support in the XSD generator.
+
+
+
+ package
+ org.codehaus.modello.test.xsdenumtest
+
+
+
+
+ Artifact
+ 1.0.0+
+ A simple artifact descriptor.
+
+
+
+ scope
+ 1.0.0+
+ String
+ The dependency scope (strict).
+
+
+
+ packaging
+ 1.0.0+
+ String
+ The packaging type (suggested values).
+
+
+
+ artifactId
+ 1.0.0+
+ String
+ The artifact identifier (pattern-validated).
+
+
+
+ description
+ 1.0.0+
+ String
+ A free-form description.
+
+
+
+ attributeScope
+ 1.0.0+
+ String
+ Attribute-based scope (strict enumeration, attribute form).
+
+
+
+
+