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. + * + */ + 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). + + + + +