Repository: ontodev/robot
Pull request #1249 (2025-03-07 17:52:36)

Add support for predicate-object pairs to ROBOT template
Replaces #1205, building on work by @pk-mitre.

- [X] `docs/` have been added/updated
- [X] tests have been added/updated
- [X] `mvn verify` says all tests pass
- [ ] `mvn site` says all JavaDocs correct
- [ ] `CHANGELOG.md` has been updated

Add support for property-object pairs in a single cell of a ROBOT template:

`AP` annotation property-object pairs: If the template string is `AP` then ROBOT will look in each cell of that column for predicate-object pairs, with a syntax similar to Turtle, such as `ex:p1 'A'` or `ex:p2 '1234'^^xsd:integer`. Using `AP SPLIT=|` we can specify a list of annotations: `ex:p1 'A'|ex:p2 "B"@en|ex:p3 '3'^^xsd:integer|ex:p4 ex:curie|ex:p5 <http://ex.com>`.


Patch:
diff --git a/docs/examples/multi_annotations.owl b/docs/examples/multi_annotations.owl
new file mode 100644
index 00000000..8870a246
--- /dev/null
+++ b/docs/examples/multi_annotations.owl
@@ -0,0 +1,67 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="http://www.w3.org/2002/07/owl#"
+     xml:base="http://www.w3.org/2002/07/owl"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:xml="http://www.w3.org/XML/1998/namespace"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:acme="https://example.com/acme/"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#">
+    <Ontology/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Annotation properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- https://example.com/acme/p-1 -->
+
+    <AnnotationProperty rdf:about="https://example.com/acme/p-1"/>
+    
+
+
+    <!-- https://example.com/acme/p-2 -->
+
+    <AnnotationProperty rdf:about="https://example.com/acme/p-2"/>
+    
+
+
+    <!-- https://example.com/acme/p-3 -->
+
+    <AnnotationProperty rdf:about="https://example.com/acme/p-3"/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Classes
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- https://example.com/acme/s-1 -->
+
+    <Class rdf:about="https://example.com/acme/s-1">
+        <rdfs:label>ACME Communications INC</rdfs:label>
+        <acme:p-1> Value ZZ</acme:p-1>
+        <acme:p-2 rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">1234</acme:p-2>
+        <acme:p-3>FOO	BAR,BAZ,BAT</acme:p-3>
+    </Class>
+</rdf:RDF>
+
+
+
+<!-- Generated by the OWL API (version 4.5.29) https://github.com/owlcs/owlapi -->
+
diff --git a/docs/examples/multi_annotations.tsv b/docs/examples/multi_annotations.tsv
new file mode 100644
index 00000000..94024d8a
--- /dev/null
+++ b/docs/examples/multi_annotations.tsv
@@ -0,0 +1,2 @@
+Term	Term ID	Annotations
+ACME Communications INC	acme:s-1	"acme:p-1 " Value ZZ"|acme:p-2 "1234"^^xsd:integer|acme:p-3 'FOO	BAR,BAZ,BAT'"
diff --git a/docs/examples/multi_annotations_header.tsv b/docs/examples/multi_annotations_header.tsv
new file mode 100644
index 00000000..a1bdd819
--- /dev/null
+++ b/docs/examples/multi_annotations_header.tsv
@@ -0,0 +1,2 @@
+Term	Term ID	Annotations
+LABEL	ID	AP SPLIT=|
diff --git a/docs/template.md b/docs/template.md
index 30e72cf8..46e242c6 100644
--- a/docs/template.md
+++ b/docs/template.md
@@ -64,6 +64,7 @@ If `--force true` is not included with `--errors <path>`, ROBOT will exit with a
     - `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.
+    - `AP` annotation property-object pairs: If the template string is `AP` then ROBOT will look in the cell of that column for predicate-object pairs, with a syntax similar to Turtle, such as `ex:p1 'A'` or `ex:p2 '1234'^^xsd:integer`. Using `AP SPLIT=|` we can specify a list of annotations: `ex:p1 'A'|ex:p2 "B"@en|ex:p3 '3'^^xsd:integer|ex:p4 ex:curie|ex:p5 <http://ex.com>`.
 - `>A` (**axiom annotations**): ROBOT can also annotate logical and annotation axioms. The axiom annotation will be on the axiom created on the cell to the left of the `>A*` template string. The `>` symbol can be used in front of any valid annotation character (`>A`, `>AT`, `>AL`, `>AI`)
 
 Sometimes you want to include zero or more values in a single spreadsheet cell, for example when you want to allow for multiple annotations or have separate logical axioms. If a 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 characters of your choice -- other than pure whitespace -- to split on (e.g. `SPLIT=, `).
@@ -223,24 +224,22 @@ robot template --merge-before \
   --output results/test_template.owl
 ```
 
-ROBOT template data read from separate external file
+\* NOTE: the imports would be merged into the output if `--collapse-import-closure true` is included instead.
+
+ROBOT can read template header data from an `--external-template` file:
 
     robot template --template animals_template.tsv \
         --external-template animals_ext_template.tsv \
         --output results/animals_ext_template.owl
 
-Adjusted line numbers for error reporting for template data read from separate external file
-<!-- DO NOT TEST -->
-```
-robot template --template animals_template_error.tsv \
-  --ext-template animals_ext_template.tsv \
-  --output results/animals_ext_template.owl
-```
-
+Multiple annotations can be specified in a single column, using the `AP` in the template header, and in the cell a syntax similar to Turtle `ex:p-1 'A'|ex:p-2 '1234'^^xsd:integer|ex:p-3 ex:curie`:
 
-\* NOTE: the imports would be merged into the output if `--collapse-import-closure true` is included instead.
+    robot template --prefix "acme: https://example.com/acme/" \
+        --template multi_annotations.tsv \
+        --external-template multi_annotations_header.tsv
+        --output results/multi_annotations.owl
 
-Further examples can be found [in the OBI repository](https://github.com/obi-ontology/obi/tree/master/src/ontology/templates)
+Further template examples can be found [in the OBI repository](https://github.com/obi-ontology/obi/tree/master/src/ontology/templates)
 
 ---
 
diff --git a/robot-core/src/main/java/org/obolibrary/robot/Template.java b/robot-core/src/main/java/org/obolibrary/robot/Template.java
index 2e271261..f876a36a 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/Template.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/Template.java
@@ -877,7 +877,6 @@ public class Template {
       if (value == null || value.trim().isEmpty()) {
         continue;
       }
-
       if (template.startsWith("A") || template.startsWith("LABEL")) {
         // Handle class annotations
         Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
index 1c5e8efa..ec2b779a 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
@@ -53,6 +53,11 @@ public class TemplateHelper {
   /** Namespace for error messages. */
   private static final String NS = "template#";
 
+  /** Error message when an AP annotation string does not match any pattern. Expects:value string */
+  private static final String annotationParseError =
+      NS
+          + "ANNOTATION PARSE ERROR invalid annotation string at row %d, column %d in table \"%s\": %s";
+
   /**
    * Error message when annotation property cannot be resolved. Expects: annotation property name.
    */
@@ -186,8 +191,9 @@ public class TemplateHelper {
     if (template.startsWith(">")) {
       template = template.substring(1);
     }
-
-    if (template.startsWith("A ") || template.startsWith("C ")) {
+    if (template.equals("AP")) {
+      return getAnnotationPairs(checker, template, split, value, tableName, rowNum, column);
+    } else if (template.startsWith("A ") || template.startsWith("C ")) {
       return getStringAnnotations(checker, template, split, value, column);
     } else if (template.startsWith("AT ") || template.startsWith("CT ")) {
       if (template.contains("^^")) {
@@ -698,6 +704,145 @@ public class TemplateHelper {
     return expressions;
   }
 
+  /**
+   * Return a set of annotations for the given AP template string and value(s).
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param split the character to split values on
+   * @param value the value for the annotation
+   * @param column the index of the column
+   * @return a set of new annotation(s) with property and string literal value
+   * @throws RowParseException if the annotation property cannot be found
+   */
+  public static Set<OWLAnnotation> getAnnotationPairs(
+      QuotedEntityChecker checker,
+      String template,
+      String split,
+      String value,
+      String tableName,
+      int rowNum,
+      int column)
+      throws Exception {
+    Set<OWLAnnotation> annotations = new HashSet<>();
+
+    Pattern stringSinglePattern = Pattern.compile("^(.+)\\s+'([^']+)'$");
+    Pattern stringDoublePattern = Pattern.compile("^(.+)\\s+\"([^\"]+)\"$");
+    Pattern langSinglePattern = Pattern.compile("^(.+)\\s+'([^']+)'@(\\w+)$");
+    Pattern langDoublePattern = Pattern.compile("^(.+)\\s+\"([^\"]+)\"@(\\w+)$");
+    Pattern typeSinglePattern = Pattern.compile("^(.+)\\s+'([^']+)'\\^\\^(\\S+)$");
+    Pattern typeDoublePattern = Pattern.compile("^(.+)\\s+\"([^\"]+)\"\\^\\^(\\S+)$");
+    Pattern iriPattern = Pattern.compile("^(.+)\\s+<(\\S+)>$");
+    Pattern curiePattern = Pattern.compile("^(.+)\\s+(.+)$");
+
+    String[] pairs = new String[] {value};
+    if (split != null) {
+      pairs = value.split(Pattern.quote(split));
+    }
+
+    for (String pair : pairs) {
+      pair = pair.trim();
+      Matcher matcher = stringSinglePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+      matcher = stringDoublePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+
+      matcher = langSinglePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        String lang = matcher.group(3);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical, lang);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+      matcher = langDoublePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        String lang = matcher.group(3);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical, lang);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+
+      matcher = typeSinglePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        String typeName = matcher.group(3);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLDatatype datatype = getDatatype(tableName, checker, typeName, rowNum, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical, datatype);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+      matcher = typeDoublePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String lexical = matcher.group(2);
+        String typeName = matcher.group(3);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        OWLDatatype datatype = getDatatype(tableName, checker, typeName, rowNum, column);
+        OWLLiteral literal = dataFactory.getOWLLiteral(lexical, datatype);
+        annotations.add(dataFactory.getOWLAnnotation(property, literal));
+        continue;
+      }
+
+      matcher = iriPattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String iriString = matcher.group(2);
+        IRI iri = checker.getIRI(iriString, true);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        annotations.add(dataFactory.getOWLAnnotation(property, iri));
+        continue;
+      }
+
+      matcher = curiePattern.matcher(pair);
+      if (matcher.matches()) {
+        String propertyName = matcher.group(1);
+        String curie = matcher.group(2);
+        // System.out.println(String.format("[%s] [%s] [%s]", pair, propertyName, curie));
+        IRI iri = checker.getIRI(curie, true);
+        OWLAnnotationProperty property = getAnnotationProperty(checker, propertyName, column);
+        if (iri == null) {
+          throw new RowParseException(
+              String.format(annotationParseError, rowNum, column + 1, tableName, pair),
+              rowNum,
+              column + 1,
+              pair);
+        }
+        annotations.add(dataFactory.getOWLAnnotation(property, iri));
+        continue;
+      }
+
+      throw new RowParseException(
+          String.format(annotationParseError, rowNum, column + 1, tableName, pair),
+          rowNum,
+          column + 1,
+          pair);
+    }
+
+    return annotations;
+  }
+
   /**
    * Return a set of string annotations for the given template string and value(s).
    *
@@ -712,8 +857,9 @@ public class TemplateHelper {
   public static Set<OWLAnnotation> getStringAnnotations(
       QuotedEntityChecker checker, String template, String split, String value, int column)
       throws Exception {
-
+    Set<OWLAnnotation> annotations = new HashSet<>();
     OWLAnnotationProperty property;
+
     if (template.equals("LABEL")) {
       // Handle special LABEL case
       property = dataFactory.getRDFSLabel();
@@ -722,7 +868,6 @@ public class TemplateHelper {
       property = getAnnotationProperty(checker, name, column);
     }
 
-    Set<OWLAnnotation> annotations = new HashSet<>();
     if (split != null) {
       String[] values = value.split(Pattern.quote(split));
       for (String v : values) {
@@ -930,6 +1075,9 @@ public class TemplateHelper {
     } else if (template.matches("^(P .*|PI.?|[SEDI]P .*)")) {
       // Properties can be P, PI (does not need to be followed by space), SP, EP, DP, or IP
       return true;
+    } else if (template.equals("AP") || template.matches("^AP SPLIT=.+$")) {
+      // AP multi-annotation type
+      return true;
     } else
       // Individuals can be I, II (does not need to be followed by space), SI, or DI
       return template.matches("^(I .*|II.?|[TSD]I .*)");


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java
index 5d97f63a..d630521a 100644
--- a/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java
+++ b/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java
@@ -5,6 +5,7 @@ import static org.junit.Assert.fail;
 
 import com.google.common.collect.Sets;
 import java.io.IOException;
+import java.util.HashSet;
 import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
@@ -116,6 +117,47 @@ public class TemplateHelperTest extends CoreTest {
     for (OWLAnnotation a : annotations) {
       assertEquals(annMatch, a);
     }
+
+    // Annotation Properties
+    template = "AP";
+    value = "obo:p1 'A'";
+    annotations = TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    OWLLiteral o;
+    Set<OWLAnnotation> expected = new HashSet<>();
+    p = checker.getOWLAnnotationProperty("obo:p1");
+    o = dataFactory.getOWLLiteral("A");
+    expected.add(dataFactory.getOWLAnnotation(p, o));
+
+    // Annotation Properties with SPLIT
+    template = "AP SPLIT=|";
+    value =
+        "obo:p1 'A'"
+            + "|obo:p2 \"B\"@en"
+            + "|obo:p3 '3'^^xsd:integer"
+            + "|obo:p4 obo:curie"
+            + "|obo:p5 <http://ex.com>";
+    annotations = TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    expected = new HashSet<>();
+    p = checker.getOWLAnnotationProperty("obo:p1");
+    o = dataFactory.getOWLLiteral("A");
+    expected.add(dataFactory.getOWLAnnotation(p, o));
+    p = checker.getOWLAnnotationProperty("obo:p2");
+    o = dataFactory.getOWLLiteral("B", "en");
+    expected.add(dataFactory.getOWLAnnotation(p, o));
+    p = checker.getOWLAnnotationProperty("obo:p3");
+    OWLDatatype datatype = checker.getOWLDatatype("xsd:integer");
+    o = dataFactory.getOWLLiteral("3", datatype);
+    expected.add(dataFactory.getOWLAnnotation(p, o));
+    p = checker.getOWLAnnotationProperty("obo:p4");
+    iri = checker.getIRI("obo:curie", true);
+    expected.add(dataFactory.getOWLAnnotation(p, iri));
+    p = checker.getOWLAnnotationProperty("obo:p5");
+    iri = IRI.create("http://ex.com");
+    expected.add(dataFactory.getOWLAnnotation(p, iri));
+
+    assertEquals(expected, annotations);
   }
 
   /**