diff --git a/core/src/main/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitor.java b/core/src/main/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitor.java new file mode 100644 index 000000000..93a50cc67 --- /dev/null +++ b/core/src/main/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitor.java @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.core.metapath.item.node; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import dev.metaschema.core.metapath.DynamicContext; +import dev.metaschema.core.metapath.StaticContext; +import dev.metaschema.core.metapath.item.ISequence; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.constraint.IAllowedValuesConstraint; +import dev.metaschema.core.model.constraint.ILet; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A visitor that traverses a Metaschema module's node items and collects all + * allowed-values constraints, organized by the target node they apply to. + *

+ * This visitor extends {@link AbstractRecursionPreventingNodeItemVisitor} to + * safely handle recursive assembly definitions without infinite looping. + *

+ * Usage example: + * + *

+ * AllowedValueCollectingNodeItemVisitor visitor
+ *     = new AllowedValueCollectingNodeItemVisitor();
+ * visitor.visit(module);
+ * Collection<NodeItemRecord> locations = visitor.getAllowedValueLocations();
+ * 
+ */ +public class AllowedValueCollectingNodeItemVisitor + extends AbstractRecursionPreventingNodeItemVisitor { + + @NonNull + private final Map, NodeItemRecord> nodeItemAnalysis = new LinkedHashMap<>(); + + /** + * Get the collected allowed-values constraint locations found during + * visitation. + * + * @return a collection of records, each containing a definition node item and + * the allowed-values constraints that target it + */ + @NonNull + public Collection getAllowedValueLocations() { + return nodeItemAnalysis.values(); + } + + /** + * Visit all definitions in the provided module to collect allowed-values + * constraints. + *

+ * This method creates a new {@link DynamicContext} configured with the module's + * default namespace and with predicate evaluation disabled. + * + * @param module + * the Metaschema module to visit + */ + public void visit(@NonNull IModule module) { + DynamicContext context = new DynamicContext( + StaticContext.builder() + .defaultModelNamespace(module.getXmlNamespace()) + .build()); + context.disablePredicateEvaluation(); + + visit(INodeItemFactory.instance().newModuleNodeItem(module), context); + } + + /** + * Visit all definitions in the provided module node item using the given + * dynamic context. + * + * @param module + * the module node item to visit + * @param context + * the dynamic context to use for constraint evaluation + */ + public void visit(@NonNull IModuleNodeItem module, @NonNull DynamicContext context) { + visitMetaschema(module, context); + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private void handleAllowedValuesAtLocation( + @NonNull IDefinitionNodeItem itemLocation, + @NonNull DynamicContext context) { + itemLocation.getDefinition().getAllowedValuesConstraints().stream() + .forEachOrdered(allowedValues -> { + ISequence result = allowedValues.getTarget().evaluate(itemLocation, context); + result.stream().forEachOrdered(target -> { + assert target != null; + handleAllowedValues(allowedValues, itemLocation, (IDefinitionNodeItem) target); + }); + }); + } + + private void handleAllowedValues( + @NonNull IAllowedValuesConstraint allowedValues, + @NonNull IDefinitionNodeItem location, + @NonNull IDefinitionNodeItem target) { + NodeItemRecord itemRecord = nodeItemAnalysis.get(target); + if (itemRecord == null) { + itemRecord = new NodeItemRecord(target); + nodeItemAnalysis.put(target, itemRecord); + } + + AllowedValuesRecord allowedValuesRecord = new AllowedValuesRecord(allowedValues, location, target); + itemRecord.addAllowedValues(allowedValuesRecord); + } + + @Override + public Void visitFlag(IFlagNodeItem item, DynamicContext context) { + assert context != null; + DynamicContext subContext = handleLetStatements(item, context); + handleAllowedValuesAtLocation(item, subContext); + return super.visitFlag(item, subContext); + } + + @Override + public Void visitField(IFieldNodeItem item, DynamicContext context) { + assert context != null; + DynamicContext subContext = handleLetStatements(item, context); + handleAllowedValuesAtLocation(item, subContext); + return super.visitField(item, subContext); + } + + @Override + public Void visitAssembly(IAssemblyNodeItem item, DynamicContext context) { + assert context != null; + DynamicContext subContext = handleLetStatements(item, context); + handleAllowedValuesAtLocation(item, subContext); + return super.visitAssembly(item, subContext); + } + + @SuppressWarnings("PMD.AssignmentInOperand") + private DynamicContext handleLetStatements(IDefinitionNodeItem item, DynamicContext context) { + assert context != null; + DynamicContext subContext = context; + for (ILet let : item.getDefinition().getLetExpressions().values()) { + ISequence result = let.getValueExpression().evaluate(item, + subContext).reusable(); + subContext = subContext.bindVariableValue(let.getName(), result); + } + return subContext; + } + + @Override + public Void visitAssembly(IAssemblyInstanceGroupedNodeItem item, DynamicContext context) { + return visitAssembly((IAssemblyNodeItem) item, context); + } + + @Override + protected Void defaultResult() { + return null; + } + + /** + * A record that associates a definition node item with all the allowed-values + * constraints that target it. + */ + public static final class NodeItemRecord { + @NonNull + private final IDefinitionNodeItem item; + @NonNull + private final List allowedValues = new LinkedList<>(); + + private NodeItemRecord(@NonNull IDefinitionNodeItem item) { + this.item = item; + } + + /** + * Get the definition node item that is targeted by the allowed-values + * constraints. + * + * @return the target node item + */ + @NonNull + public IDefinitionNodeItem getItem() { + return item; + } + + /** + * Get the list of allowed-values constraint records targeting this node item. + * + * @return the list of allowed-values records + */ + @NonNull + public List getAllowedValues() { + return allowedValues; + } + + /** + * Add an allowed-values constraint record to this node item. + * + * @param record + * the allowed-values record to add + */ + public void addAllowedValues(@NonNull AllowedValuesRecord record) { + this.allowedValues.add(record); + } + } + + /** + * A record capturing the relationship between an allowed-values constraint, the + * definition where it is declared, and the target node it applies to. + */ + public static final class AllowedValuesRecord { + @NonNull + private final IAllowedValuesConstraint allowedValues; + @NonNull + private final IDefinitionNodeItem location; + @NonNull + private final IDefinitionNodeItem target; + + /** + * Construct a new allowed-values record. + * + * @param allowedValues + * the allowed-values constraint + * @param location + * the definition node item where the constraint is declared + * @param target + * the definition node item that the constraint targets + */ + public AllowedValuesRecord( + @NonNull IAllowedValuesConstraint allowedValues, + @NonNull IDefinitionNodeItem location, + @NonNull IDefinitionNodeItem target) { + this.allowedValues = allowedValues; + this.location = location; + this.target = target; + } + + /** + * Get the allowed-values constraint. + * + * @return the allowed-values constraint + */ + @NonNull + public IAllowedValuesConstraint getAllowedValues() { + return allowedValues; + } + + /** + * Get the definition node item where the constraint is declared. + * + * @return the location node item + */ + @NonNull + public IDefinitionNodeItem getLocation() { + return location; + } + + /** + * Get the definition node item that the constraint targets. + * + * @return the target node item + */ + @NonNull + public IDefinitionNodeItem getTarget() { + return target; + } + } +} diff --git a/core/src/test/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitorTest.java b/core/src/test/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitorTest.java new file mode 100644 index 000000000..d2f991c04 --- /dev/null +++ b/core/src/test/java/dev/metaschema/core/metapath/item/node/AllowedValueCollectingNodeItemVisitorTest.java @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.core.metapath.item.node; + +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.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Collection; +import java.util.List; + +import dev.metaschema.core.datatype.markup.MarkupLine; +import dev.metaschema.core.metapath.IMetapathExpression; +import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor.AllowedValuesRecord; +import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor.NodeItemRecord; +import dev.metaschema.core.model.IAssemblyDefinition; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.ISource; +import dev.metaschema.core.model.constraint.IAllowedValue; +import dev.metaschema.core.model.constraint.IAllowedValuesConstraint; +import dev.metaschema.core.model.constraint.IValueConstrained; +import dev.metaschema.core.testsupport.MockedModelTestSupport; +import dev.metaschema.core.testsupport.builder.IModuleBuilder; + +class AllowedValueCollectingNodeItemVisitorTest { + + private static final String TEST_NAMESPACE = "http://example.com/ns/allowed-values-test"; + + @Test + void testVisitorFindsAllowedValuesConstraints() { + MockedModelTestSupport mocking = new MockedModelTestSupport(); + ISource source = ISource.externalSource(URI.create(TEST_NAMESPACE)); + + IModule module = IModuleBuilder.builder() + .namespace(TEST_NAMESPACE) + .shortName("av-test") + .version("1.0.0") + .source(source) + .assembly(mocking.assembly() + .name("root") + .rootName("root") + .flags(List.of(mocking.flag().name("status")))) + .toModule(); + + // Add an allowed-values constraint to the assembly targeting the status flag + IAssemblyDefinition rootDef = module.getAssemblyDefinitions().iterator().next(); + IValueConstrained constraintSupport = rootDef.getConstraintSupport(); + constraintSupport.addConstraint( + IAllowedValuesConstraint.builder() + .source(source) + .target(IMetapathExpression.compile("@status")) + .allowedValue(IAllowedValue.of("active", MarkupLine.fromMarkdown("Active"), null)) + .allowedValue(IAllowedValue.of("inactive", MarkupLine.fromMarkdown("Inactive"), null)) + .build()); + + AllowedValueCollectingNodeItemVisitor visitor = new AllowedValueCollectingNodeItemVisitor(); + visitor.visit(module); + + Collection locations = visitor.getAllowedValueLocations(); + assertFalse(locations.isEmpty(), "Visitor should find at least one allowed-values location"); + + NodeItemRecord record = locations.iterator().next(); + assertNotNull(record.getItem(), "Node item should not be null"); + assertFalse(record.getAllowedValues().isEmpty(), "Should have at least one allowed-values constraint"); + + AllowedValuesRecord avRecord = record.getAllowedValues().get(0); + assertNotNull(avRecord.getAllowedValues(), "AllowedValuesConstraint should not be null"); + assertNotNull(avRecord.getLocation(), "Location should not be null"); + assertNotNull(avRecord.getTarget(), "Target should not be null"); + } + + @Test + void testVisitorReturnsEmptyForModuleWithoutConstraints() { + MockedModelTestSupport mocking = new MockedModelTestSupport(); + ISource source = ISource.externalSource(URI.create(TEST_NAMESPACE)); + + IModule module = IModuleBuilder.builder() + .namespace(TEST_NAMESPACE) + .shortName("no-constraints-test") + .version("1.0.0") + .source(source) + .assembly(mocking.assembly() + .name("root") + .rootName("root") + .flags(List.of(mocking.flag().name("id")))) + .toModule(); + + AllowedValueCollectingNodeItemVisitor visitor = new AllowedValueCollectingNodeItemVisitor(); + visitor.visit(module); + + Collection locations = visitor.getAllowedValueLocations(); + assertTrue(locations.isEmpty(), "Visitor should return empty for module without allowed-values constraints"); + } + + @Test + void testVisitorHandlesMultipleConstraints() { + MockedModelTestSupport mocking = new MockedModelTestSupport(); + ISource source = ISource.externalSource(URI.create(TEST_NAMESPACE)); + + IModule module = IModuleBuilder.builder() + .namespace(TEST_NAMESPACE) + .shortName("multi-constraints-test") + .version("1.0.0") + .source(source) + .assembly(mocking.assembly() + .name("root") + .rootName("root") + .flags(List.of( + mocking.flag().name("type"), + mocking.flag().name("status")))) + .toModule(); + + // Add two allowed-values constraints on the assembly targeting different flags + IAssemblyDefinition rootDef = module.getAssemblyDefinitions().iterator().next(); + IValueConstrained constraintSupport = rootDef.getConstraintSupport(); + constraintSupport.addConstraint( + IAllowedValuesConstraint.builder() + .source(source) + .target(IMetapathExpression.compile("@type")) + .allowedValue(IAllowedValue.of("typeA", MarkupLine.fromMarkdown("Type A"), null)) + .build()); + constraintSupport.addConstraint( + IAllowedValuesConstraint.builder() + .source(source) + .target(IMetapathExpression.compile("@status")) + .allowedValue(IAllowedValue.of("active", MarkupLine.fromMarkdown("Active"), null)) + .build()); + + AllowedValueCollectingNodeItemVisitor visitor = new AllowedValueCollectingNodeItemVisitor(); + visitor.visit(module); + + Collection locations = visitor.getAllowedValueLocations(); + assertFalse(locations.isEmpty(), "Visitor should find allowed-values locations"); + + int totalConstraints = locations.stream() + .mapToInt(loc -> loc.getAllowedValues().size()) + .sum(); + assertEquals(2, totalConstraints, "Should find exactly two allowed-values constraints"); + } +} diff --git a/metaschema-cli/src/main/java/dev/metaschema/cli/commands/ListAllowedValuesCommand.java b/metaschema-cli/src/main/java/dev/metaschema/cli/commands/ListAllowedValuesCommand.java new file mode 100644 index 000000000..b9bb6c5b9 --- /dev/null +++ b/metaschema-cli/src/main/java/dev/metaschema/cli/commands/ListAllowedValuesCommand.java @@ -0,0 +1,330 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.cli.commands; + +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactoryBuilder; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.Option; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +import dev.metaschema.cli.processor.CallingContext; +import dev.metaschema.cli.processor.ExitCode; +import dev.metaschema.cli.processor.command.AbstractTerminalCommand; +import dev.metaschema.cli.processor.command.CommandExecutionException; +import dev.metaschema.cli.processor.command.ExtraArgument; +import dev.metaschema.cli.processor.command.ICommandExecutor; +import dev.metaschema.core.metapath.DynamicContext; +import dev.metaschema.core.metapath.StaticContext; +import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor; +import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor.AllowedValuesRecord; +import dev.metaschema.core.metapath.item.node.IDefinitionNodeItem; +import dev.metaschema.core.metapath.item.node.IModuleNodeItem; +import dev.metaschema.core.metapath.item.node.INodeItemFactory; +import dev.metaschema.core.model.IModule; +import dev.metaschema.core.model.constraint.IAllowedValue; +import dev.metaschema.core.model.constraint.IAllowedValuesConstraint; +import dev.metaschema.core.model.constraint.IConstraintSet; +import dev.metaschema.core.util.ObjectUtils; +import dev.metaschema.databind.IBindingContext; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A CLI command that lists allowed-values constraints for a Metaschema module, + * organized by the target node they apply to. + *

+ * The output is produced in YAML format, showing each target location and the + * allowed-values constraints that apply to it, including constraint + * identifiers, allowed values, and source information. + */ +@SuppressWarnings("PMD.CouplingBetweenObjects") +public class ListAllowedValuesCommand + extends AbstractTerminalCommand { + private static final Logger LOGGER = LogManager.getLogger(ListAllowedValuesCommand.class); + + @NonNull + private static final String COMMAND = "list-allowed-values"; + @NonNull + private static final List EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of( + ExtraArgument.newInstance("metaschema-module-file-or-URL", true), + ExtraArgument.newInstance("destination-file", false))); + @NonNull + private static final Option CONSTRAINTS_OPTION = ObjectUtils.notNull( + Option.builder("c") + .hasArgs() + .argName("URL") + .desc("additional constraint definitions") + .get()); + + @Override + public String getName() { + return COMMAND; + } + + @Override + public String getDescription() { + return "List allowed values constraints for the provided Metaschema module"; + } + + @SuppressWarnings("null") + @Override + public Collection gatherOptions() { + return List.of( + CONSTRAINTS_OPTION, + MetaschemaCommands.OVERWRITE_OPTION); + } + + @Override + public List getExtraArguments() { + return EXTRA_ARGUMENTS; + } + + @Override + public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) { + return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand); + } + + /** + * Execute the list allowed values command. + * + * @param callingContext + * information about the calling context + * @param cmdLine + * the parsed command line details + * @throws CommandExecutionException + * if an error occurred while executing the command + */ + @SuppressWarnings({ + "PMD.OnlyOneReturn", + "PMD.AvoidCatchingGenericException", + "PMD.CognitiveComplexity" + }) + protected void executeCommand( + @NonNull CallingContext callingContext, + @NonNull CommandLine cmdLine) throws CommandExecutionException { + + List extraArgs = cmdLine.getArgList(); + + Path destination = null; + if (extraArgs.size() > 1) { + destination = MetaschemaCommands.handleDestination(ObjectUtils.requireNonNull(extraArgs.get(1)), cmdLine); + } + + URI currentWorkingDirectory = ObjectUtils.notNull(getCurrentWorkingDirectory().toUri()); + Set constraintSets = MetaschemaCommands.loadConstraintSets( + cmdLine, + CONSTRAINTS_OPTION, + currentWorkingDirectory); + + IBindingContext bindingContext = MetaschemaCommands.newBindingContextWithDynamicCompilation(constraintSets); + + URI moduleUri; + try { + moduleUri = resolveAgainstCWD(ObjectUtils.requireNonNull(extraArgs.get(0))); + } catch (URISyntaxException ex) { + throw new CommandExecutionException( + ExitCode.INVALID_ARGUMENTS, + String.format("Cannot load module as '%s' is not a valid file or URL. %s", + extraArgs.get(0), + ex.getLocalizedMessage()), + ex); + } + IModule module = MetaschemaCommands.loadModule(moduleUri, bindingContext); + + try { + if (destination == null) { + Writer stringWriter = new StringWriter(); + try (PrintWriter writer = new PrintWriter(stringWriter)) { + generateAllowedValuesList(module, writer); + } + + if (LOGGER.isInfoEnabled()) { + LOGGER.info(stringWriter.toString()); + } + } else { + try (Writer writer = Files.newBufferedWriter( + destination, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + try (PrintWriter printWriter = new PrintWriter(writer)) { + generateAllowedValuesList(module, printWriter); + } + } + } + } catch (IOException ex) { + throw new CommandExecutionException(ExitCode.IO_ERROR, ex); + } catch (RuntimeException ex) { + throw new CommandExecutionException(ExitCode.RUNTIME_ERROR, ex); + } + } + + private static void generateAllowedValuesList( + @NonNull IModule module, + @NonNull PrintWriter writer) throws IOException { + AllowedValueCollectingNodeItemVisitor walker = new AllowedValueCollectingNodeItemVisitor(); + + StaticContext staticContext = StaticContext.builder() + .defaultModelNamespace(module.getXmlNamespace()) + .build(); + + IModuleNodeItem moduleNodeItem = INodeItemFactory.instance().newModuleNodeItem(module); + + DynamicContext dynamicContext = new DynamicContext(staticContext); + dynamicContext.disablePredicateEvaluation(); + + walker.visit(moduleNodeItem, dynamicContext); + + Map, + List> allowedValuesByTarget + = ObjectUtils.notNull(walker.getAllowedValueLocations().stream() + .flatMap(location -> location.getAllowedValues().stream()) + .collect(Collectors.groupingBy(AllowedValuesRecord::getTarget, + () -> new TreeMap<>(Comparator.comparing(IDefinitionNodeItem::getMetapath)), + Collectors.mapping(Function.identity(), Collectors.toUnmodifiableList())))); + + generateYaml(allowedValuesByTarget, writer); + } + + private static void generateYaml( + @NonNull Map, List> allowedValuesByTarget, + @NonNull PrintWriter writer) throws IOException { + + YAMLFactoryBuilder builder = YAMLFactory.builder(); + YAMLFactory factory = ObjectUtils.notNull(builder + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .enable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) + .disable(YAMLGenerator.Feature.SPLIT_LINES) + .build()); + + try (YAMLGenerator generator = factory.createGenerator(writer)) { + + generator.writeStartObject(); + + writeLocations(allowedValuesByTarget, generator); + + generator.writeEndObject(); + } + } + + private static void writeLocations( + @NonNull Map, List> allowedValuesByTarget, + @NonNull YAMLGenerator generator) throws IOException { + generator.writeFieldName("locations"); + generator.writeStartObject(); + + for (Map.Entry, List> entry : allowedValuesByTarget.entrySet()) { + assert entry != null; + writeLocation(entry, generator); + } + generator.writeEndObject(); + } + + private static void writeLocation( + @NonNull Map.Entry, List> entry, + @NonNull YAMLGenerator generator) throws IOException { + + IDefinitionNodeItem target = ObjectUtils.notNull(entry.getKey()); + + generator.writeFieldName(metapath(target)); + + generator.writeStartObject(); + + writeLocationConstraints(entry, generator); + + generator.writeEndObject(); + } + + private static void writeLocationConstraints( + @NonNull Map.Entry, List> entry, + @NonNull YAMLGenerator generator) throws IOException { + IDefinitionNodeItem target = ObjectUtils.notNull(entry.getKey()); + + List allowedValues = entry.getValue(); + if (allowedValues != null) { + generator.writeFieldName("constraints"); + + generator.writeStartArray(); + + for (AllowedValuesRecord record : allowedValues) { + assert target.equals(record.getTarget()); + + writeAllowedValue(record, generator); + } + + generator.writeEndArray(); + } + } + + private static void writeAllowedValue(@NonNull AllowedValuesRecord record, @NonNull YAMLGenerator generator) + throws IOException { + + generator.writeStartObject(); + + generator.writeStringField("type", "allowed-values"); + + IAllowedValuesConstraint constraint = record.getAllowedValues(); + if (constraint.getId() != null) { + generator.writeStringField("identifier", constraint.getId()); + } + generator.writeStringField("location", metapath(record.getLocation())); + generator.writeStringField("target", constraint.getTarget().getPath()); + + List values = constraint.getAllowedValues().values().stream() + .map(IAllowedValue::getValue) + .collect(Collectors.toList()); + generator.writeFieldName("values"); + if (values == null) { + generator.writeNull(); + } else { + generator.writeStartArray(); + for (String value : values) { + generator.writeString(value); + } + generator.writeEndArray(); + } + + generator.writeBooleanField("allow-other", constraint.isAllowedOther()); + + URI source = constraint.getSource().getSource(); + generator.writeStringField("source", source == null ? "builtin" : source.toString()); + + generator.writeEndObject(); + } + + private static String metapath(@NonNull IDefinitionNodeItem item) { + return metapath(item.getMetapath()); + } + + private static String metapath(@NonNull String path) { + // remove position 1 predicates + return path.replace("[1]", ""); + } +} diff --git a/metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java b/metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java index 37de5611d..7d83ce5b6 100644 --- a/metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java +++ b/metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java @@ -63,6 +63,7 @@ public final class MetaschemaCommands { new ValidateModuleCommand(), new GenerateSchemaCommand(), new GenerateDiagramCommand(), + new ListAllowedValuesCommand(), new ValidateContentUsingModuleCommand(), new ConvertContentUsingModuleCommand(), new MetapathCommand())); diff --git a/metaschema-cli/src/main/java/module-info.java b/metaschema-cli/src/main/java/module-info.java index 7e348b101..b81ac3f1a 100644 --- a/metaschema-cli/src/main/java/module-info.java +++ b/metaschema-cli/src/main/java/module-info.java @@ -26,6 +26,7 @@ requires nl.talsmasoftware.lazy4j; requires org.apache.commons.cli; + requires com.fasterxml.jackson.dataformat.yaml; requires org.apache.logging.log4j; requires org.apache.logging.log4j.core; @@ -38,6 +39,7 @@ dev.metaschema.cli.commands.ValidateModuleCommand, dev.metaschema.cli.commands.GenerateSchemaCommand, dev.metaschema.cli.commands.GenerateDiagramCommand, + dev.metaschema.cli.commands.ListAllowedValuesCommand, dev.metaschema.cli.commands.ValidateContentUsingModuleCommand, dev.metaschema.cli.commands.ConvertContentUsingModuleCommand, dev.metaschema.cli.commands.metapath.MetapathCommand; diff --git a/metaschema-cli/src/test/java/dev/metaschema/cli/CLITest.java b/metaschema-cli/src/test/java/dev/metaschema/cli/CLITest.java index 37c7fa3a9..cca212d2a 100644 --- a/metaschema-cli/src/test/java/dev/metaschema/cli/CLITest.java +++ b/metaschema-cli/src/test/java/dev/metaschema/cli/CLITest.java @@ -142,6 +142,13 @@ private static Stream providesValues() { NO_EXCEPTION_CLASS)); add(Arguments.of(new String[] { "metapath", "eval", "--help" }, ExitCode.OK, NO_EXCEPTION_CLASS)); + add(Arguments.of(new String[] { "list-allowed-values", "--help" }, ExitCode.OK, + NO_EXCEPTION_CLASS)); + add(Arguments.of( + new String[] { "list-allowed-values", + "src/test/resources/content/schema-validation-module.xml" + }, + ExitCode.OK, NO_EXCEPTION_CLASS)); add(Arguments.of( new String[] { "validate", "../databind/src/test/resources/metaschema/fields_with_flags/metaschema.xml" @@ -313,6 +320,28 @@ void testValidateContent() { } } + @Test + void testListAllowedValuesOutput() throws Exception { + java.nio.file.Path outputFile = java.nio.file.Path.of("target/test-list-allowed-values.yaml"); + String[] cliArgs = { "list-allowed-values", + "src/test/resources/content/schema-validation-module.xml", + outputFile.toString(), + "--overwrite", + "--show-stack-trace" + }; + ExitStatus status = CLI.runCli(NULL_STREAM, cliArgs); + evaluateResult(status, ExitCode.OK, cliArgs); + + String content = java.nio.file.Files.readString(outputFile); + assertThat(content) + .contains("locations:") + .contains("/root/optional:") + .contains("type: allowed-values") + .contains("target: optional") + .contains("yes") + .contains("allow-other: false"); + } + @Test void testValidateConstraints() { try (LogCaptor captor = LogCaptor.forRoot()) {