diff --git a/build.sbt b/build.sbt index 662f2585537d5a71ea2eb790f99d4ad0447e6214..3f4d5692e58a7e5468d91c458a5f8dbda3b86955 100644 --- a/build.sbt +++ b/build.sbt @@ -24,7 +24,15 @@ lazy val generator = (project in file(".")) "org.eclipse.emf" % "org.eclipse.emf.ecore" % emfecoreVersion, "com.github.scopt" %% "scopt" % scoptVersion, "net.liftweb" %% "lift-json" % liftVersion, - "com.google.code.gson" % "gson" % gsonVersion + "com.google.code.gson" % "gson" % gsonVersion, + + + "org.junit.platform" % "junit-platform-runner" % "1.0.0" % "test", + "org.junit.jupiter" % "junit-jupiter-engine" % "5.0.0" % "test", + "org.junit.vintage" % "junit-vintage-engine" % "4.12.0" % "test", + "org.assertj" % "assertj-core" % "3.12.2" % "test", + + "com.novocode" % "junit-interface" % "0.11" % "test" ), scalacOptions ++= Seq( "-language:implicitConversions" diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/core/AttributePath.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/core/AttributePath.java index e30fcd1cbdb32edb9d05ec352a7088ef34ea8781..e852eb223f3d858b61930dc6ec9ff78ffd04c93f 100644 --- a/src/main/java/org/rosi_project/model_sync/model_join/representation/core/AttributePath.java +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/core/AttributePath.java @@ -36,6 +36,10 @@ public class AttributePath { return new AttributePath(containingClass, attribute); } + public static AttributePath from(String containingClass, String attributeName) { + return new AttributePath(containingClass, attributeName); + } + /** * Generates a new {@code AttributePath} by splitting up the {@code qualifiedPath} into the * {@link ClassResource} and attribute portion. Therefore the {@code qualifiedPath} is expected @@ -60,6 +64,11 @@ public class AttributePath { @Nonnull private final RelativeAttribute attribute; + public AttributePath(String containingClass, String attributeName) { + this.containingClass = ClassResource.fromQualifiedName(containingClass); + this.attribute = RelativeAttribute.of(attributeName); + } + /** * Full constructor, parsing the attribute's name into a valid {@link RelativeAttribute}. * diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/grammar/JoinExpression.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/grammar/JoinExpression.java index cc0613bce01412b7b83d6a7c0bb092714ac595ec..ecdccf901a9aa31d861a66f11afdad9c78ba96e3 100644 --- a/src/main/java/org/rosi_project/model_sync/model_join/representation/grammar/JoinExpression.java +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/grammar/JoinExpression.java @@ -1,5 +1,6 @@ package org.rosi_project.model_sync.model_join.representation.grammar; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -93,15 +94,15 @@ public abstract class JoinExpression implements Iterable<KeepExpression> { this.target = target; this.keeps = keeps; } - + /** * Proof if the right and the left class path are equals. * @return result value */ public boolean isSameElement() { - if (left.getResourceName().equals(right.getResourceName()) + if (left.getResourceName().equals(right.getResourceName()) && left.getResourcePath().equals(right.getResourcePath())) { - return true; + return true; } return false; } @@ -156,9 +157,9 @@ public abstract class JoinExpression implements Iterable<KeepExpression> { public Iterable<KeepExpression> getKeeps() { return keeps; } - + public List<KeepExpression> getKeepsList() { - return keeps; + return Collections.unmodifiableList(keeps); } /** diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Functional.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Functional.java new file mode 100644 index 0000000000000000000000000000000000000000..853ee96122a43dfe41d4269dcaf6feb592b284d9 --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Functional.java @@ -0,0 +1,235 @@ +package org.rosi_project.model_sync.model_join.representation.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.swing.text.html.Option; + +/** + * Contains a number of useful classes to mimic behaviour from functional programming languages in + * Java. + * + * @author Rico Bergmann + */ +public class Functional { + + /** + * Emulates a dispatch on the concrete type of an object. Depending on the type, different control + * paths will be executed. + * + * @param something the object to match on + */ + @Nonnull + public static <R> FunctionalMatch<R> match(@Nonnull Object something) { + return new FunctionalMatch<>(something); + } + + /** + * + * @author Rico Bergmann + * + * @param <R> + */ + public static class FunctionalMatch<R> extends Functional { + + @Nonnull + private final Object matchInstance; + + @Nonnull + private final List<Pair<Class<?>, CaseResult<R>>> functions; + + @Nullable + private CaseResult<R> defaultAction; + + /** + * + * @param matchInstance + */ + private FunctionalMatch(@Nonnull Object matchInstance) { + Assert.notNull(matchInstance, "Object to match on may not be null"); + this.matchInstance = matchInstance; + this.functions = new ArrayList<>(); + } + + /** + * @param clazz + * @param action + * @return + */ + @SuppressWarnings("unchecked") + public <T> FunctionalMatch<R> caseOf(@Nonnull Class<T> clazz, @Nonnull Function<T, R> action) { + Assert.notNull(clazz, "Class to match on may not be null"); + Assert.notNull(action, "Action to perform may not be null"); + functions.add( + Pair.of(clazz, new RunnableCaseResult<>((Function<Object, R>) action, matchInstance))); + return this; + } + + /** + * @param clazz + * @param result + * @return + */ + public <T> FunctionalMatch<R> caseOf(@Nonnull Class<T> clazz, @Nonnull R result) { + Assert.notNull(clazz, "Class to match on may not be null"); + Assert.notNull(result, "Match result may not be null"); + functions.add(Pair.of(clazz, new StaticCaseResult<>(result))); + return this; + } + + /** + * @param action + * @return + */ + public FunctionalMatch<R> defaultCase(@Nonnull Function<? super Object, R> action) { + Assert.notNull(action, "Default action may not be null"); + this.defaultAction = new RunnableCaseResult<>(action, matchInstance); + return this; + } + + /** + * @param result + * @return + */ + public FunctionalMatch<R> defaultCase(R result) { + Assert.notNull(result, "Match result may not be null"); + this.defaultAction = new StaticCaseResult<>(result); + return this; + } + + /** + * Performs the match. + * <p> + * This will check for the first case to which the match object is assignable and execute the + * associated action. + * + * @return the result of the action or {@code null} if no matching case was specified. + */ + public R run() { + try { + R res = runOrThrow(); + return res; + } catch (MatchException e) { + return null; + } + } + + public Optional<R> tryRun() { + try { + R res = runOrThrow(); + return Optional.of(res); + } catch (MatchException e) { + return Optional.empty(); + } + } + + /** + * Attempts to perform the match. + * <p> + * This will check for the first case to which the match object is assignable and execute the + * associated action. If no matching action was specified, an exception will be raised. + * + * @return the result of the action or {@code null} if no matching case was specified. + * @throws MatchException if no matching action was given. + */ + public R runOrThrow() { + for (Pair<Class<?>, CaseResult<R>> caseEntry : functions) { + Class<?> entryClass = caseEntry.getFirst(); + if (entryClass.isAssignableFrom(matchInstance.getClass())) { + return caseEntry.getSecond().calculate(); + } + } + if (defaultAction != null) { + return defaultAction.calculate(); + } + throw new MatchException(matchInstance.getClass(), "No case specified"); + } + + private static abstract class CaseResult<R> { + + abstract R calculate(); + + } + + private static class RunnableCaseResult<R> extends CaseResult<R> { + + private final Function<Object, R> action; + private final Object param; + + /** + * @param action + * @param param + */ + RunnableCaseResult(@Nonnull Function<Object, R> action, @Nonnull Object param) { + Assert.notNull(action, "Action may not be null"); + Assert.notNull(param, "Param may not be null"); + this.action = action; + this.param = param; + } + + /* + * (non-Javadoc) + * + * @see de.naju.adebar.util.Functional.FunctionalMatch.CaseResult#calculate() + */ + @Override + R calculate() { + return action.apply(param); + } + + } + + private static class StaticCaseResult<R> extends CaseResult<R> { + + private final R result; + + /** + * @param result + */ + public StaticCaseResult(R result) { + this.result = result; + } + + /* + * (non-Javadoc) + * + * @see de.naju.adebar.util.Functional.FunctionalMatch.CaseResult#calculate() + */ + @Override + R calculate() { + return result; + } + + } + + } + + public static class MatchException extends RuntimeException { + + private static final long serialVersionUID = -3903882205980399140L; + + private final Class<?> actualClass; + + MatchException(Class<?> actualClass) { + this.actualClass = actualClass; + } + + MatchException(Class<?> actualClass, String message) { + super(message); + this.actualClass = actualClass; + } + + /** + * @return the actualClass + */ + public Class<?> getActualClass() { + return actualClass; + } + + } + + +} diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/JoinFactory.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/JoinFactory.java index 1ce154c12405888660b0ff36a8cfc12e10d54942..bd873738be32d3169868662f1f048e01480db08e 100644 --- a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/JoinFactory.java +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/JoinFactory.java @@ -273,6 +273,14 @@ public class JoinFactory { return this; } + /** + * Specifies the join condition. + */ + @Nonnull + public ThetaJoinBuilder where(@Nonnull String oclCondition) { + return where(OCLConstraint.of(oclCondition)); + } + /** * Specifies a field that should be contained in the resulting view. */ diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/ModelJoinBuilder.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/ModelJoinBuilder.java index 6cbf3611cf2f06c68aa1255097c40c845cdd1590..ca2ee58040af64d1928120a4b0eabdf11d44601d 100644 --- a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/ModelJoinBuilder.java +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/ModelJoinBuilder.java @@ -3,8 +3,17 @@ package org.rosi_project.model_sync.model_join.representation.util; import java.util.LinkedList; import java.util.List; import javax.annotation.Nonnull; +import org.rosi_project.model_sync.model_join.representation.core.AttributePath; +import org.rosi_project.model_sync.model_join.representation.core.ClassResource; import org.rosi_project.model_sync.model_join.representation.grammar.JoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepAttributesExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepReferenceExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepReferenceExpression.KeepReferenceBuilder; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepSuperTypeExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepSuperTypeExpression.KeepSuperTypeBuilder; import org.rosi_project.model_sync.model_join.representation.grammar.ModelJoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.ThetaJoinExpression; +import org.rosi_project.model_sync.model_join.representation.util.JoinFactory.ThetaJoinBuilder; /** * The {@code ModelJoinBuilder} provides a nice and fluent way of creating new {@code ModelJoin} @@ -14,6 +23,22 @@ import org.rosi_project.model_sync.model_join.representation.grammar.ModelJoinEx */ public class ModelJoinBuilder { + public static ThetaJoinBuilder thetaJoin() { + return JoinFactory.createNew().theta(); + } + + public static KeepAttributesExpression attributes(AttributePath firstAttribute, AttributePath... moreAttributes) { + return KeepAttributesExpression.keepAttributes(firstAttribute, moreAttributes); + } + + public static KeepReferenceBuilder outgoing(AttributePath attr) { + return KeepReferenceExpression.keep().outgoing(attr); + } + + public static KeepSuperTypeBuilder supertype(ClassResource supertype) { + return KeepSuperTypeExpression.keep().supertype(supertype); + } + /** * Starts the construction process for a new {@link ModelJoinExpression}. */ diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Nothing.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Nothing.java new file mode 100644 index 0000000000000000000000000000000000000000..6f4ddf4e0686a58f691c788bf2c71a5bcabde1b4 --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Nothing.java @@ -0,0 +1,11 @@ +package org.rosi_project.model_sync.model_join.representation.util; + +public final class Nothing { + + public static Nothing __() { + return new Nothing(); + } + + private Nothing() {}; + +} diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Pair.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Pair.java new file mode 100644 index 0000000000000000000000000000000000000000..8cfb469171b91948577ec7a0016ecba646cd7029 --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/util/Pair.java @@ -0,0 +1,48 @@ +package org.rosi_project.model_sync.model_join.representation.util; + +import java.util.Objects; + +public class Pair<F, S> { + + public static <F, S> Pair<F, S> of(F first, S second) { + return new Pair<F, S>(first, second); + } + + private final F first; + private final S second; + + public Pair(F first, S second) { + this.first = first; + this.second = second; + } + + public F getFirst() { + return first; + } + + public S getSecond() { + return second; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Pair)) { + return false; + } + Pair<?, ?> pair = (Pair<?, ?>) o; + return Objects.equals(first, pair.first) && + Objects.equals(second, pair.second); + } + + @Override + public int hashCode() { + return Objects.hash(first, second); + } + + public String toString() { + return "(" + first + ", " + second + ")"; + } +} diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/ModelJoinWriter.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/ModelJoinWriter.java new file mode 100644 index 0000000000000000000000000000000000000000..d39eee5ca3901d0b6906dd87a62bc2988b859888 --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/ModelJoinWriter.java @@ -0,0 +1,9 @@ +package org.rosi_project.model_sync.model_join.representation.writer; + +import org.rosi_project.model_sync.model_join.representation.grammar.ModelJoinExpression; + +public interface ModelJoinWriter<T> { + + T write(ModelJoinExpression modelJoin); + +} diff --git a/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriter.java b/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriter.java new file mode 100644 index 0000000000000000000000000000000000000000..aca5a8a5869661ac49cc4c8a766d2d552331a7b3 --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriter.java @@ -0,0 +1,127 @@ +package org.rosi_project.model_sync.model_join.representation.writer; + +import java.util.StringJoiner; +import javax.annotation.Nonnull; +import org.rosi_project.model_sync.model_join.representation.grammar.JoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepAggregateExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepAttributesExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepCalculatedExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepReferenceExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepSubTypeExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.KeepSuperTypeExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.ModelJoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.NaturalJoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.OuterJoinExpression; +import org.rosi_project.model_sync.model_join.representation.grammar.ThetaJoinExpression; +import org.rosi_project.model_sync.model_join.representation.util.Functional; +import org.rosi_project.model_sync.model_join.representation.util.Nothing; + +public class StringBasedModelJoinWriter implements ModelJoinWriter<String> { + + @Override + public String write(@Nonnull ModelJoinExpression modelJoin) { + StringJoiner joiner = new StringJoiner("\n\n"); + + modelJoin.getJoins().forEach( + joinStatement -> joiner.merge(writeJoin(joinStatement)) + ); + + return joiner.toString(); + } + + private StringJoiner writeJoin(@Nonnull JoinExpression join) { + StringJoiner joiner = new StringJoiner(" "); + + String joinHeader = Functional.<String>match(join) + .caseOf(ThetaJoinExpression.class, "theta join") + .caseOf(NaturalJoinExpression.class, "natural join") + .caseOf(OuterJoinExpression.class, outerJoin -> { + switch (outerJoin.getDirection()) { + case LEFT: + return "left outer join"; + case RIGHT: + return "right outer join"; + default: + throw new AssertionError(outerJoin.getDirection()); + } + }) + .runOrThrow(); + joiner.add(String.format("%s %s with %s as %s", joinHeader, join.getLeft(), join.getRight(), join.getTarget())); + Functional.<Nothing>match(join) + .caseOf(ThetaJoinExpression.class, thetaJoin -> { + joiner.add("where").add(thetaJoin.getCondition().toString()); + return Nothing.__(); + }) + .defaultCase(Nothing.__()) + .runOrThrow(); + + joiner.add("{\n"); + join.getKeeps().forEach(keep -> joiner.add(writeKeep(keep) + "\n")); + joiner.add("}"); + + return joiner; + } + + private String writeKeep(KeepExpression keep) { + + return Functional.<String>match(keep) + .caseOf(KeepAttributesExpression.class, keepAttributes -> { + StringJoiner attributesJoiner = new StringJoiner(", "); + keepAttributes.getAttributes().forEach(attr -> attributesJoiner.add(attr.toString())); + return "keep attributes " + attributesJoiner; + }) + .caseOf(KeepCalculatedExpression.class, keepCalculated -> + "keep calculated attribute " + + keepCalculated.getCalculationRule() + + " as " + keepCalculated.getTarget()) + .caseOf(KeepAggregateExpression.class, keepAggregate -> + String.format("keep aggregate %s(%s) over %s as %s", + keepAggregate.getAggregation().name().toLowerCase(), + keepAggregate.getAggregatedAttribute(), + keepAggregate.getSource(), keepAggregate.getTarget()) + ) + .caseOf(KeepReferenceExpression.class, this::writeKeepReference) + .caseOf(KeepSubTypeExpression.class, this::writeKeepSubType) + .caseOf(KeepSuperTypeExpression.class, this::writeKeepSuperType) + .runOrThrow(); + + } + + private String writeKeepReference(KeepReferenceExpression keepRef) { + StringJoiner refJoiner = new StringJoiner(" "); + + String header = String.format("keep %s %s as type %s {\n", keepRef.getReferenceDirection().name().toLowerCase(), keepRef.getAttribute(), keepRef.getTarget()); + + refJoiner.add(header); + keepRef.getKeeps().forEach(keep -> refJoiner.add(writeKeep(keep) + "\n")); + refJoiner.add("}"); + + return refJoiner.toString(); + } + + private String writeKeepSubType(KeepSubTypeExpression keepSubType) { + StringJoiner refJoiner = new StringJoiner(" "); + + String header = String.format("keep subtype %s as type %s {\n", keepSubType.getType(), keepSubType.getTarget()); + + refJoiner.add(header); + keepSubType.getKeeps().forEach(keep -> refJoiner.add(writeKeep(keep) + "\n")); + refJoiner.add("}"); + + return refJoiner.toString(); + } + + private String writeKeepSuperType(KeepSuperTypeExpression keepSuperType) { + StringJoiner refJoiner = new StringJoiner(" "); + + String header = String.format("keep supertype %s as type %s {\n", keepSuperType.getType(), keepSuperType.getTarget()); + + refJoiner.add(header); + keepSuperType.getKeeps().forEach(keep -> refJoiner.add(writeKeep(keep) + "\n")); + refJoiner.add("}"); + + return refJoiner.toString(); + } + +} diff --git a/src/main/java/org/rosi_project/model_sync/util/NeedsCleanup.java b/src/main/java/org/rosi_project/model_sync/util/NeedsCleanup.java new file mode 100644 index 0000000000000000000000000000000000000000..60eea42bb5583c75f1c0a79f72095dd5d99a8a7b --- /dev/null +++ b/src/main/java/org/rosi_project/model_sync/util/NeedsCleanup.java @@ -0,0 +1,5 @@ +package org.rosi_project.model_sync.util; + +public @interface NeedsCleanup { + +} diff --git a/src/test/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriterAcceptanceTests.java b/src/test/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriterAcceptanceTests.java new file mode 100644 index 0000000000000000000000000000000000000000..3136ebe15d60112418cf4ecfd7bf70fd426c730f --- /dev/null +++ b/src/test/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriterAcceptanceTests.java @@ -0,0 +1,92 @@ +package org.rosi_project.model_sync.model_join.representation.writer; + +import static org.rosi_project.model_sync.model_join.representation.util.ModelJoinBuilder.attributes; +import static org.rosi_project.model_sync.model_join.representation.util.ModelJoinBuilder.outgoing; +import static org.rosi_project.model_sync.model_join.representation.util.ModelJoinBuilder.supertype; +import static org.rosi_project.model_sync.model_join.representation.util.ModelJoinBuilder.thetaJoin; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.platform.runner.JUnitPlatform; +import org.junit.runner.RunWith; +import org.rosi_project.model_sync.model_join.representation.core.AttributePath; +import org.rosi_project.model_sync.model_join.representation.core.ClassResource; +import org.rosi_project.model_sync.model_join.representation.grammar.ModelJoinExpression; +import org.rosi_project.model_sync.model_join.representation.util.ModelJoinBuilder; +import org.rosi_project.model_sync.util.NeedsCleanup; + +@RunWith(JUnitPlatform.class) +@NeedsCleanup +class StringBasedModelJoinWriterAcceptanceTests { + + /* + * The technical report which forms the basis of the tests here is + * + * Burger, E. et Al.: "ModelJoin - A Textual Domain-Specific Language for the Combination of + * Heterogeneous Models"; Karslruhe Institute of Technololgy 2014 + */ + + @Test + void writeProducesListing2FromTechnicalReport() { + + ClassResource imdbFilm = ClassResource.from("imdb", "Film"); + ClassResource libraryVideoCassette = ClassResource.from("library", "VideoCassette"); + ClassResource libraryAudioVisualItem = ClassResource.from("library", "AudioVisualItem"); + ClassResource jointargetMovie = ClassResource.from("jointarget", "Movie"); + ClassResource jointargetVote = ClassResource.from("jointarget", "Vote"); + ClassResource jointargetMediaItem = ClassResource.from("jointarget", "MediaItem"); + + AttributePath imdbFilmYear = AttributePath.from(imdbFilm, "year"); + AttributePath imdbFilmVotes = AttributePath.from(imdbFilm, "votes"); + AttributePath imdbVoteScore = AttributePath.from("imdb.Vote", "score"); + AttributePath libraryAudioVisualItemMinutesLength = AttributePath.from(libraryAudioVisualItem, "minutesLength"); + + ModelJoinExpression listing2 = ModelJoinBuilder.createNewModelJoin() + .add(thetaJoin() + .join(imdbFilm) + .with(libraryVideoCassette) + .as(jointargetMovie) + .where( + "library.VideoCassette.cast->forAll (p | imdb.Film.figures->playedBy->exists (a | p.firstname.concat(\" \") .concat(p.lastName) == a.name))") + .keep(attributes(imdbFilmYear)) + .keep(outgoing(imdbFilmVotes) + .as(jointargetVote) + .keep(attributes(imdbVoteScore)) + .buildExpression() + ) + .keep(supertype(libraryAudioVisualItem) + .as(jointargetMediaItem) + .keep(attributes(libraryAudioVisualItemMinutesLength)) + .buildExpression() + ) + .done()) + .build(); + + StringBasedModelJoinWriter writer = new StringBasedModelJoinWriter(); + String writerOutput = writer.write(listing2); + + String sanitizedWriterOutput = sanitize(writerOutput); + + String expectedOutput = sanitize("theta join imdb.Film with library.VideoCassette as jointarget.Movie\n" + + "where library.VideoCassette.cast->forAll (p | imdb.Film.figures->playedBy->exists (a | p.\n" + + "firstname.concat(\" \") .concat(p.lastName) == a.name)) {\n" + + "keep attributes imdb.Film.year\n" + + "keep outgoing imdb.Film.votes as type jointarget.Vote {\n" + + "keep attributes imdb.Vote.score\n" + + "}\n" + + "keep supertype library.AudioVisualItem as type jointarget.MediaItem {\n" + + " keep attributes library.AudioVisualItem.minutesLength\n" + + "}\n" + + "}"); + + + // Although AssertJ provides an isEqualToIgnoringWhitespace as well as an + // isEqualToIgnoringNewline method, there's none that ignores both. Thus we reside to manual + // sanitization + assertThat(sanitizedWriterOutput).isEqualTo(expectedOutput); + } + + private String sanitize(String rawModelJoin) { + return rawModelJoin.replaceAll("\\s", ""); + } +}