Skip to content

Commit

Permalink
Add configurable diff evaluator
Browse files Browse the repository at this point in the history
Adds a smithy-diff evaluator which can be configured via metadata
property `diffEvaluators` within the model. Unlike the similar
EmitEach/EmitNoneSelectorValidators, this diff evaluator is not
loaded using SPI, and the ability to create your own custom
configurable diff evaluators has not been exposed. Instead,
smithy-diff just looks at the new model's metadata and loads
any diff evaluators directly. We can expose creating custom
configurable diff evaluators later if there is a need, but this
diff evaluator should be enough for almost every use case, and
we can also extend it easily with more configuration options.

This evaluator works as follows:
1. Get a subset of shapes to match based on the `appliesTo`
property. Currently either added shapes or removed shapes.
2. Optionally filter this subset of shapes further with a
selector configured in the `filter` property, which runs
on either the new or old model depending on `appliesTo`.
3. Run the configured `selector` on either the new model or
old model depending on `appliesTo`.
4. Match shapes returned by `selector` to the set of shapes
from 2, and emit events based on `emitCondition`.

This functionality can be extended later by adding more options
for `emitCondition` and `appliesTo`. For example, there is
currently no configuration option for looking at changed shapes,
but you could imaging wanting to see which shapes don't match
in the old model, but do in the new model, which would be a new
`appliesTo`. Another `emitCondition` could be `IfAnyDontMatch`
or something.

Also adds a validator to smithy-diff for this metadata property so
you'll know when the model is being build if evaluators have been
configured incorrectly. This requires having a dependency on
smithy-diff in the model package where you're configuring the diff
evaluators, but the alternative is stick the validation in
smithy-model, creating an implicit dependency on smithy-diff.
  • Loading branch information
milesziemer committed Nov 30, 2023
1 parent d457aab commit 273a14c
Show file tree
Hide file tree
Showing 11 changed files with 498 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.diff;

import java.util.List;
import software.amazon.smithy.diff.evaluators.configurable.ConfigurableEvaluatorLoader;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.validation.AbstractValidator;
import software.amazon.smithy.model.validation.ValidationEvent;

/**
* Validates diff evaluators configured in {@code diffEvaluators} metadata
* property.
*/
public class DiffEvaluatorsMetadataValidator extends AbstractValidator {
@Override
public List<ValidationEvent> validate(Model model) {
return ConfigurableEvaluatorLoader.loadMetadataDiffEvaluators(model).getValidationEvents();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import software.amazon.smithy.diff.evaluators.configurable.ConfigurableEvaluatorLoader;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.validation.Severity;
import software.amazon.smithy.model.validation.ValidatedResult;
Expand Down Expand Up @@ -281,6 +282,8 @@ public Result compare() {

List<DiffEvaluator> evaluators = new ArrayList<>();
ServiceLoader.load(DiffEvaluator.class, classLoader).forEach(evaluators::add);
evaluators.addAll(ConfigurableEvaluatorLoader.loadMetadataDiffEvaluators(newModel).unwrap());

Differences differences = Differences.detect(oldModel, newModel);

// Applies suppressions and elevates event severities.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.diff.evaluators.configurable;

import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.NodeMapper;

/**
* Possible values for the {@code appliesTo} property of configurable diff evaluators.
*
* <p>This property configures which model the selectors should run on.
* <ol>
* <li> Using {@link AppliesTo#ADDED_SHAPES} will configure the evaluator to run selectors on
* the new model, and only consider matches corresponding to added shapes.
* <li> Using {@link AppliesTo#REMOVED_SHAPES} will configure the evaluator to run selectors on
* the old model, and only consider matches corresponding to removed shapes.
* </ol>
*
*/
enum AppliesTo {
ADDED_SHAPES("AddedShapes"),
REMOVED_SHAPES("RemovedShapes");

private final String stringValue;

AppliesTo(String stringValue) {
this.stringValue = stringValue;
}

@Override
public String toString() {
return stringValue;
}

static AppliesTo fromNode(Node node) {
return new NodeMapper().deserialize(node, AppliesTo.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.diff.evaluators.configurable;

import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import software.amazon.smithy.diff.Differences;
import software.amazon.smithy.diff.evaluators.AbstractDiffEvaluator;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.validation.ValidationEvent;

/**
* Diff evaluator that is configurable via the `diffEvaluators` metadata property in
* Smithy models.
*/
public final class ConfigurableEvaluator extends AbstractDiffEvaluator {
private final ConfigurableEvaluatorDefinition definition;

ConfigurableEvaluator(ConfigurableEvaluatorDefinition definition) {
this.definition = definition;
}

@Override
public String getEventId() {
return definition.getId();
}

@Override
public List<ValidationEvent> evaluate(Differences differences) {
Model model = getApplicableModel(differences);
Set<Shape> shapes = getApplicableShapes(differences);
definition.getFilter().ifPresent(filter -> shapes.retainAll(filter.select(model)));
Set<Shape> matches = definition.getSelector().shapes(model)
.filter(shapes::contains)
.collect(Collectors.toSet());
return mapToEvents(shapes, matches);
}

private Model getApplicableModel(Differences differences) {
return definition.getAppliesTo() == AppliesTo.ADDED_SHAPES
? differences.getNewModel()
: differences.getOldModel();
}

private Set<Shape> getApplicableShapes(Differences differences) {
return definition.getAppliesTo() == AppliesTo.ADDED_SHAPES
? differences.addedShapes().collect(Collectors.toSet())
: differences.removedShapes().collect(Collectors.toSet());
}

private List<ValidationEvent> mapToEvents(Set<Shape> applicableShapes, Set<Shape> matches) {
switch (definition.getEmitCondition()) {
case IF_ANY_MATCH:
if (!matches.isEmpty()) {
return Collections.singletonList(getBaseEventBuilder().build());
}
break;
case IF_ALL_MATCH:
if (matches.equals(applicableShapes)) {
return Collections.singletonList(getBaseEventBuilder().build());
}
break;
case IF_NONE_MATCH:
if (matches.stream().anyMatch(applicableShapes::contains)) {
return Collections.singletonList(getBaseEventBuilder().build());
}
break;
case FOR_EACH_MATCH:
default:
return matches.stream()
.map(shape -> getBaseEventBuilder().shape(shape).build())
.collect(Collectors.toList());
}
return Collections.emptyList();
}

private ValidationEvent.Builder getBaseEventBuilder() {
return ValidationEvent.builder()
.id(definition.getId())
.message(definition.getMessage())
.severity(definition.getSeverity());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.diff.evaluators.configurable;

import java.util.List;
import java.util.Optional;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.ObjectNode;
import software.amazon.smithy.model.selector.Selector;
import software.amazon.smithy.model.validation.Severity;
import software.amazon.smithy.utils.ListUtils;

/**
* Definition of a diff evaluator which can be configured within a Smithy model.
*/
final class ConfigurableEvaluatorDefinition {
private static final String ID = "id";
private static final String MESSAGE = "message";
private static final String EMIT_CONDITION = "emitCondition";
private static final String APPLIES_TO = "appliesTo";
private static final String SEVERITY = "severity";
private static final String FILTER = "filter";
private static final String SELECTOR = "selector";
private static final List<String> ALLOWED_PROPERTIES = ListUtils.of(
ID, MESSAGE, EMIT_CONDITION, APPLIES_TO, SEVERITY, FILTER, SELECTOR);


private final String id;
private final String message;
private final EmitCondition emitCondition;
private final AppliesTo appliesTo;
private final Severity severity;
private final Selector filter;
private final Selector selector;

private ConfigurableEvaluatorDefinition(
String id,
String message,
EmitCondition emitCondition,
AppliesTo appliesTo,
Severity severity,
Selector filter,
Selector selector
) {
this.id = id;
this.message = message;
this.emitCondition = emitCondition;
this.appliesTo = appliesTo;
this.severity = severity;
this.filter = filter;
this.selector = selector;
}

static ConfigurableEvaluatorDefinition fromNode(Node node) {
ObjectNode objectNode = node.expectObjectNode("Configurable diff evaluators must be objects.");
objectNode.warnIfAdditionalProperties(ALLOWED_PROPERTIES);
String id = objectNode.expectStringMember(ID).getValue();
String message = objectNode.expectStringMember(MESSAGE).getValue();
EmitCondition emitCondition = EmitCondition.fromNode(objectNode.expectStringMember(EMIT_CONDITION));
AppliesTo appliesTo = AppliesTo.fromNode(objectNode.expectStringMember(APPLIES_TO));
Severity severity = Severity.fromNode(objectNode.expectStringMember(SEVERITY));
Selector filter = objectNode.getStringMember(FILTER).map(Selector::fromNode).orElse(null);
Selector selector = Selector.fromNode(objectNode.expectStringMember(SELECTOR));
return new ConfigurableEvaluatorDefinition(
id,
message,
emitCondition,
appliesTo,
severity,
filter,
selector
);
}

/**
* @return The id of the event the diff evaluator will emit.
*/
String getId() {
return id;
}

/**
* @return The message in the event that the diff evaluator will emit.
*/
String getMessage() {
return message;
}

/**
* @return The condition on which the diff evaluator will emit an event.
*/
EmitCondition getEmitCondition() {
return emitCondition;
}

/**
* @return What kind of diff the evaluator applies to.
*/
AppliesTo getAppliesTo() {
return appliesTo;
}

/**
* @return The severity of the events emitted by the diff evaluator.
*/
Severity getSeverity() {
return severity;
}

/**
* @return An optional selector used to filter the subset of shapes configured by {@link #getAppliesTo()}.
*/
Optional<Selector> getFilter() {
return Optional.ofNullable(filter);
}

/**
* @return The selector that chooses which shapes the diff evaluator should apply to.
*/
Selector getSelector() {
return selector;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.diff.evaluators.configurable;

import java.util.ArrayList;
import java.util.List;
import software.amazon.smithy.diff.DiffEvaluator;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.SourceException;
import software.amazon.smithy.model.node.ArrayNode;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.validation.ValidatedResult;
import software.amazon.smithy.model.validation.ValidationEvent;

/**
* Loads configurable diff evaluators defined in the model's metadata.
*/
public final class ConfigurableEvaluatorLoader {
private static final String DIFF_EVALUATORS_KEY = "diffEvaluators";

private ConfigurableEvaluatorLoader() {}

/**
* Loads configurable diff evaluators defined in a model's metadata.
*
* <p>This returns a ValidatedResult containing validation events that
* occurred when deserializing found diff evaluators.
*
* @param model Model to load diff evaluators from.
* @return The result of loading the diff evaluators, including any validation errors that occurred.
*/
public static ValidatedResult<List<DiffEvaluator>> loadMetadataDiffEvaluators(Model model) {
List<ValidationEvent> events = new ArrayList<>();
List<DiffEvaluator> evaluatorDefinitions = new ArrayList<>();

model.getMetadataProperty(DIFF_EVALUATORS_KEY).ifPresent(node -> {
try {
ArrayNode arrayNode = node.expectArrayNode(
String.format("metadata property `%s` must be an array of objects.", DIFF_EVALUATORS_KEY));
for (Node element : arrayNode.getElements()) {
try {
ConfigurableEvaluatorDefinition definition = ConfigurableEvaluatorDefinition.fromNode(element);
evaluatorDefinitions.add(new ConfigurableEvaluator(definition));
} catch (SourceException e) {
events.add(ValidationEvent.fromSourceException(e));
}
}
} catch (SourceException e) {
events.add(ValidationEvent.fromSourceException(e));
}
});

return new ValidatedResult<>(evaluatorDefinitions, events);
}
}
Loading

0 comments on commit 273a14c

Please sign in to comment.