From 52da91c0faaf6735eceac864100ec3d01c43ff3b Mon Sep 17 00:00:00 2001
From: Rico Bergmann <rico.bergmann1@tu-dresden.de>
Date: Wed, 10 Jul 2019 16:35:03 +0200
Subject: [PATCH] [WIP] Provide first draft of ModelJoin writer

---
 build.sbt                                     |  10 +-
 .../representation/core/AttributePath.java    |   9 +
 .../grammar/JoinExpression.java               |  11 +-
 .../representation/util/Functional.java       | 235 ++++++++++++++++++
 .../representation/util/JoinFactory.java      |   8 +
 .../representation/util/ModelJoinBuilder.java |  25 ++
 .../representation/util/Nothing.java          |  11 +
 .../model_join/representation/util/Pair.java  |  48 ++++
 .../writer/ModelJoinWriter.java               |   9 +
 .../writer/StringBasedModelJoinWriter.java    | 127 ++++++++++
 .../model_sync/util/NeedsCleanup.java         |   5 +
 ...ngBasedModelJoinWriterAcceptanceTests.java |  92 +++++++
 12 files changed, 584 insertions(+), 6 deletions(-)
 create mode 100644 src/main/java/org/rosi_project/model_sync/model_join/representation/util/Functional.java
 create mode 100644 src/main/java/org/rosi_project/model_sync/model_join/representation/util/Nothing.java
 create mode 100644 src/main/java/org/rosi_project/model_sync/model_join/representation/util/Pair.java
 create mode 100644 src/main/java/org/rosi_project/model_sync/model_join/representation/writer/ModelJoinWriter.java
 create mode 100644 src/main/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriter.java
 create mode 100644 src/main/java/org/rosi_project/model_sync/util/NeedsCleanup.java
 create mode 100644 src/test/java/org/rosi_project/model_sync/model_join/representation/writer/StringBasedModelJoinWriterAcceptanceTests.java

diff --git a/build.sbt b/build.sbt
index 662f258..3f4d569 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 e30fcd1..e852eb2 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 cc0613b..ecdccf9 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 0000000..853ee96
--- /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 1ce154c..bd87373 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 6cbf361..ca2ee58 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 0000000..6f4ddf4
--- /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 0000000..8cfb469
--- /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 0000000..d39eee5
--- /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 0000000..aca5a8a
--- /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 0000000..60eea42
--- /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 0000000..3136ebe
--- /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", "");
+  }
+}
-- 
GitLab