Repository: ontodev/robot
Pull request #228 (2018-01-15 16:48:25)

Add support for axiom annotations
See #226


Patch:
diff --git a/.editorconfig b/.editorconfig
index 68018e32..2eb81ba2 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -8,4 +8,4 @@ charset = utf-8
 end_of_line = lf
 trim_trailing_whitespace = true
 indent_style = space
-indent_size = 2
+indent_size = 4
diff --git a/README.md b/README.md
index c332e532..caf1d040 100644
--- a/README.md
+++ b/README.md
@@ -75,14 +75,16 @@ Other build options:
 
 - `mvn clean test` runs JUnit tests with reports in `[module]/target/surefire-reports`
 - `mvn clean verify` rebuilds the package and runs integration tests against it, with reports in `[module]/target/failsafe-reports`
-- `mvn site` generates Javadoc in `target/site` and `[module]/target/site`
+- `mvn site` generates reports (including Javadoc and Checkstyle) in `target/site` and `[module]/target/site`
 
 Alternatively, you can use [Docker](https://www.docker.com) with the provided [Dockerfile](Dockerfile) to build and run ROBOT from within a container. First build an image with `docker build --tag robot .` then run ROBOT from the container with the usual command-line arguments: `docker run --rm robot --help`.
 
 
-## Code Style
+## Development
 
-We use [Google Java Style](https://google.github.io/styleguide/javaguide.html), automatically enforced with [google-java-format](https://github.com/google/google-java-format).
+We use [Checkstyle](http://checkstyle.sourceforge.net) to enforce the default [Sun Guidelines](http://www.oracle.com/technetwork/java/javase/documentation/codeconvtoc-136057.html), with some exceptions in [checkstyle-suppressions.xml](checkstyle-suppressions.xml). Run `mvn clean site` to generate the Checkstyle reports in `target/site/checkstyle-aggregate.html`. Some of the rules are admittedly arbitrary, but consistency should help newcomers read the code. See also the [.editorconfig](.editorconfig) file, which can provide some [hints to your text editor](http://editorconfig.org).
+
+Basics: indent with four spaces; maximum line length is 80 characters; use spaces after commas, before and after operators such as `+`; no trailing whitespace; UTF-8 encoding; Unix newlines; remove unused imports; always include JavaDocs.
 
 
 # Design
diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml
new file mode 100644
index 00000000..203afa85
--- /dev/null
+++ b/checkstyle-suppressions.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+
+<!DOCTYPE suppressions PUBLIC
+     "-//Puppy Crawl//DTD Suppressions 1.0//EN"
+     "http://www.puppycrawl.com/dtds/suppressions_1_0.dtd">
+
+<!-- Suppress several rules. -->
+<suppressions>
+  <suppress checks="JavadocPackage" files="."/>
+  <suppress checks="PackageName" files="."/>
+  <suppress checks="HideUtilityClassConstructor" files="."/>
+  <suppress checks="ConstantName" files="."/>
+  <suppress checks="DesignForExtension" files="."/>
+  <suppress checks="FinalParameters" files="."/>
+  <suppress checks="HiddenField" files="."/>
+  <suppress checks="MagicNumber" files="."/>
+  <suppress checks="VisibilityModifier" files="test"/>
+  <suppress checks="ParameterNumber" files="."/>
+</suppressions>
diff --git a/docs/template.md b/docs/template.md
index a0c14118..49392fa0 100644
--- a/docs/template.md
+++ b/docs/template.md
@@ -17,12 +17,15 @@ The `template` command accepts an optional input ontology, either using the `--i
 - `CLASS_TYPE`: ROBOT creates a class for each row of data. You must specify a CLASS_TYPE, which can be either:
     - `subclass`: the created class will be asserted to be a subclass of each templated class expression
     - `equivalent`: the created class will be asserted to be equivalent to the intersection of all the templated class expressions
-- annotations: ROBOT can attach annotations to your class. There are four options:
+- **annotations**: ROBOT can attach annotations to your class. There are four options:
     - `A` string annotation: If the template string starts with an `A` and a space then it will be interpreted as a string annotation. The rest of the template string should be the label or compact IRI of an annotation property, e.g. `label` or `rdfs:label`. The cell value will be the literal value of the annotation with type `xsd:string`.
     - `AT` typed annotation: If the template string starts with an `AT` and a space then it will be interpreted as a typed annotation. The `^^` characters must be used to separate the annotation property from the datatype, e.g. `rdfs:comment^^xsd:integer`. The cell value will be the typed literal value of the annotation.
     - `AL` language annotation: If the template string starts with an `AL` and a space then it will be interpreted as a language annotation. The `@` character must be used to separate the annotation property from the language code, e.g. `rdfs:comment@en`.
     - `AI` annotation IRI: If the template string starts with an `AI` and a space, then the annotation will be made as with a string annotation, except that the cell value will be interpreted as an IRI.
-- `C` class expression: If the template string starts with a `C` and a space then it will be interpreted as a class expression. The value of the current cell will be substituted into the template, replacing all occurrences of the percent `%` character. Then the result will be parsed into an OWL class expression. ROBOT uses the same syntax for class expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). If it does not recognize a name, ROBOT will assume that you're trying to refer to a class by its IRI (or compact IRI). This can lead to unexpected behaviour, but it allows you to refer to classes (by IRI) without loading them into the input ontology. This is particularly useful when the input ontology would be too large, such as the NCBI Taxonomy.
+- `C` **class expression**: If the template string starts with a `C` and a space then it will be interpreted as a class expression. The value of the current cell will be substituted into the template, replacing all occurrences of the percent `%` character. Then the result will be parsed into an OWL class expression. ROBOT uses the same syntax for class expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). If it does not recognize a name, ROBOT will assume that you're trying to refer to a class by its IRI (or compact IRI). This can lead to unexpected behaviour, but it allows you to refer to classes (by IRI) without loading them into the input ontology. This is particularly useful when the input ontology would be too large, such as the NCBI Taxonomy.
+- **axiom annotations**: ROBOT can also annotate logical and annotation axioms.
+    - `>A` annotation on annotation: Annotates the annotation axiom created from the cell to the left with the cell value. The column to the left must be an `A*` template string.
+    - `>C` annotation on class expression: Annotates the class expression axiom created from the cell to the left with the cell value. The column to the left must be a `C` template string.
 
 Sometimes you want to include zero or more values in a single spreadsheet cell, for example when you want to allow for multiple annotations. If an annotation template string also contains `SPLIT=|`, then ROBOT will use the `|` character to split the contents of a cell in that column and add an annotation for each result (if there are any). Instead of `|` you can specify a string of non-whitespace characters of your choice to split on.
 
diff --git a/pom.xml b/pom.xml
index 5022ca9d..668c748c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -117,6 +117,15 @@
                 <artifactId>maven-jxr-plugin</artifactId>
                 <version>2.5</version>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-checkstyle-plugin</artifactId>
+                <version>2.14</version>
+                <configuration>
+                    <includeTestSourceDirectory>true</includeTestSourceDirectory>
+                    <suppressionsLocation>checkstyle-suppressions.xml</suppressionsLocation>
+                </configuration>
+            </plugin>
         </plugins>
     </reporting>
 
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
index 4c65eb06..942b2fa6 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
@@ -1,11 +1,14 @@
 package org.obolibrary.robot;
 
+import com.google.common.collect.Sets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.semanticweb.owlapi.apibinding.OWLManager;
@@ -13,13 +16,15 @@ import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassEx
 import org.semanticweb.owlapi.manchestersyntax.renderer.ParserException;
 import org.semanticweb.owlapi.model.IRI;
 import org.semanticweb.owlapi.model.OWLAnnotation;
+import org.semanticweb.owlapi.model.OWLAnnotationAssertionAxiom;
 import org.semanticweb.owlapi.model.OWLAnnotationProperty;
+import org.semanticweb.owlapi.model.OWLAnnotationValue;
+import org.semanticweb.owlapi.model.OWLAxiom;
 import org.semanticweb.owlapi.model.OWLClass;
 import org.semanticweb.owlapi.model.OWLClassExpression;
 import org.semanticweb.owlapi.model.OWLDataFactory;
 import org.semanticweb.owlapi.model.OWLDatatype;
 import org.semanticweb.owlapi.model.OWLEntity;
-import org.semanticweb.owlapi.model.OWLNamedObject;
 import org.semanticweb.owlapi.model.OWLObjectIntersectionOf;
 import org.semanticweb.owlapi.model.OWLOntology;
 import org.semanticweb.owlapi.model.OWLOntologyManager;
@@ -48,6 +53,16 @@ public class TemplateOperation {
   /** Shared DataFactory. */
   private static OWLDataFactory dataFactory = new OWLDataFactoryImpl();
 
+  /**
+   * Error message when an axiom annotation does not have the appropriate annotation or class
+   * expression to the left. Expects: table name, row number, row id, column number, column name,
+   * cell value, axiom type.
+   */
+  private static String axiomAnnotationError =
+      "Axiom annotation \"%6$s\" at row %2$d (\"%3$s\"), "
+          + "column %4$d (\"%5$s\") in table \"%1$s\" "
+          + "requires %7$s in the previous column.";
+
   /**
    * Error message when the number of header columns does not match the number of template columns.
    * Expects: table name, header count, template count.
@@ -57,23 +72,6 @@ public class TemplateOperation {
           + "the number of template columns (%3$d) "
           + "in table \"%1$s\".";
 
-  /**
-   * Error message when a required column is missing from a row. Expects: table name, row number,
-   * row id, columns number, column name.
-   */
-  private static String columnError =
-      "Missing column %4$d (\"%5$s\") " + "from row %2$d (\"%3$s\") " + "in table \"%1$s\".";
-
-  /**
-   * Error message when a required cell is null. Expects: table name, row number, row id, columns
-   * number, column name, template.
-   */
-  private static String nullCellError =
-      "Null value at row %2$d (\"%3$s\"), "
-          + "column %4$d (\"%5$s\") "
-          + "for template \"%6$s\" "
-          + "in table \"%1$s\".";
-
   /**
    * Error message when a template cannot be understood. Expects: table name, column number, column
    * name, template.
@@ -152,8 +150,7 @@ public class TemplateOperation {
   }
 
   /**
-   * Return a string annotation for the given template string and value. The template string format
-   * is "A [name]" and the value is any string.
+   * Return a string annotation for the given template string and value.
    *
    * @param checker used to resolve the annotation property
    * @param template the template string
@@ -175,7 +172,7 @@ public class TemplateOperation {
    * @param checker used to resolve the annotation property and datatype
    * @param template the template string
    * @param value the value for the annotation
-   * @return a new annotation with property and typed literal value
+   * @return a new annotation axiom with property and typed literal value
    * @throws Exception if the annotation property cannot be found
    */
   public static OWLAnnotation getTypedAnnotation(
@@ -195,7 +192,7 @@ public class TemplateOperation {
    * @param checker used to resolve the annotation property
    * @param template the template string
    * @param value the value for the annotation
-   * @return a new annotation with property and language tagged literal
+   * @return a new annotation axiom with property and language tagged literal
    * @throws Exception if the annotation property cannot be found
    */
   public static OWLAnnotation getLanguageAnnotation(
@@ -214,7 +211,7 @@ public class TemplateOperation {
    * @param checker used to resolve the annotation property
    * @param template the template string
    * @param value the IRI value for the annotation
-   * @return a new annotation with property and an IRI value
+   * @return a new annotation axiom with property and an IRI value
    * @throws Exception if the annotation property cannot be found
    */
   public static OWLAnnotation getIRIAnnotation(
@@ -244,7 +241,7 @@ public class TemplateOperation {
     if (template.equals("CLASS_TYPE")) {
       return true;
     }
-    if (template.matches("^(A|AT|AL|AI|C) .*")) {
+    if (template.matches("^(>?C|>{0,2}A[L,T,I]?) .*")) {
       return true;
     }
     if (template.equals("CI")) {
@@ -371,10 +368,7 @@ public class TemplateOperation {
 
       if (idColumn == -1 && labelColumn == -1) {
         throw new Exception(
-            "Template row must include "
-                + "an \"ID\" or \"LABEL\" column "
-                + "in table: "
-                + tableName);
+            "Template row must include an \"ID\" or \"LABEL\" column in table: " + tableName);
       }
       idColumns.put(tableName, idColumn);
       labelColumns.put(tableName, labelColumn);
@@ -484,6 +478,74 @@ public class TemplateOperation {
     return null;
   }
 
+  /**
+   * Get a list of the IRIs defined in a set of template tables.
+   *
+   * @param tables a map from table names to tables
+   * @param ioHelper used to find entities by name
+   * @return a list of IRIs
+   * @throws Exception when names or templates cannot be handled
+   */
+  public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
+      throws Exception {
+    List<IRI> iris = new ArrayList<IRI>();
+    for (Map.Entry<String, List<List<String>>> table : tables.entrySet()) {
+      String tableName = table.getKey();
+      List<List<String>> rows = table.getValue();
+      iris.addAll(getIRIs(tableName, rows, ioHelper));
+    }
+    return iris;
+  }
+
+  /**
+   * Get a list of the IRIs defined in a template table.
+   *
+   * @param tableName the name of the table
+   * @param rows the table of data
+   * @param ioHelper used to find entities by name
+   * @return a list of IRIs
+   * @throws Exception when names or templates cannot be handled
+   */
+  public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
+      throws Exception {
+    // Find the ID column.
+    List<String> templates = rows.get(1);
+    int idColumn = -1;
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      if (template == null) {
+        continue;
+      }
+      template = template.trim();
+      if (template.equals("ID")) {
+        idColumn = column;
+      }
+    }
+    if (idColumn == -1) {
+      throw new Exception("Template row must include an \"ID\" column " + "in table " + tableName);
+    }
+
+    List<IRI> iris = new ArrayList<IRI>();
+    for (int row = 2; row < rows.size(); row++) {
+      String id = null;
+      try {
+        id = rows.get(row).get(idColumn);
+      } catch (IndexOutOfBoundsException e) {
+        continue;
+      }
+      if (id == null || id.trim().isEmpty()) {
+        continue;
+      }
+      IRI iri = ioHelper.createIRI(id);
+      if (iri == null) {
+        continue;
+      }
+      iris.add(iri);
+    }
+
+    return iris;
+  }
+
   /**
    * Use templates to add entities and their annotations to an ontology.
    *
@@ -523,7 +585,18 @@ public class TemplateOperation {
 
     IOHelper ioHelper = checker.getIOHelper();
     IRI type = null;
-    List<OWLAnnotation> annotations = new ArrayList<OWLAnnotation>();
+    Set<OWLAnnotation> annotations = new HashSet<>();
+    // For handling nested axioms (annotation on annotation on annotation)
+    // Key is first level, value is a map with key as second level annotation
+    // and value as set of third-level annotations (or empty set if none)
+    Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested = new HashMap<>();
+    // Track the last top-level annotation
+    OWLAnnotation lastAnnotation = null;
+    // Track the last second-level annotation
+    // This is reset to null each time we return to a top-level annotation
+    OWLAnnotation lastAxiomAnnotation = null;
+    Map<OWLAnnotation, Set<OWLAnnotation>> axiomAnnotations;
+    Set<OWLAnnotation> axiomAnnotationAnnotations;
 
     // For each column, apply templates for annotations.
     for (int column = 0; column < headers.size(); column++) {
@@ -536,7 +609,6 @@ public class TemplateOperation {
         continue;
       }
 
-      String header = headers.get(column);
       String cell = null;
       try {
         cell = rows.get(row).get(column);
@@ -550,7 +622,6 @@ public class TemplateOperation {
       if (cell.trim().isEmpty()) {
         continue;
       }
-      String content = QuotedEntityChecker.wrap(cell);
 
       // If the template contains SPLIT=X,
       // then split the cell value
@@ -566,10 +637,14 @@ public class TemplateOperation {
         values.add(cell);
       }
 
+      // Create OWLAnnotation objects
+      // Link any axiom annotations in a map
       for (String value : values) {
         if (template.equals("LABEL")) {
           label = value;
-          annotations.add(getStringAnnotation(checker, "A rdfs:label", value));
+          lastAxiomAnnotation = null;
+          lastAnnotation = getStringAnnotation(checker, "A rdfs:label", value);
+          annotations.add(lastAnnotation);
         } else if (template.equals("TYPE")) {
           OWLEntity entity = checker.getOWLEntity(value);
           if (entity != null) {
@@ -579,34 +654,78 @@ public class TemplateOperation {
           }
           OWLAnnotationProperty rdfType = getAnnotationProperty(checker, "rdf:type");
           annotations.add(dataFactory.getOWLAnnotation(rdfType, type));
-        } else if (template.startsWith("A ")) {
-          annotations.add(getStringAnnotation(checker, template, value));
-        } else if (template.startsWith("AT ") && template.indexOf("^^") > -1) {
-          annotations.add(getTypedAnnotation(checker, template, value));
-        } else if (template.startsWith("AL ") && template.indexOf("@") > -1) {
-          annotations.add(getLanguageAnnotation(checker, template, value));
-        } else if (template.startsWith("AI ")) {
-          IRI iri = ioHelper.createIRI(value);
-          annotations.add(getIRIAnnotation(checker, template, iri));
+        } else if (template.startsWith("A")) {
+          lastAxiomAnnotation = null;
+          lastAnnotation = getAnnotation(checker, ioHelper, template, value);
+          annotations.add(lastAnnotation);
+        } else if (template.startsWith(">A")) {
+          if (lastAnnotation == null) {
+            // Just in case an AA template is first
+            throw new Exception(
+                String.format(
+                    axiomAnnotationError,
+                    tableName,
+                    row + 1,
+                    id,
+                    column + 1,
+                    template,
+                    value,
+                    "an annotation"));
+          }
+          // Get annotation based on annotation type
+          lastAxiomAnnotation = getAnnotation(checker, ioHelper, template.substring(1), value);
+          // If the last annotation is already in the map, get it's existing annotations
+          if (nested.containsKey(lastAnnotation)) {
+            axiomAnnotations = nested.get(lastAnnotation);
+          } else {
+            axiomAnnotations = new HashMap<>();
+          }
+          // Add this annotation to map of axiom annotations with an empty set
+          axiomAnnotations.put(lastAxiomAnnotation, Sets.newHashSet());
+          nested.put(lastAnnotation, axiomAnnotations);
+          // Remove from annotation set to prevent duplication
+          if (annotations.contains(lastAnnotation)) {
+            annotations.remove(lastAnnotation);
+          }
+          // Handle axiom annotation annotations ?
+        } else if (template.startsWith(">>A")) {
+          if (lastAxiomAnnotation == null) {
+            // The last axiom annotation is reset each time we go back to a top-level annotation.
+            // If there isn't an annotation with the AA template string before the AAA template,
+            // there is nothing to annotate...
+            throw new Exception(
+                String.format(
+                    axiomAnnotationError,
+                    tableName,
+                    row + 1,
+                    id,
+                    column + 1,
+                    template,
+                    value,
+                    "an axiom annotation"));
+          }
+          // These are put in during the >A loop, so they should always be there
+          axiomAnnotations = nested.get(lastAnnotation);
+          axiomAnnotationAnnotations = axiomAnnotations.get(lastAxiomAnnotation);
+          // Add this iteration of annotation and put into the nested map
+          axiomAnnotationAnnotations.add(
+              getAnnotation(checker, ioHelper, template.substring(2), value));
+          axiomAnnotations.put(lastAxiomAnnotation, axiomAnnotationAnnotations);
+          nested.put(lastAnnotation, axiomAnnotations);
         }
       }
     }
 
-    // Add annotations to an entity
     OWLEntity entity = getEntity(checker, type, id, label);
     if (entity == null) {
       throw new Exception(String.format(nullIDError, tableName, row + 1, id, label));
     }
-
+    // Create axioms from OWLAnnotation sets
+    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(entity, annotations, nested);
+    // Add annotations to an entity
     OWLOntology ontology = outputManager.createOntology();
     outputManager.addAxiom(ontology, dataFactory.getOWLDeclarationAxiom(entity));
-
-    for (OWLAnnotation annotation : annotations) {
-      outputManager.addAxiom(
-          ontology,
-          dataFactory.getOWLAnnotationAssertionAxiom(
-              ((OWLNamedObject) entity).getIRI(), annotation));
-    }
+    outputManager.addAxioms(ontology, axioms);
 
     checker.addAll(ontology);
     MergeOperation.mergeInto(ontology, outputOntology);
@@ -665,7 +784,9 @@ public class TemplateOperation {
     }
 
     String classType = "subclass";
-    List<OWLClassExpression> classExpressions = new ArrayList<OWLClassExpression>();
+    Set<OWLClassExpression> classExpressions = new HashSet<>();
+    OWLClassExpression lastExpression = null;
+    Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions = new HashMap<>();
 
     // For each column, add logical axioms.
     for (int column = 0; column < headers.size(); column++) {
@@ -699,22 +820,49 @@ public class TemplateOperation {
       } else if (template.startsWith("C ")) {
         String sub = template.substring(2).trim().replaceAll("%", content);
         try {
-          classExpressions.add(parser.parse(sub));
+          lastExpression = parser.parse(sub);
         } catch (ParserException e) {
           throw new Exception(
               String.format(
                   parseError, tableName, row + 1, id, column + 1, header, sub, e.getMessage()));
         }
+        classExpressions.add(lastExpression);
       } else if (template.startsWith("CI")) {
         IRI iri = ioHelper.createIRI(cell);
-        classExpressions.add(dataFactory.getOWLClass(iri));
+        lastExpression = dataFactory.getOWLClass(iri);
+        classExpressions.add(lastExpression);
+      } else if (template.startsWith(">C ")) {
+        if (lastExpression == null) {
+          throw new Exception(
+              String.format(
+                  axiomAnnotationError,
+                  tableName,
+                  row + 1,
+                  id,
+                  column + 1,
+                  template,
+                  cell,
+                  "a class expression"));
+        }
+        Set<OWLAnnotation> annotations;
+        if (annotatedExpressions.containsKey(lastExpression)) {
+          annotations = annotatedExpressions.get(lastExpression);
+        } else {
+          annotations = new HashSet<>();
+        }
+        annotations.add(getStringAnnotation(checker, template.substring(1).trim(), cell));
+        annotatedExpressions.put(lastExpression, annotations);
+        // Remove to prevent duplication
+        if (classExpressions.contains(lastExpression)) {
+          classExpressions.remove(lastExpression);
+        }
       }
     }
 
-    if (classExpressions.size() == 0) {
+    // Make sure there are expressions to add
+    if ((classExpressions.size() == 0) && (annotatedExpressions.isEmpty())) {
       return;
     }
-
     if (classType == null) {
       throw new Exception(String.format(missingTypeError, tableName, row + 1, id));
     }
@@ -731,86 +879,131 @@ public class TemplateOperation {
     if (cls == null) {
       throw new Exception(String.format(nullIDError, tableName, row + 1, id, label));
     }
-
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    if (classType.equals("subclass")) {
-      for (OWLClassExpression expression : classExpressions) {
-        manager.addAxiom(ontology, dataFactory.getOWLSubClassOfAxiom(cls, expression));
-      }
-    } else if (classType.equals("equivalent")) {
-      OWLObjectIntersectionOf intersection =
-          dataFactory.getOWLObjectIntersectionOf(new HashSet<OWLClassExpression>(classExpressions));
-      manager.addAxiom(ontology, dataFactory.getOWLEquivalentClassesAxiom(cls, intersection));
-    } else {
+    Set<OWLAxiom> axioms = getLogicalAxioms(cls, classType, classExpressions, annotatedExpressions);
+    if (axioms == null) {
       throw new Exception(String.format(unknownTypeError, tableName, row + 1, id));
+    } else {
+      ontology.getOWLOntologyManager().addAxioms(ontology, axioms);
     }
   }
 
   /**
-   * Get a list of the IRIs defined in a set of template tables.
+   * Create an OWLAnnotation based on the template string and cell value.
    *
-   * @param tables a map from table names to tables
-   * @param ioHelper used to find entities by name
-   * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
+   * @param checker used to resolve the annotation property
+   * @param ioHelper IOHelper used to create IRIs from values
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return OWLAnnotation, or null if template string is not supported
+   * @throws Exception if annotation property cannot be found
    */
-  public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
+  private static OWLAnnotation getAnnotation(
+      QuotedEntityChecker checker, IOHelper ioHelper, String template, String value)
       throws Exception {
-    List<IRI> iris = new ArrayList<IRI>();
-    for (Map.Entry<String, List<List<String>>> table : tables.entrySet()) {
-      String tableName = table.getKey();
-      List<List<String>> rows = table.getValue();
-      iris.addAll(getIRIs(tableName, rows, ioHelper));
+    if (template.startsWith("A ")) {
+      return getStringAnnotation(checker, template, value);
+    } else if (template.startsWith("AT ") && template.indexOf("^^") > -1) {
+      return getTypedAnnotation(checker, template, value);
+    } else if (template.startsWith("AL ") && template.indexOf("@") > -1) {
+      return getLanguageAnnotation(checker, template, value);
+    } else if (template.startsWith("AI ")) {
+      IRI iri = ioHelper.createIRI(value);
+      return getIRIAnnotation(checker, template, iri);
+    } else {
+      return null;
     }
-    return iris;
   }
 
   /**
-   * Get a list of the IRIs defined in a template table.
+   * Get a set of annotation axioms for an OWLEntity. Supports axiom annotations and axiom
+   * annotation annotations.
    *
-   * @param tableName the name of the table
-   * @param rows the table of data
-   * @param ioHelper used to find entities by name
-   * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
+   * @param entity OWLEntity to annotation
+   * @param annotations Set of OWLAnnotations
+   * @param nested Map with top-level OWLAnnotation as key and another map (axiom OWLAnnotation, set
+   *     of axiom annotation OWLAnnotations) as value
+   * @return Set of OWLAnnotationAssertionAxioms
    */
-  public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
-      throws Exception {
-    // Find the ID column.
-    List<String> templates = rows.get(1);
-    int idColumn = -1;
-    for (int column = 0; column < templates.size(); column++) {
-      String template = templates.get(column);
-      if (template == null) {
-        continue;
-      }
-      template = template.trim();
-      if (template.equals("ID")) {
-        idColumn = column;
+  private static Set<OWLAnnotationAssertionAxiom> getAnnotationAxioms(
+      OWLEntity entity,
+      Set<OWLAnnotation> annotations,
+      Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested) {
+    Set<OWLAnnotationAssertionAxiom> axioms = new HashSet<>();
+    // Create basic annotations
+    for (OWLAnnotation annotation : annotations) {
+      axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(entity.getIRI(), annotation));
+    }
+    // Create annotations with axiom annotations
+    for (Entry<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> annotationPlusAxioms :
+        nested.entrySet()) {
+      OWLAnnotation annotation = annotationPlusAxioms.getKey();
+      Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+      // For each annotation with its axiom annotations ...
+      for (Entry<OWLAnnotation, Set<OWLAnnotation>> nestedSet :
+          annotationPlusAxioms.getValue().entrySet()) {
+        OWLAnnotation axiomAnnotation = nestedSet.getKey();
+        // Check if there are annotations on the axiom annotations, and add those
+        Set<OWLAnnotation> axiomAnnotationAnnotations = nestedSet.getValue();
+        if (axiomAnnotationAnnotations.isEmpty()) {
+          axiomAnnotations.add(axiomAnnotation);
+        } else {
+          OWLAnnotationProperty property = axiomAnnotation.getProperty();
+          OWLAnnotationValue value = axiomAnnotation.getValue();
+          axiomAnnotations.add(
+              dataFactory.getOWLAnnotation(property, value, axiomAnnotationAnnotations));
+        }
       }
+      axioms.add(
+          dataFactory.getOWLAnnotationAssertionAxiom(
+              entity.getIRI(), annotation, axiomAnnotations));
     }
-    if (idColumn == -1) {
-      throw new Exception("Template row must include an \"ID\" column " + "in table " + tableName);
-    }
+    return axioms;
+  }
 
-    List<IRI> iris = new ArrayList<IRI>();
-    for (int row = 2; row < rows.size(); row++) {
-      String id = null;
-      try {
-        id = rows.get(row).get(idColumn);
-      } catch (IndexOutOfBoundsException e) {
-        continue;
+  /**
+   * Get a set of OWLAxioms (subclass or equivalent) for an OWLClass. Supports axiom annotations.
+   *
+   * @param cls OWLClass to add axioms to
+   * @param classType subclass or equivalent
+   * @param classExpressions Set of OWLClassExpressions
+   * @param annotatedExpressions Map of annotated OWLClassExpressions and the Set of OWLAnnotations
+   * @return Set of OWLAxioms, or null if classType is not subclass or equivalent
+   */
+  private static Set<OWLAxiom> getLogicalAxioms(
+      OWLClass cls,
+      String classType,
+      Set<OWLClassExpression> classExpressions,
+      Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions) {
+    Set<OWLAxiom> axioms = new HashSet<>();
+    if (classType.equals("subclass")) {
+      for (OWLClassExpression expression : classExpressions) {
+        axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expression));
       }
-      if (id == null || id.trim().isEmpty()) {
-        continue;
+      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+          annotatedExpressions.entrySet()) {
+        axioms.add(
+            dataFactory.getOWLSubClassOfAxiom(cls, annotatedEx.getKey(), annotatedEx.getValue()));
       }
-      IRI iri = ioHelper.createIRI(id);
-      if (iri == null) {
-        continue;
+      return axioms;
+    } else if (classType.equals("equivalent")) {
+      // Since it's an intersection, all annotations will be added to the same axiom
+      Set<OWLAnnotation> annotations = new HashSet<>();
+      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+          annotatedExpressions.entrySet()) {
+        classExpressions.add(annotatedEx.getKey());
+        annotations.addAll(annotatedEx.getValue());
       }
-      iris.add(iri);
+      OWLObjectIntersectionOf intersection =
+          dataFactory.getOWLObjectIntersectionOf(classExpressions);
+      OWLAxiom axiom;
+      if (!annotations.isEmpty()) {
+        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, annotations);
+      } else {
+        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection);
+      }
+      return Sets.newHashSet(axiom);
+    } else {
+      return null;
     }
-
-    return iris;
   }
 }


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
index b0d24123..7dedaeb5 100644
--- a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
+++ b/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
@@ -98,9 +98,8 @@ public class TemplateOperationTest extends CoreTest {
     tables.put(path, IOHelper.readCSV(this.getClass().getResourceAsStream(path)));
     OWLOntology simpleParts = loadOntology("/simple_parts.owl");
     OWLOntology template = TemplateOperation.template(tables, simpleParts);
-    assertEquals("Count classes", 4, template.getClassesInSignature().size());
-    assertEquals("Count logical axioms", 3, template.getLogicalAxiomCount());
-    assertEquals("Count all axioms", 9, template.getAxiomCount());
+    OntologyHelper.setOntologyIRI(template, IRI.create("http://test.com/template.owl"), null);
+    assertIdentical("/template.owl", template);
   }
 
   /**
diff --git a/robot-core/src/test/resources/template.csv b/robot-core/src/test/resources/template.csv
index deea1b97..2ea0c73b 100644
--- a/robot-core/src/test/resources/template.csv
+++ b/robot-core/src/test/resources/template.csv
@@ -1,5 +1,5 @@
-Label,ID,Synonyms,Type,Parent,Parts
-A rdfs:label,ID,A IAO:0000118 SPLIT=|,CLASS_TYPE,C %,C part_of some %
+Label,Label Comment,ID,Synonyms,Type,Parent,Parts,Parts Annotation
+A rdfs:label,>AL rdfs:comment@en,ID,A IAO:0000118 SPLIT=|,CLASS_TYPE,C %,C part_of some %,>C rdfs:comment
 skip this row
-test 3,GO:1234,synonym 1|synonym 2,subclass,test2,test one
-test 4,GO:1235,,equivalent,test one,(test2 and 'test 3')
+test 3,test 3 comment,GO:1234,synonym 1|synonym 2,subclass,test2,test one,test one comment
+test 4,test 4 comment,GO:1235,,equivalent,test one,(test2 and 'test 3'),test2 and test 3 comment
diff --git a/robot-core/src/test/resources/template.owl b/robot-core/src/test/resources/template.owl
new file mode 100644
index 00000000..5b0866f0
--- /dev/null
+++ b/robot-core/src/test/resources/template.owl
@@ -0,0 +1,116 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="http://test.com/template.owl#"
+     xml:base="http://test.com/template.owl"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:xml="http://www.w3.org/XML/1998/namespace"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
+     xmlns:obo="http://purl.obolibrary.org/obo/">
+    <owl:Ontology rdf:about="http://test.com/template.owl"/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Classes
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/GO_1234 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/GO_1234">
+        <rdfs:subClassOf rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test2"/>
+        <rdfs:subClassOf>
+            <owl:Restriction>
+                <owl:onProperty rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#part_of"/>
+                <owl:someValuesFrom rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test1"/>
+            </owl:Restriction>
+        </rdfs:subClassOf>
+        <obo:IAO_0000118 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">synonym 1</obo:IAO_0000118>
+        <obo:IAO_0000118 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">synonym 2</obo:IAO_0000118>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test 3</rdfs:label>
+    </owl:Class>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://purl.obolibrary.org/obo/GO_1234"/>
+        <owl:annotatedProperty rdf:resource="http://www.w3.org/2000/01/rdf-schema#subClassOf"/>
+        <owl:annotatedTarget>
+            <owl:Restriction>
+                <owl:onProperty rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#part_of"/>
+                <owl:someValuesFrom rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test1"/>
+            </owl:Restriction>
+        </owl:annotatedTarget>
+        <rdfs:comment rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test one comment</rdfs:comment>
+    </owl:Axiom>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://purl.obolibrary.org/obo/GO_1234"/>
+        <owl:annotatedProperty rdf:resource="http://www.w3.org/2000/01/rdf-schema#label"/>
+        <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test 3</owl:annotatedTarget>
+        <rdfs:comment xml:lang="en">test 3 comment</rdfs:comment>
+    </owl:Axiom>
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/GO_1235 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/GO_1235">
+        <owl:equivalentClass>
+            <owl:Class>
+                <owl:intersectionOf rdf:parseType="Collection">
+                    <rdf:Description rdf:about="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test1"/>
+                    <owl:Restriction>
+                        <owl:onProperty rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#part_of"/>
+                        <owl:someValuesFrom>
+                            <owl:Class>
+                                <owl:intersectionOf rdf:parseType="Collection">
+                                    <rdf:Description rdf:about="http://purl.obolibrary.org/obo/GO_1234"/>
+                                    <rdf:Description rdf:about="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test2"/>
+                                </owl:intersectionOf>
+                            </owl:Class>
+                        </owl:someValuesFrom>
+                    </owl:Restriction>
+                </owl:intersectionOf>
+            </owl:Class>
+        </owl:equivalentClass>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test 4</rdfs:label>
+    </owl:Class>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://purl.obolibrary.org/obo/GO_1235"/>
+        <owl:annotatedProperty rdf:resource="http://www.w3.org/2002/07/owl#equivalentClass"/>
+        <owl:annotatedTarget>
+            <owl:Class>
+                <owl:intersectionOf rdf:parseType="Collection">
+                    <rdf:Description rdf:about="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test1"/>
+                    <owl:Restriction>
+                        <owl:onProperty rdf:resource="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#part_of"/>
+                        <owl:someValuesFrom>
+                            <owl:Class>
+                                <owl:intersectionOf rdf:parseType="Collection">
+                                    <rdf:Description rdf:about="http://purl.obolibrary.org/obo/GO_1234"/>
+                                    <rdf:Description rdf:about="https://github.com/ontodev/robot/robot-core/src/test/resources/simple.owl#test2"/>
+                                </owl:intersectionOf>
+                            </owl:Class>
+                        </owl:someValuesFrom>
+                    </owl:Restriction>
+                </owl:intersectionOf>
+            </owl:Class>
+        </owl:annotatedTarget>
+        <rdfs:comment rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test2 and test 3 comment</rdfs:comment>
+    </owl:Axiom>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://purl.obolibrary.org/obo/GO_1235"/>
+        <owl:annotatedProperty rdf:resource="http://www.w3.org/2000/01/rdf-schema#label"/>
+        <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test 4</owl:annotatedTarget>
+        <rdfs:comment xml:lang="en">test 4 comment</rdfs:comment>
+    </owl:Axiom>
+</rdf:RDF>
+
+
+
+<!-- Generated by the OWL API (version 4.2.6.20160910-2108) https://github.com/owlcs/owlapi -->
+